From a44eaa781050183c040020237a1c696877e6957a Mon Sep 17 00:00:00 2001 From: Chris Ren Date: Sun, 21 Dec 2025 20:17:05 -0800 Subject: [PATCH 1/7] feat: add keepPreviousData option to eliminate navigation flash - Add keepPreviousData option (default: true) to useSubscribe - Preserve previous snapshot during dependency transitions - Eliminates UI flash when switching between subscriptions - Bump version to 6.1.0 --- package.json | 2 +- src/index.ts | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 06bcac0..03bf5da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "replicache-react", - "version": "6.0.0", + "version": "6.1.0", "description": "Miscellaneous utilities for using Replicache with React", "homepage": "https://replicache.dev", "repository": "github:rocicorp/replicache-react", diff --git a/src/index.ts b/src/index.ts index f1a1057..c67bc7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import {DependencyList, useEffect, useState} from 'react'; +import {DependencyList, useEffect, useRef, useState} from 'react'; export type Subscribable = { subscribe( @@ -33,6 +33,12 @@ export type UseSubscribeOptions = { default?: Default; dependencies?: DependencyList | undefined; isEqual?: ((a: QueryRet, b: QueryRet) => boolean) | undefined; + /** + * When true (default), preserves the previous snapshot value during dependency + * transitions instead of resetting to undefined. This eliminates UI flash when + * switching between subscriptions with different dependencies. + */ + keepPreviousData?: boolean; }; /** @@ -50,8 +56,10 @@ export function useSubscribe( query: (tx: Tx) => Promise, options: UseSubscribeOptions = {}, ): RemoveUndefined | Default { - const {default: def, dependencies = [], isEqual} = options; + const {default: def, dependencies = [], isEqual, keepPreviousData = true} = options; const [snapshot, setSnapshot] = useState(undefined); + const prevSnapshotRef = useRef(undefined); + useEffect(() => { if (!r) { return; @@ -59,6 +67,8 @@ export function useSubscribe( const unsubscribe = r.subscribe(query, { onData: data => { + // Track the previous value for keepPreviousData feature + prevSnapshotRef.current = data; // This is safe because we know that subscribe in fact can only return // `R` (the return type of query or def). callbacks.push(() => setSnapshot(data)); @@ -72,10 +82,18 @@ export function useSubscribe( return () => { unsubscribe(); - setSnapshot(undefined); + // Only reset state if keepPreviousData is false + if (!keepPreviousData) { + setSnapshot(undefined); + } }; }, [r, ...dependencies]); + + // Return previous data while new subscription initializes (eliminates flash) if (snapshot === undefined) { + if (keepPreviousData && prevSnapshotRef.current !== undefined) { + return prevSnapshotRef.current as RemoveUndefined; + } return def as Default; } return snapshot as RemoveUndefined; From 5d772773301cf34a1f5e8d780406d6d9301bbad8 Mon Sep 17 00:00:00 2001 From: Chris Ren Date: Sun, 21 Dec 2025 20:21:38 -0800 Subject: [PATCH 2/7] test: update test expectations for keepPreviousData behavior --- src/index.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.test.tsx b/src/index.test.tsx index a764b4f..c7316b8 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -212,7 +212,9 @@ test('changing subscribable instances', async () => { const {resolve: r3} = resolver(); render(, div); await sleep(1); - expect(div.textContent).toBe(''); + // With keepPreviousData=true (default), previous data is preserved when rep becomes undefined + // So subResult is 'b' (previous value), not undefined, thus val='c' is rendered + expect(div.textContent).toBe('c'); await rep1.close(); await rep2.close(); From 549a30ecbc2e23db43e549992ec4c530d1057992 Mon Sep 17 00:00:00 2001 From: Chris Ren Date: Sun, 21 Dec 2025 20:42:39 -0800 Subject: [PATCH 3/7] feat(keepPreviousData): add comprehensive safety improvements and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safety improvements: - Add generation counter to prevent race conditions with stale subscriptions - Add isMounted guard to prevent setState after unmount - Detect transitions during render for immediate default when keepPreviousData=false - Clear prevSnapshotRef in cleanup when keepPreviousData is false - Capture current snapshot when deps change (shows most recent data, not oldest) New tests: - Explicit keepPreviousData: true/false behavior tests - Rapid dependency changes (A → B → C) race condition test - Previous data was null test - Multiple transitions (A → B → A → null → B) test - Generation counter ignores stale data test Documentation: - Comprehensive JSDoc for useSubscribe, Subscribable, UseSubscribeOptions - Updated README with React 19+ fork section and keepPreviousData feature docs - Fixed RemoveUndefined type to use Exclude --- README.md | 96 +++++++++++ package-lock.json | 302 +---------------------------------- src/index.test.tsx | 385 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 140 ++++++++++++++++- 4 files changed, 618 insertions(+), 305 deletions(-) diff --git a/README.md b/README.md index 519b815..859931a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,31 @@ Provides a `useSubscribe()` hook for React which wraps Replicache's `subscribe()` method. +## React 19+ Fork + +This is a **React 19+ compatible fork** of the official `replicache-react` package. Key differences: + +- **Removes deprecated batching APIs**: No longer relies on `unstable_batchedUpdates`, leveraging React 19's automatic batching instead +- **Adds `keepPreviousData` option**: Eliminates UI flash during navigation by preserving data across subscription transitions +- **Requires React 19+**: Takes advantage of improved batching behavior in React 19 + +### Installation + +```bash +npm install @renchris/replicache-react +# or via GitHub +npm install renchris/replicache-react#feat/react-19-automatic-batching +``` + +**Peer Dependencies**: React 19+ + +### Migration from Official Package + +1. Update to React 19+ +2. Replace `replicache-react` with `@renchris/replicache-react` in your `package.json` +3. No code changes required - API is fully compatible +4. Optional: Use new `keepPreviousData` option to prevent UI flash during navigation + ## API ### function useSubscribe @@ -22,6 +47,7 @@ React hook that allows you monitor replicache changes | `.default?` | `R \| undefined = undefined` | Default value returned on first render _or_ whenever `query` returns `undefined` | | `.dependencies?` | `Array = []` | List of dependencies, query will be rerun when any of these change | | `.isEqual?` | `((a: R, b: R) => boolean) = jsonDeepEqual` | Compare two returned values. Used to know whether to refire subscription. | +| `.keepPreviousData?` | `boolean = true` | When `true` (default), preserves previous data during dependency transitions to eliminate UI flash. Set to `false` to reset immediately. | ## Usage @@ -52,8 +78,78 @@ return ( ); ``` +## New Feature: `keepPreviousData` + +The `keepPreviousData` option (default: `true`) eliminates UI flash when navigating between views with different subscription dependencies. + +### Problem Without `keepPreviousData` + +When switching between subscriptions (e.g., navigating between categories), the hook traditionally resets to `undefined` or the default value, causing a brief flash of empty content before new data loads: + +```typescript +// User switches from category "work" to "personal" +// 1. Hook unsubscribes from "work" data → returns default: [] +// 2. UI renders empty list (FLASH!) +// 3. Hook subscribes to "personal" data +// 4. UI renders "personal" todos +``` + +### Solution With `keepPreviousData: true` (Default) + +The hook preserves the previous subscription's data while the new subscription initializes: + +```typescript +// User switches from category "work" to "personal" +// 1. Hook unsubscribes from "work" data → KEEPS "work" data displayed +// 2. Hook subscribes to "personal" data +// 3. UI renders "personal" todos (NO FLASH!) +``` + +### Example + +```typescript +const todos = useSubscribe( + rep, + tx => getTodosByCategory(tx, category), + { + default: [], + dependencies: [category], + keepPreviousData: true, // Default - can be omitted + } +); + +// When category changes: +// - Old behavior: Shows [] briefly → new data +// - New behavior: Shows old data → new data (smooth transition) +``` + +### When to Disable + +Set `keepPreviousData: false` if you want to explicitly show the default value during transitions: + +```typescript +const todos = useSubscribe( + rep, + tx => getTodosByCategory(tx, category), + { + default: [], + dependencies: [category], + keepPreviousData: false, // Show [] during category switch + } +); +``` + ## Changelog +### 6.1.0 (React 19+ Fork) + +- **NEW**: Add `keepPreviousData` option (default: `true`) to eliminate UI flash during subscription transitions +- **Enhancement**: Add generation counter to prevent stale subscription callbacks +- **Enhancement**: Add isMounted guard to prevent setState after unmount +- **Enhancement**: Improve type safety with `Exclude` instead of conditional type +- **Enhancement**: Add comprehensive JSDoc documentation with examples +- Requires React 19+ + ### 6.0.0 Remove `unstable_batchedUpdates` - no longer needed with React 19's automatic batching. diff --git a/package-lock.json b/package-lock.json index a78dd4a..9396356 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "replicache-react", - "version": "6.0.0", + "name": "@renchris/replicache-react", + "version": "6.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "replicache-react", - "version": "6.0.0", + "name": "@renchris/replicache-react", + "version": "6.1.0", "license": "ISC", "devDependencies": { "@rocicorp/resolver": "^1.0.2", @@ -288,34 +288,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", @@ -330,272 +302,6 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", diff --git a/src/index.test.tsx b/src/index.test.tsx index c7316b8..add4e97 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -372,3 +372,388 @@ test.skip('using isEqual [type checking]', () => { use(s); } }); + +test('keepPreviousData: true - preserves data when switching instances', async () => { + const {promise: p1, resolve: r1} = resolver(); + const {promise: p2, resolve: r2} = resolver(); + + function A({ + rep, + val, + res, + }: { + rep: Replicache | null | undefined; + val: string; + res: () => void; + }) { + const subResult = useSubscribe( + rep, + async () => { + res(); + await sleep(10); // Small delay to ensure async behavior + return val; + }, + {default: 'default', keepPreviousData: true}, + ); + return
{subResult}
; + } + + const div = document.createElement('div'); + + // Initial render with no rep + render(
{}} />, div); + expect(div.textContent).toBe('default'); + + // First instance + const rep1 = new Replicache({ + name: 'keep-prev-data-1', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + render(, div); + expect(div.textContent).toBe('default'); // Still showing default + await p1; + await sleep(20); + expect(div.textContent).toBe('data1'); // Now showing data1 + + // Switch to second instance - should keep showing data1 until data2 arrives + const rep2 = new Replicache({ + name: 'keep-prev-data-2', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + render(, div); + expect(div.textContent).toBe('data1'); // PRESERVED previous data + await p2; + await sleep(20); + expect(div.textContent).toBe('data2'); // Now showing new data + + await rep1.close(); + await rep2.close(); +}); + +test('keepPreviousData: false - resets to default when switching instances', async () => { + const {promise: p1, resolve: r1} = resolver(); + const {promise: p2, resolve: r2} = resolver(); + + function A({ + rep, + val, + res, + }: { + rep: Replicache | null | undefined; + val: string; + res: () => void; + }) { + const subResult = useSubscribe( + rep, + async () => { + res(); + await sleep(10); + return val; + }, + {default: 'default', keepPreviousData: false}, + ); + return
{subResult}
; + } + + const div = document.createElement('div'); + + // First instance + const rep1 = new Replicache({ + name: 'no-keep-prev-data-1', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + render(
, div); + expect(div.textContent).toBe('default'); + await p1; + await sleep(20); + expect(div.textContent).toBe('data1'); + + // Switch to second instance - should reset to default immediately + const rep2 = new Replicache({ + name: 'no-keep-prev-data-2', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + render(, div); + expect(div.textContent).toBe('default'); // RESET to default, not 'data1' + await p2; + await sleep(20); + expect(div.textContent).toBe('data2'); + + await rep1.close(); + await rep2.close(); +}); + +test('keepPreviousData: rapid dependency changes (A → B → C)', async () => { + const {promise: pA, resolve: rA} = resolver(); + const {promise: pB, resolve: rB} = resolver(); + const {promise: pC, resolve: rC} = resolver(); + + const resolvers = {a: rA, b: rB, c: rC}; + + function A({ + rep, + val, + }: { + rep: Replicache | null | undefined; + val: keyof typeof resolvers; + }) { + const subResult = useSubscribe( + rep, + async () => { + resolvers[val](); + await sleep(30); // Longer delay to simulate slow query + return `data-${val}`; + }, + {default: 'default', keepPreviousData: true}, + ); + return
{subResult}
; + } + + const div = document.createElement('div'); + + const repA = new Replicache({ + name: 'rapid-change-a', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + const repB = new Replicache({ + name: 'rapid-change-b', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + const repC = new Replicache({ + name: 'rapid-change-c', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + // Render A + render(
, div); + await pA; + await sleep(40); + expect(div.textContent).toBe('data-a'); + + // Rapidly switch A → B → C without waiting + render(, div); + await pB; // B query starts + render(, div); + await pC; // C query starts + + // Wait for all queries to potentially complete + await sleep(100); + + // Should show C's data, not stale data from A or B + expect(div.textContent).toBe('data-c'); + + await repA.close(); + await repB.close(); + await repC.close(); +}); + +test('keepPreviousData: previous data was null', async () => { + const {promise: p1, resolve: r1} = resolver(); + const {promise: p2, resolve: r2} = resolver(); + + function A({ + rep, + val, + res, + }: { + rep: Replicache | null | undefined; + val: string | null; + res: () => void; + }) { + const subResult = useSubscribe( + rep, + async () => { + res(); + await sleep(10); + return val; + }, + {default: 'default', keepPreviousData: true}, + ); + return
{String(subResult)}
; + } + + const div = document.createElement('div'); + + // First instance returns null + const rep1 = new Replicache({ + name: 'null-prev-data-1', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + render(
, div); + await p1; + await sleep(20); + expect(div.textContent).toBe('null'); // null is valid data (not undefined) + + // Switch to second instance - should preserve null + const rep2 = new Replicache({ + name: 'null-prev-data-2', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + render(, div); + expect(div.textContent).toBe('null'); // PRESERVED null as previous data + await p2; + await sleep(20); + expect(div.textContent).toBe('data2'); + + await rep1.close(); + await rep2.close(); +}); + +test('keepPreviousData: multiple transitions (A → B → A → null → B)', async () => { + function A({ + rep, + val, + onQuery, + }: { + rep: Replicache | null | undefined; + val: string; + onQuery?: () => void; + }) { + const subResult = useSubscribe( + rep, + async () => { + onQuery?.(); + await sleep(10); + return val; + }, + {default: 'default', keepPreviousData: true, dependencies: [val]}, + ); + return
{subResult}
; + } + + const div = document.createElement('div'); + + const repA = new Replicache({ + name: 'multi-transition-a', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + const repB = new Replicache({ + name: 'multi-transition-b', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + // Transition 1: null → A + const {promise: p1, resolve: r1} = resolver(); + render(
, div); + await p1; + await sleep(20); + expect(div.textContent).toBe('dataA'); + + // Transition 2: A → B + const {promise: p2, resolve: r2} = resolver(); + render(, div); + expect(div.textContent).toBe('dataA'); // Keep A's data + await p2; + await sleep(20); + expect(div.textContent).toBe('dataB'); + + // Transition 3: B → A (same repA but different val triggers re-subscribe via dependencies) + const {promise: p3, resolve: r3} = resolver(); + render(, div); + expect(div.textContent).toBe('dataB'); // Keep B's data + await p3; + await sleep(20); + expect(div.textContent).toBe('dataA2'); + + // Transition 4: A → null + render(, div); + expect(div.textContent).toBe('dataA2'); // Keep A's data even when rep is null + + // Transition 5: null → B + const {promise: p5, resolve: r5} = resolver(); + render(, div); + expect(div.textContent).toBe('dataA2'); // Still keeping previous data + await p5; + await sleep(20); + expect(div.textContent).toBe('dataB2'); + + await repA.close(); + await repB.close(); +}); + +test('keepPreviousData: generation counter ignores stale data', async () => { + const queryDelays: Record = { + fast: 10, + slow: 100, + }; + + const {promise: pSlow, resolve: rSlow} = resolver(); + const {promise: pFast, resolve: rFast} = resolver(); + + function A({ + rep, + speed, + }: { + rep: Replicache | null | undefined; + speed: keyof typeof queryDelays; + }) { + const subResult = useSubscribe( + rep, + async () => { + if (speed === 'slow') { + rSlow(); + } else { + rFast(); + } + await sleep(queryDelays[speed]); + return `${speed}-data`; + }, + {default: 'default', keepPreviousData: true}, + ); + return
{subResult}
; + } + + const div = document.createElement('div'); + + const repSlow = new Replicache({ + name: 'race-condition-slow', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + const repFast = new Replicache({ + name: 'race-condition-fast', + licenseKey: TEST_LICENSE_KEY, + mutators: {}, + }); + + // Start slow query + render(
, div); + await pSlow; // Slow query started (will take 100ms) + expect(div.textContent).toBe('default'); + + // Immediately switch to fast query (before slow completes) + render(, div); + await pFast; // Fast query started (will take 10ms) + + // Wait for fast to complete + await sleep(30); + expect(div.textContent).toBe('fast-data'); + + // Wait for slow to complete (stale subscription) + await sleep(100); + + // Should still show fast-data, NOT slow-data + // The generation counter should have ignored the stale slow query result + expect(div.textContent).toBe('fast-data'); + + await repSlow.close(); + await repFast.close(); +}); diff --git a/src/index.ts b/src/index.ts index c67bc7c..199d047 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,30 @@ import {DependencyList, useEffect, useRef, useState} from 'react'; +/** + * Interface for objects that support reactive subscriptions to query results. + * + * @template Tx - The transaction type used by the subscribable object + * + * @example + * ```typescript + * const replicache: Subscribable = new Replicache({...}); + * const unsubscribe = replicache.subscribe( + * async (tx) => await tx.get('key'), + * { onData: (data) => console.log(data) } + * ); + * ``` + */ export type Subscribable = { + /** + * Subscribe to query results. The query will be re-run whenever dependencies change. + * + * @template Data - The return type of the query + * @param query - Async function that runs the query using the transaction + * @param options - Subscription options + * @param options.onData - Callback invoked with query results + * @param options.isEqual - Optional equality function to prevent unnecessary updates + * @returns Unsubscribe function to clean up the subscription + */ subscribe( query: (tx: Tx) => Promise, options: { @@ -10,9 +34,9 @@ export type Subscribable = { ): () => void; }; -// React 19+ has automatic batching for all updates (including microtasks), -// so we no longer need unstable_batchedUpdates. We still batch via microtask -// to ensure we do not render more than once over all changed subscriptions. +// React 19+ has automatic batching for all updates (including microtasks). +// We batch state updates via microtask to ensure we do not render more than +// once over all changed subscriptions. let hasPendingCallback = false; let callbacks: (() => void)[] = []; @@ -26,17 +50,51 @@ function doCallback() { } } -export type RemoveUndefined = T extends undefined ? never : T; +/** + * Removes `undefined` from a union type. + * + * @example + * ```typescript + * type A = RemoveUndefined; // string + * type B = RemoveUndefined; // number | null + * ``` + */ +export type RemoveUndefined = Exclude; +/** + * Options for configuring the `useSubscribe` hook. + * + * @template QueryRet - The return type of the query function + * @template Default - The type of the default value (defaults to `undefined`) + */ export type UseSubscribeOptions = { - /** Default can already be undefined since it is an unbounded type parameter. */ + /** + * Default value to return while the query is loading or when `r` is null/undefined. + * Can be undefined since it is an unbounded type parameter. + */ default?: Default; + /** + * Dependencies array similar to `useEffect`. When these change, the subscription + * will be re-created. By default, only changes to `r` trigger re-subscription. + */ dependencies?: DependencyList | undefined; + /** + * Custom equality function to determine if query results have changed. + * If not provided, uses reference equality (`===`). + * + * @param a - Previous query result + * @param b - New query result + * @returns `true` if values are equal (prevents re-render), `false` otherwise + */ isEqual?: ((a: QueryRet, b: QueryRet) => boolean) | undefined; /** - * When true (default), preserves the previous snapshot value during dependency + * When `true` (default), preserves the previous snapshot value during dependency * transitions instead of resetting to undefined. This eliminates UI flash when * switching between subscriptions with different dependencies. + * + * Set to `false` to reset to the default value on every subscription change. + * + * @default true */ keepPreviousData?: boolean; }; @@ -50,6 +108,30 @@ export type UseSubscribeOptions = { * values are often object/array/function literals which change on every * render. If you want to re-run the query when these change, you can pass * them as dependencies. + * + * @param r - The Replicache instance to subscribe to (or null/undefined) + * @param query - The query function to run against the Replicache transaction + * @param options - Configuration options + * @param options.default - Default value returned before first data or when query returns undefined + * @param options.dependencies - Additional dependencies that trigger re-subscription when changed + * @param options.isEqual - Custom equality function to compare query results + * @param options.keepPreviousData - When true (default), preserves previous data during + * dependency transitions instead of resetting to undefined. This eliminates UI flash + * when switching between subscriptions. Set to false to reset immediately. + * + * @example + * // Basic usage + * const todos = useSubscribe(rep, tx => tx.scan({prefix: '/todo'}).values().toArray(), { + * default: [], + * }); + * + * @example + * // With dependencies - re-subscribes when category changes, no flash + * const todos = useSubscribe(rep, tx => getTodosByCategory(tx, category), { + * default: [], + * dependencies: [category], + * keepPreviousData: true, // default, can omit + * }); */ export function useSubscribe( r: Subscribable | null | undefined, @@ -59,19 +141,52 @@ export function useSubscribe( const {default: def, dependencies = [], isEqual, keepPreviousData = true} = options; const [snapshot, setSnapshot] = useState(undefined); const prevSnapshotRef = useRef(undefined); + const generationRef = useRef(0); + // Track the subscribable to detect transitions during render (before effect runs) + const prevRRef = useRef(undefined); + // Track deps to detect dependency changes during render + const prevDepsRef = useRef([]); + + // Detect if we're in a transition (r or deps changed since last effect) + const depsChanged = dependencies.length !== prevDepsRef.current.length || + dependencies.some((dep, i) => !Object.is(dep, prevDepsRef.current[i])); + const hasTransitioned = (r !== prevRRef.current || depsChanged) && prevRRef.current !== undefined; useEffect(() => { + // Update refs after effect runs + prevRRef.current = r; + prevDepsRef.current = dependencies; + if (!r) { return; } + // Capture current snapshot when deps change (fixes showing oldest data instead of most recent) + if (keepPreviousData && snapshot !== undefined) { + prevSnapshotRef.current = snapshot; + } + + // Increment generation counter to invalidate stale subscription callbacks + const currentGen = ++generationRef.current; + let isMounted = true; + const unsubscribe = r.subscribe(query, { onData: data => { + // Ignore callbacks from stale subscriptions + if (generationRef.current !== currentGen) { + return; + } + // Track the previous value for keepPreviousData feature prevSnapshotRef.current = data; // This is safe because we know that subscribe in fact can only return // `R` (the return type of query or def). - callbacks.push(() => setSnapshot(data)); + callbacks.push(() => { + // Prevent setState after unmount + if (isMounted) { + setSnapshot(data); + } + }); if (!hasPendingCallback) { void Promise.resolve().then(doCallback); hasPendingCallback = true; @@ -81,20 +196,31 @@ export function useSubscribe( }); return () => { + isMounted = false; unsubscribe(); // Only reset state if keepPreviousData is false if (!keepPreviousData) { setSnapshot(undefined); + prevSnapshotRef.current = undefined; } }; }, [r, ...dependencies]); + // Handle transitions: when deps changed and keepPreviousData is false, show default immediately + if (hasTransitioned && !keepPreviousData) { + // Safe: def is Default type by parameter definition + return def as Default; + } + // Return previous data while new subscription initializes (eliminates flash) if (snapshot === undefined) { if (keepPreviousData && prevSnapshotRef.current !== undefined) { + // Safe: prevSnapshotRef holds QueryRet from previous successful subscription return prevSnapshotRef.current as RemoveUndefined; } + // Safe: def is Default type by parameter definition return def as Default; } + // Safe: snapshot is QueryRet (not undefined) after the guard above return snapshot as RemoveUndefined; } From 700196ef2a3276ac79a3afc5b4106f765db796b5 Mon Sep 17 00:00:00 2001 From: Chris Ren Date: Sun, 21 Dec 2025 20:52:18 -0800 Subject: [PATCH 4/7] fix: apply audit improvements for robustness - Fix memory leak: always clear prevSnapshotRef on unmount - Fix unsafe spread: use (dependencies ?? []) for null safety - Fix edge case: add hasRunEffectRef to properly detect initial mount vs transitions (r=undefined at start is now handled correctly) - Add try-catch in doCallback: isolate errors so one bad callback doesn't block others - Improve types: use ReadonlyArray for prevDepsRef --- src/index.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 199d047..238b809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,12 @@ function doCallback() { callbacks = []; hasPendingCallback = false; for (const callback of cbs) { - callback(); + try { + callback(); + } catch (e) { + // Log but don't let one bad callback block others + console.error('useSubscribe callback error:', e); + } } } @@ -145,14 +150,19 @@ export function useSubscribe( // Track the subscribable to detect transitions during render (before effect runs) const prevRRef = useRef(undefined); // Track deps to detect dependency changes during render - const prevDepsRef = useRef([]); + const prevDepsRef = useRef>([]); + // Track whether effect has run at least once (to detect initial vs transition) + const hasRunEffectRef = useRef(false); // Detect if we're in a transition (r or deps changed since last effect) + // Only consider it a transition after the first effect run (hasRunEffectRef guards initial mount) const depsChanged = dependencies.length !== prevDepsRef.current.length || dependencies.some((dep, i) => !Object.is(dep, prevDepsRef.current[i])); - const hasTransitioned = (r !== prevRRef.current || depsChanged) && prevRRef.current !== undefined; + const hasTransitioned = (r !== prevRRef.current || depsChanged) && hasRunEffectRef.current; useEffect(() => { + // Mark that effect has run at least once (for transition detection) + hasRunEffectRef.current = true; // Update refs after effect runs prevRRef.current = r; prevDepsRef.current = dependencies; @@ -198,13 +208,14 @@ export function useSubscribe( return () => { isMounted = false; unsubscribe(); - // Only reset state if keepPreviousData is false + // Always clear prevSnapshotRef to prevent memory leak (ref holds stale data) + prevSnapshotRef.current = undefined; + // Only reset snapshot state if keepPreviousData is false if (!keepPreviousData) { setSnapshot(undefined); - prevSnapshotRef.current = undefined; } }; - }, [r, ...dependencies]); + }, [r, ...(dependencies ?? [])]); // Handle transitions: when deps changed and keepPreviousData is false, show default immediately if (hasTransitioned && !keepPreviousData) { From d91979bea5ffa885023c8d7c9153b757e0d6289a Mon Sep 17 00:00:00 2001 From: Chris Ren Date: Sun, 21 Dec 2025 21:07:28 -0800 Subject: [PATCH 5/7] docs: clean up README for upstream PR --- README.md | 96 +++++++++++++++---------------------------------------- 1 file changed, 26 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 859931a..a90786b 100644 --- a/README.md +++ b/README.md @@ -8,31 +8,6 @@ Provides a `useSubscribe()` hook for React which wraps Replicache's `subscribe()` method. -## React 19+ Fork - -This is a **React 19+ compatible fork** of the official `replicache-react` package. Key differences: - -- **Removes deprecated batching APIs**: No longer relies on `unstable_batchedUpdates`, leveraging React 19's automatic batching instead -- **Adds `keepPreviousData` option**: Eliminates UI flash during navigation by preserving data across subscription transitions -- **Requires React 19+**: Takes advantage of improved batching behavior in React 19 - -### Installation - -```bash -npm install @renchris/replicache-react -# or via GitHub -npm install renchris/replicache-react#feat/react-19-automatic-batching -``` - -**Peer Dependencies**: React 19+ - -### Migration from Official Package - -1. Update to React 19+ -2. Replace `replicache-react` with `@renchris/replicache-react` in your `package.json` -3. No code changes required - API is fully compatible -4. Optional: Use new `keepPreviousData` option to prevent UI flash during navigation - ## API ### function useSubscribe @@ -42,12 +17,12 @@ React hook that allows you monitor replicache changes | Parameter | Type | Description | | :--------------- | :------------------------------------------ | :------------------------------------------------------------------------------- | | `rep` | `Replicache` | Replicache instance that is being monitored | -| `query` | `(tx: ReadTransaction) => Promise` | Query that retrieves data to be watched | +| `query` | `(tx: ReadTransaction) => Promise` | Query that retrieves data to be watched | | `options?` | `Object \| undefined` | Option bag containing the named arguments listed below ⬇️ | | `.default?` | `R \| undefined = undefined` | Default value returned on first render _or_ whenever `query` returns `undefined` | | `.dependencies?` | `Array = []` | List of dependencies, query will be rerun when any of these change | -| `.isEqual?` | `((a: R, b: R) => boolean) = jsonDeepEqual` | Compare two returned values. Used to know whether to refire subscription. | -| `.keepPreviousData?` | `boolean = true` | When `true` (default), preserves previous data during dependency transitions to eliminate UI flash. Set to `false` to reset immediately. | +| `.isEqual?` | `((a: R, b: R) => boolean) = jsonDeepEqual` | Compare two returned values. Used to know whether to refire subscription. | +| `.keepPreviousData?` | `boolean = true` | Preserves previous data during dependency transitions to eliminate UI flash. Set to `false` to reset immediately. | ## Usage @@ -78,54 +53,36 @@ return ( ); ``` -## New Feature: `keepPreviousData` +## `keepPreviousData` Option The `keepPreviousData` option (default: `true`) eliminates UI flash when navigating between views with different subscription dependencies. -### Problem Without `keepPreviousData` +### The Problem When switching between subscriptions (e.g., navigating between categories), the hook traditionally resets to `undefined` or the default value, causing a brief flash of empty content before new data loads: -```typescript -// User switches from category "work" to "personal" -// 1. Hook unsubscribes from "work" data → returns default: [] -// 2. UI renders empty list (FLASH!) -// 3. Hook subscribes to "personal" data -// 4. UI renders "personal" todos ``` - -### Solution With `keepPreviousData: true` (Default) - -The hook preserves the previous subscription's data while the new subscription initializes: - -```typescript -// User switches from category "work" to "personal" -// 1. Hook unsubscribes from "work" data → KEEPS "work" data displayed -// 2. Hook subscribes to "personal" data -// 3. UI renders "personal" todos (NO FLASH!) +User switches from category "work" to "personal": +1. Hook unsubscribes from "work" → returns default: [] +2. UI renders empty list (FLASH!) +3. New subscription fires with "personal" data +4. UI renders "personal" todos ``` -### Example +### The Solution -```typescript -const todos = useSubscribe( - rep, - tx => getTodosByCategory(tx, category), - { - default: [], - dependencies: [category], - keepPreviousData: true, // Default - can be omitted - } -); +With `keepPreviousData: true` (default), the hook preserves the previous subscription's data while the new subscription initializes: -// When category changes: -// - Old behavior: Shows [] briefly → new data -// - New behavior: Shows old data → new data (smooth transition) +``` +User switches from category "work" to "personal": +1. Hook unsubscribes from "work" → KEEPS "work" data displayed +2. New subscription fires with "personal" data +3. UI renders "personal" todos (seamless transition) ``` -### When to Disable +### Disabling -Set `keepPreviousData: false` if you want to explicitly show the default value during transitions: +Set `keepPreviousData: false` if you want to show the default value during transitions: ```typescript const todos = useSubscribe( @@ -141,14 +98,13 @@ const todos = useSubscribe( ## Changelog -### 6.1.0 (React 19+ Fork) +### 6.1.0 -- **NEW**: Add `keepPreviousData` option (default: `true`) to eliminate UI flash during subscription transitions -- **Enhancement**: Add generation counter to prevent stale subscription callbacks -- **Enhancement**: Add isMounted guard to prevent setState after unmount -- **Enhancement**: Improve type safety with `Exclude` instead of conditional type -- **Enhancement**: Add comprehensive JSDoc documentation with examples -- Requires React 19+ +- Add `keepPreviousData` option (default: `true`) to eliminate UI flash during subscription transitions +- Add generation counter to prevent stale subscription callbacks from updating state +- Add isMounted guard to prevent setState after component unmount +- Improve error isolation in batched callbacks +- Add comprehensive JSDoc documentation ### 6.0.0 @@ -157,7 +113,7 @@ Requires React 19+. See https://react.dev/blog/2024/12/05/react-19 ### 5.0.1 -Change package to pure ESM. See See https://github.com/rocicorp/replicache-react/pull/61 for more information. +Change package to pure ESM. See https://github.com/rocicorp/replicache-react/pull/61 for more information. ### 5.0.0 From c0c9f708c748da0d6a24acbdb547a552c48f6f36 Mon Sep 17 00:00:00 2001 From: Chris Ren Date: Sun, 21 Dec 2025 21:08:04 -0800 Subject: [PATCH 6/7] chore: regenerate package-lock with correct package name --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9396356..bdb2370 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@renchris/replicache-react", + "name": "replicache-react", "version": "6.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@renchris/replicache-react", + "name": "replicache-react", "version": "6.1.0", "license": "ISC", "devDependencies": { From f74b4bda2d21df713be5dadd6c77dc9c98afcd21 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Thu, 15 Jan 2026 09:53:10 +0100 Subject: [PATCH 7/7] npm run format --- README.md | 32 ++++++++++++++------------------ src/index.ts | 13 ++++++++++--- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a90786b..352647e 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,15 @@ Provides a `useSubscribe()` hook for React which wraps Replicache's `subscribe() React hook that allows you monitor replicache changes -| Parameter | Type | Description | -| :--------------- | :------------------------------------------ | :------------------------------------------------------------------------------- | -| `rep` | `Replicache` | Replicache instance that is being monitored | -| `query` | `(tx: ReadTransaction) => Promise` | Query that retrieves data to be watched | -| `options?` | `Object \| undefined` | Option bag containing the named arguments listed below ⬇️ | -| `.default?` | `R \| undefined = undefined` | Default value returned on first render _or_ whenever `query` returns `undefined` | -| `.dependencies?` | `Array = []` | List of dependencies, query will be rerun when any of these change | -| `.isEqual?` | `((a: R, b: R) => boolean) = jsonDeepEqual` | Compare two returned values. Used to know whether to refire subscription. | -| `.keepPreviousData?` | `boolean = true` | Preserves previous data during dependency transitions to eliminate UI flash. Set to `false` to reset immediately. | +| Parameter | Type | Description | +| :------------------- | :------------------------------------------ | :---------------------------------------------------------------------------------------------------------------- | +| `rep` | `Replicache` | Replicache instance that is being monitored | +| `query` | `(tx: ReadTransaction) => Promise` | Query that retrieves data to be watched | +| `options?` | `Object \| undefined` | Option bag containing the named arguments listed below ⬇️ | +| `.default?` | `R \| undefined = undefined` | Default value returned on first render _or_ whenever `query` returns `undefined` | +| `.dependencies?` | `Array = []` | List of dependencies, query will be rerun when any of these change | +| `.isEqual?` | `((a: R, b: R) => boolean) = jsonDeepEqual` | Compare two returned values. Used to know whether to refire subscription. | +| `.keepPreviousData?` | `boolean = true` | Preserves previous data during dependency transitions to eliminate UI flash. Set to `false` to reset immediately. | ## Usage @@ -85,15 +85,11 @@ User switches from category "work" to "personal": Set `keepPreviousData: false` if you want to show the default value during transitions: ```typescript -const todos = useSubscribe( - rep, - tx => getTodosByCategory(tx, category), - { - default: [], - dependencies: [category], - keepPreviousData: false, // Show [] during category switch - } -); +const todos = useSubscribe(rep, tx => getTodosByCategory(tx, category), { + default: [], + dependencies: [category], + keepPreviousData: false, // Show [] during category switch +}); ``` ## Changelog diff --git a/src/index.ts b/src/index.ts index 238b809..4123ccc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -143,7 +143,12 @@ export function useSubscribe( query: (tx: Tx) => Promise, options: UseSubscribeOptions = {}, ): RemoveUndefined | Default { - const {default: def, dependencies = [], isEqual, keepPreviousData = true} = options; + const { + default: def, + dependencies = [], + isEqual, + keepPreviousData = true, + } = options; const [snapshot, setSnapshot] = useState(undefined); const prevSnapshotRef = useRef(undefined); const generationRef = useRef(0); @@ -156,9 +161,11 @@ export function useSubscribe( // Detect if we're in a transition (r or deps changed since last effect) // Only consider it a transition after the first effect run (hasRunEffectRef guards initial mount) - const depsChanged = dependencies.length !== prevDepsRef.current.length || + const depsChanged = + dependencies.length !== prevDepsRef.current.length || dependencies.some((dep, i) => !Object.is(dep, prevDepsRef.current[i])); - const hasTransitioned = (r !== prevRRef.current || depsChanged) && hasRunEffectRef.current; + const hasTransitioned = + (r !== prevRRef.current || depsChanged) && hasRunEffectRef.current; useEffect(() => { // Mark that effect has run at least once (for transition detection)