Companion to #1827 (live-query graph run hook), same app, same measurement rig.
What we see (@tanstack/db 0.7.2, @tanstack/react-db)
On a page with 6 useLiveQuery subscribers over the same source collections (a chat composer, a main view with 2 queries, an outbox replay, a sidebar section, a tab registrar), every committed transaction on a source collection makes all 6 rerender, including subscribers whose query result did not change a single row. The path is LiveQueryObserverImpl.flushPublications → forceStoreRerender: the subscriber's snapshot object is new on every publication, so useSyncExternalStore sees a changed snapshot and React renders.
Measured (React DevTools hook walk restricted to cloned subtrees, unminified prod build, 24 sidebar rows, median of 3):
- one row's
pull_request update → 1 commit → 285 fibers with performed work, of which ~60 are the composer subtree (ChatComposer, tray, footer, focus trap) whose query selects nothing to do with pull requests, plus MainView ×2, SessionOutboxReplay, the sessions section; 55–120 ms main-thread busy per tick.
- a bulk delivery of 1,500 changed rows arriving as 1,500 server transactions → 850–885 commits in ~15 s at a 10–50 ms cadence, 241 fibers each, 0 network in the window; the same 6
forceStoreRerender sources on every commit. Per-commit cost × commits, so batch size does not help.
The affected subscribers' selected rows are unchanged on those commits; their rerender does no DOM work, but the render itself (composer ~60 fibers) is the cost.
What would help
Either of:
- Identity-stable snapshot when the selection is unchanged. If a publication produces no change to the subscriber's result set (no key added/removed/updated within the query's selection),
getSnapshot returns the previous snapshot object so useSyncExternalStore bails out. A structural compare on the D2 output for that subscriber, or a per-subscriber "dirty" flag set only when its graph emitted a change.
- A batched revision. Let a subscriber read a revision that advances only when its own result changed, so
useLiveQuery can compare previousRevision === currentRevision before forcing a store rerender.
(1) is what a caller would expect from a live query: rerender when my rows changed. We can work around at the call site with useMemo on the result array by identity, but the store-level rerender still fires; the fix belongs where the snapshot is produced.
Repro
- Two collections A and B, live queries
qA = from(A), qB = from(B) mounted in one tree.
- Insert one row into A.
- Observe: the component holding
qB rerenders (its useSyncExternalStore snapshot changed) though qB's result is identical.
Happy to test a patch against our rig; the numbers above are reproducible on demand.
Companion to #1827 (live-query graph run hook), same app, same measurement rig.
What we see (@tanstack/db 0.7.2, @tanstack/react-db)
On a page with 6
useLiveQuerysubscribers over the same source collections (a chat composer, a main view with 2 queries, an outbox replay, a sidebar section, a tab registrar), every committed transaction on a source collection makes all 6 rerender, including subscribers whose query result did not change a single row. The path isLiveQueryObserverImpl.flushPublications → forceStoreRerender: the subscriber's snapshot object is new on every publication, souseSyncExternalStoresees a changed snapshot and React renders.Measured (React DevTools hook walk restricted to cloned subtrees, unminified prod build, 24 sidebar rows, median of 3):
pull_requestupdate → 1 commit → 285 fibers with performed work, of which ~60 are the composer subtree (ChatComposer, tray, footer, focus trap) whose query selects nothing to do with pull requests, plusMainView×2,SessionOutboxReplay, the sessions section; 55–120 ms main-thread busy per tick.forceStoreRerendersources on every commit. Per-commit cost × commits, so batch size does not help.The affected subscribers' selected rows are unchanged on those commits; their rerender does no DOM work, but the render itself (composer ~60 fibers) is the cost.
What would help
Either of:
getSnapshotreturns the previous snapshot object souseSyncExternalStorebails out. A structural compare on the D2 output for that subscriber, or a per-subscriber "dirty" flag set only when its graph emitted a change.useLiveQuerycan comparepreviousRevision === currentRevisionbefore forcing a store rerender.(1) is what a caller would expect from a live query: rerender when my rows changed. We can work around at the call site with
useMemoon the result array by identity, but the store-level rerender still fires; the fix belongs where the snapshot is produced.Repro
qA = from(A),qB = from(B)mounted in one tree.qBrerenders (itsuseSyncExternalStoresnapshot changed) thoughqB's result is identical.Happy to test a patch against our rig; the numbers above are reproducible on demand.