From 91f0236251bccdf97b51ae89e276068904172482 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:21:29 -0700 Subject: [PATCH 1/5] fix(react-db): defer eager onStoreChange to a microtask in useLiveQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1587. `useLiveQuery`'s `subscribeRef` calls `onStoreChange()` synchronously inside the `useSyncExternalStore` subscribe function when the underlying collection is already `ready`. That synchronous notification lands during the render-to-commit window when subscribe runs under StrictMode double-render or cold/throttled loads, which React surfaces as: Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. The fix is to defer the eager notification to a microtask so it lands after the current commit. While doing so, also guard the late notify path against an in-flight `subscribeChanges` callback firing after React unsubscribes — track a local `unsubscribed` flag and drop both the eager microtask and any in-flight subscription event after teardown, so React never sees a state update post-unsubscribe. No public API change; the contract of `useLiveQuery` is preserved (an already-ready collection still notifies React once after mount, just asynchronously instead of mid-commit). Verified `pnpm test` in packages/react-db — 94/94 pass, no type errors. Existing tests don't cover the race directly (it's a StrictMode-double-render / cold-load condition observed via Lighthouse in the issue), so the existing suite is the regression guard for existing behavior and the issue's repro is the behavioral validation. --- packages/react-db/src/useLiveQuery.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 331ff3a27..9eef6e9b3 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -434,17 +434,36 @@ export function useLiveQuery( return () => {} } + let unsubscribed = false + const subscription = collectionRef.current.subscribeChanges(() => { + // The subscription can outlive the React subscription window when an + // already-queued change arrives between `unsubscribed = true` and the + // underlying `subscription.unsubscribe()`. Drop the late notify so + // React never sees a state update post-unsubscribe. + if (unsubscribed) return // Bump version on any change; getSnapshot will rebuild next time versionRef.current += 1 onStoreChange() }) - // Collection may be ready and will not receive initial `subscribeChanges()` + // Collection may be ready and will not receive initial `subscribeChanges()`. + // We must notify React so it picks up the ready state — but doing it + // synchronously here lands during the render-to-commit window when + // `useSyncExternalStore`'s subscribe runs in StrictMode double-render + // or under cold/throttled loads, which React surfaces as: + // "Can't perform a React state update on a component that hasn't + // mounted yet. ... Move this work to useEffect instead." + // Defer to a microtask so the notify lands AFTER the current commit. + // See #1587 for the Lighthouse-cold-load repro. if (collectionRef.current.status === `ready`) { - versionRef.current += 1 - onStoreChange() + queueMicrotask(() => { + if (unsubscribed) return + versionRef.current += 1 + onStoreChange() + }) } return () => { + unsubscribed = true subscription.unsubscribe() } } From ea332a4561fbe09b1003df4d8eb91fe8a87f456c Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 17 Jun 2026 16:09:20 +0200 Subject: [PATCH 2/5] test(react-db): add regression test for useLiveQuery eager onStoreChange (#1587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the subscribe callback that useLiveQuery passes to React.useSyncExternalStore and asserts that onStoreChange is not invoked synchronously when the collection is already in the 'ready' state — it is instead deferred to a microtask. Without the fix, the eager notify lands during the render-to-commit window and React surfaces: Can't perform a React state update on a component that hasn't mounted yet. ... Move this work to useEffect instead. --- .../tests/useLiveQuery.issue-1587.test.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 packages/react-db/tests/useLiveQuery.issue-1587.test.tsx diff --git a/packages/react-db/tests/useLiveQuery.issue-1587.test.tsx b/packages/react-db/tests/useLiveQuery.issue-1587.test.tsx new file mode 100644 index 000000000..120bb572d --- /dev/null +++ b/packages/react-db/tests/useLiveQuery.issue-1587.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' + +import { renderHook } from '@testing-library/react' +import { createCollection, createLiveQueryCollection } from '@tanstack/db' +import { useLiveQuery } from '../src/useLiveQuery' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import type * as ReactNS from 'react' + +// Intercept React.useSyncExternalStore so we can capture the `subscribe` +// callback that `useLiveQuery` registers and observe whether it invokes +// `onStoreChange` synchronously (the bug in #1587) or defers it (the fix +// in #1588). +let capturedSubscribe: ((cb: () => void) => () => void) | null = null + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + default: (actual as any).default ?? actual, + useSyncExternalStore: (subscribe: any, getSnapshot: any) => { + capturedSubscribe = subscribe + return getSnapshot() + }, + } +}) + +type Person = { id: string; name: string; age: number } + +const initialPersons: Array = [ + { id: `1`, name: `A`, age: 10 }, + { id: `2`, name: `B`, age: 20 }, +] + +describe(`issue #1587: eager onStoreChange must not fire synchronously during subscribe`, () => { + it(`defers the initial ready-state onStoreChange to a microtask`, async () => { + const base = createCollection( + mockSyncCollectionOptions({ + id: `issue-1587-persons`, + getKey: (p) => p.id, + initialData: initialPersons, + }), + ) + + const lqc = createLiveQueryCollection({ + startSync: true, + query: (q) => q.from({ persons: base }), + }) + await lqc.preload() + expect(lqc.status).toBe(`ready`) + + capturedSubscribe = null + renderHook(() => useLiveQuery(lqc)) + expect(capturedSubscribe).toBeTypeOf(`function`) + + const onStoreChange = vi.fn() + const unsub = capturedSubscribe!(onStoreChange) + + // BUG (main): onStoreChange is called synchronously here because the + // collection is already ready. FIX (#1588): defers via queueMicrotask. + expect(onStoreChange).not.toHaveBeenCalled() + + await Promise.resolve() + expect(onStoreChange).toHaveBeenCalledTimes(1) + + unsub() + }) +}) From 5a139e1709e35df4b32cd759cd893e8166e2373d Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 18 Jun 2026 10:13:22 +0200 Subject: [PATCH 3/5] chore(react-db): tighten comments around deferred onStoreChange --- packages/react-db/src/useLiveQuery.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 9eef6e9b3..f0f65e184 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -437,24 +437,16 @@ export function useLiveQuery( let unsubscribed = false const subscription = collectionRef.current.subscribeChanges(() => { - // The subscription can outlive the React subscription window when an - // already-queued change arrives between `unsubscribed = true` and the - // underlying `subscription.unsubscribe()`. Drop the late notify so - // React never sees a state update post-unsubscribe. + // Drop late notifies that race with unsubscribe. if (unsubscribed) return // Bump version on any change; getSnapshot will rebuild next time versionRef.current += 1 onStoreChange() }) - // Collection may be ready and will not receive initial `subscribeChanges()`. - // We must notify React so it picks up the ready state — but doing it - // synchronously here lands during the render-to-commit window when - // `useSyncExternalStore`'s subscribe runs in StrictMode double-render - // or under cold/throttled loads, which React surfaces as: - // "Can't perform a React state update on a component that hasn't - // mounted yet. ... Move this work to useEffect instead." - // Defer to a microtask so the notify lands AFTER the current commit. - // See #1587 for the Lighthouse-cold-load repro. + // Already-ready collections won't emit an initial change. Notify React + // ourselves, but defer to a microtask — calling onStoreChange synchronously + // here lands during the render-to-commit window and trips React's + // "state update on a component that hasn't mounted yet" warning (#1587). if (collectionRef.current.status === `ready`) { queueMicrotask(() => { if (unsubscribed) return From 9be26f13dcde63ca11a8b4cf317b759189408540 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 18 Jun 2026 10:19:06 +0200 Subject: [PATCH 4/5] chore(react-db): drop #1587 reference from comment --- packages/react-db/src/useLiveQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index f0f65e184..2291c2019 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -446,7 +446,7 @@ export function useLiveQuery( // Already-ready collections won't emit an initial change. Notify React // ourselves, but defer to a microtask — calling onStoreChange synchronously // here lands during the render-to-commit window and trips React's - // "state update on a component that hasn't mounted yet" warning (#1587). + // "state update on a component that hasn't mounted yet" warning. if (collectionRef.current.status === `ready`) { queueMicrotask(() => { if (unsubscribed) return From dc3933eeb3e374af49a8c22dd66175894af5c1d7 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 18 Jun 2026 10:20:05 +0200 Subject: [PATCH 5/5] chore(react-db): drop issue refs from eager-onStoreChange test --- ...sx => useLiveQuery.eager-onstorechange.test.tsx} | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) rename packages/react-db/tests/{useLiveQuery.issue-1587.test.tsx => useLiveQuery.eager-onstorechange.test.tsx} (79%) diff --git a/packages/react-db/tests/useLiveQuery.issue-1587.test.tsx b/packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx similarity index 79% rename from packages/react-db/tests/useLiveQuery.issue-1587.test.tsx rename to packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx index 120bb572d..207f93156 100644 --- a/packages/react-db/tests/useLiveQuery.issue-1587.test.tsx +++ b/packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx @@ -7,9 +7,8 @@ import { mockSyncCollectionOptions } from '../../db/tests/utils' import type * as ReactNS from 'react' // Intercept React.useSyncExternalStore so we can capture the `subscribe` -// callback that `useLiveQuery` registers and observe whether it invokes -// `onStoreChange` synchronously (the bug in #1587) or defers it (the fix -// in #1588). +// callback that `useLiveQuery` registers and assert that it does not invoke +// `onStoreChange` synchronously when the collection is already ready. let capturedSubscribe: ((cb: () => void) => () => void) | null = null vi.mock('react', async () => { @@ -31,11 +30,11 @@ const initialPersons: Array = [ { id: `2`, name: `B`, age: 20 }, ] -describe(`issue #1587: eager onStoreChange must not fire synchronously during subscribe`, () => { +describe(`useLiveQuery: eager onStoreChange must not fire synchronously during subscribe`, () => { it(`defers the initial ready-state onStoreChange to a microtask`, async () => { const base = createCollection( mockSyncCollectionOptions({ - id: `issue-1587-persons`, + id: `eager-onstorechange-persons`, getKey: (p) => p.id, initialData: initialPersons, }), @@ -55,8 +54,8 @@ describe(`issue #1587: eager onStoreChange must not fire synchronously during su const onStoreChange = vi.fn() const unsub = capturedSubscribe!(onStoreChange) - // BUG (main): onStoreChange is called synchronously here because the - // collection is already ready. FIX (#1588): defers via queueMicrotask. + // onStoreChange must not be invoked synchronously inside subscribe; + // it should be deferred to a microtask so it lands after React commits. expect(onStoreChange).not.toHaveBeenCalled() await Promise.resolve()