diff --git a/README.md b/README.md index 519b815..352647e 100644 --- a/README.md +++ b/README.md @@ -14,14 +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. | +| 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 @@ -52,8 +53,55 @@ return ( ); ``` +## `keepPreviousData` Option + +The `keepPreviousData` option (default: `true`) eliminates UI flash when navigating between views with different subscription dependencies. + +### 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: + +``` +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 +``` + +### The Solution + +With `keepPreviousData: true` (default), the hook preserves the previous subscription's data while the new subscription initializes: + +``` +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) +``` + +### Disabling + +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 +}); +``` + ## Changelog +### 6.1.0 + +- 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 Remove `unstable_batchedUpdates` - no longer needed with React 19's automatic batching. @@ -61,7 +109,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 diff --git a/package-lock.json b/package-lock.json index a78dd4a..bdb2370 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "replicache-react", - "version": "6.0.0", + "version": "6.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "replicache-react", - "version": "6.0.0", + "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/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.test.tsx b/src/index.test.tsx index a764b4f..add4e97 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(); @@ -370,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 f1a1057..4123ccc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,30 @@ -import {DependencyList, useEffect, useState} from 'react'; +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)[] = []; @@ -22,17 +46,62 @@ 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); + } } } -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 + * 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; }; /** @@ -44,24 +113,97 @@ 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, 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); + 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>([]); + // 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) && 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; + 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; @@ -71,12 +213,32 @@ export function useSubscribe( }); return () => { + isMounted = false; unsubscribe(); - setSnapshot(undefined); + // 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); + } }; - }, [r, ...dependencies]); + }, [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; }