Skip to content

fix(react-db): defer eager onStoreChange to a microtask in useLiveQuery + regression test (closes #1587)#1594

Open
kevin-dp wants to merge 5 commits into
TanStack:mainfrom
kevin-dp:fix/1587-defer-eager-onstorechange
Open

fix(react-db): defer eager onStoreChange to a microtask in useLiveQuery + regression test (closes #1587)#1594
kevin-dp wants to merge 5 commits into
TanStack:mainfrom
kevin-dp:fix/1587-defer-eager-onstorechange

Conversation

@kevin-dp

@kevin-dp kevin-dp commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Closes #1587. Supersedes / re-applies #1588.

What

Two commits:

  1. fix(react-db): defer eager onStoreChange to a microtask in useLiveQuery — cherry-picked from fix(react-db): defer eager onStoreChange to a microtask in useLiveQuery #1588 by @tsushanth (authorship preserved). When useLiveQuery's subscribe callback runs against a collection whose status is already ready, it used to call onStoreChange() synchronously. Under React 19's useSyncExternalStore (and especially in StrictMode double-render or on cold/throttled loads), that 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.

    The fix wraps the eager notify in queueMicrotask(...) so it lands after commit, and adds an unsubscribed guard to drop late notifies if React tears down between scheduling and firing.

  2. test(react-db): add regression test for useLiveQuery eager onStoreChange (#1587) — new. Mocks react's useSyncExternalStore to capture the subscribe callback useLiveQuery registers, invokes it directly against an already-ready LiveQueryCollection, and asserts:

    • onStoreChange is NOT called synchronously inside subscribe(),
    • onStoreChange IS called exactly once after one microtask.

    I verified that test fails on main (synchronous call, 1 invocation observed) and passes after the fix.

Verification

  • Full packages/react-db suite: 95/95 passing (94 existing + 1 new).
  • No other packages touched.

Credit

Fix commit is cherry-picked with original authorship from @tsushanth (PR #1588). Thanks!

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where live query notification callbacks could be triggered after React component cleanup, which could cause memory leaks and unpredictable behavior. Initial notifications for already-loaded data are now properly deferred.
  • Tests

    • Added test coverage for live query subscription behavior with synchronous data sources to ensure callbacks are properly deferred and cleanup occurs correctly.

tsushanth and others added 2 commits June 17, 2026 16:03
Closes TanStack#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.
…nge (TanStack#1587)

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.
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0c76eb11-0e35-40e1-a380-3718bdd1b5f1

📥 Commits

Reviewing files that changed from the base of the PR and between 5a139e1 and dc3933e.

📒 Files selected for processing (2)
  • packages/react-db/src/useLiveQuery.ts
  • packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-db/src/useLiveQuery.ts

📝 Walkthrough

Walkthrough

useLiveQuery's useSyncExternalStore subscribe callback gains an unsubscribed flag that blocks post-cleanup notifications in subscribeChanges. For collections already in ready status, the synchronous onStoreChange() call is replaced with a queueMicrotask-deferred call gated by the same flag. A new test file verifies this deferred behavior.

Changes

Deferred ready notification and unsubscribe guard

Layer / File(s) Summary
Subscription guard and microtask deferral
packages/react-db/src/useLiveQuery.ts, packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx
subscribeRef.current now sets unsubscribed = false on entry and unsubscribed = true before cleanup; subscribeChanges and the initial ready-state notification both check this flag before incrementing versionRef or calling onStoreChange(). The ready-state notification is deferred to queueMicrotask instead of firing synchronously. A new Vitest file mocks useSyncExternalStore, captures the subscribe callback, and asserts onStoreChange is not called synchronously but is called exactly once after a microtask boundary.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Poem

🐇 A flag called unsubscribed hops into view,
No stale callbacks fire when cleanup is due.
The microtask queue holds the notification tight,
queueMicrotask waits for the moment that's right.
Ready or not, we defer with great care —
No eager surprises left lurking out there! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: deferring eager onStoreChange to a microtask in useLiveQuery with a regression test, and references the closed issue.
Description check ✅ Passed The description is comprehensive and complete. It explains the what, why, and how; includes verification results; and addresses the release impact (though a changeset note would strengthen it).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new

pkg-pr-new Bot commented Jun 17, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1594

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1594

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1594

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1594

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1594

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1594

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1594

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1594

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1594

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1594

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1594

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1594

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1594

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1594

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1594

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1594

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1594

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1594

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1594

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1594

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1594

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1594

commit: dc3933e

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/react-db/tests/useLiveQuery.issue-1587.test.tsx (1)

62-65: 💤 Low value

Consider adding a test for the unsubscribe guard.

The implementation guards against late notifications when unsub() is called before the microtask fires. A complementary test could verify this:

it(`does not fire onStoreChange if unsubscribed before microtask`, async () => {
  // ... same setup as above ...
  const onStoreChange = vi.fn()
  const unsub = capturedSubscribe!(onStoreChange)
  
  // Unsubscribe immediately, before microtask fires
  unsub()
  
  await Promise.resolve()
  expect(onStoreChange).not.toHaveBeenCalled()
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-db/tests/useLiveQuery.issue-1587.test.tsx` around lines 62 -
65, Add a new test case that verifies the unsubscribe guard functionality works
correctly. Create a test (using the `it` function) that sets up the same initial
conditions as the existing test (creating a capturedSubscribe call with an
onStoreChange mock), immediately calls `unsub()` before the microtask fires,
then awaits the Promise to resolve, and finally asserts that onStoreChange was
never called. This complementary test verifies that the guard prevents late
notifications when unsubscribe is called before the microtask executes.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/react-db/tests/useLiveQuery.issue-1587.test.tsx`:
- Around line 62-65: Add a new test case that verifies the unsubscribe guard
functionality works correctly. Create a test (using the `it` function) that sets
up the same initial conditions as the existing test (creating a
capturedSubscribe call with an onStoreChange mock), immediately calls `unsub()`
before the microtask fires, then awaits the Promise to resolve, and finally
asserts that onStoreChange was never called. This complementary test verifies
that the guard prevents late notifications when unsubscribe is called before the
microtask executes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 23d56529-ef8b-4b75-8634-5de216001b56

📥 Commits

Reviewing files that changed from the base of the PR and between 4d1abde and ea332a4.

📒 Files selected for processing (2)
  • packages/react-db/src/useLiveQuery.ts
  • packages/react-db/tests/useLiveQuery.issue-1587.test.tsx

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

react-db useLiveQuery can notify React before mount when collection is already ready

2 participants