From ba3dfc68de436fd5d69a8f58506e66e7aa10f265 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] 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() } }