useLiveQuery: subscribers rerender on every collection revision bump even when their selected rows are unchanged (forceStoreRerender in flushPublications)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 3.9k
- Forks
- 266
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 55
Description
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_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. - 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
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:
- 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),
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. - A batched revision. Let a subscriber read a revision that advances only when its own result changed, so
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 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
qBrerenders (itsuseSyncExternalStoresnapshot changed) thoughqB's result is identical.
Happy to test a patch against our rig; the numbers above are reproducible on demand.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at LiveQueryObserverImpl.flushPublications and the forceStoreRerender path described in the report. Reproduce the A/B case with qA and qB, then trace how each subscriber's snapshot is produced and compared by useSyncExternalStore. Done means inserting into A does not rerender qB when its selected rows are unchanged, while changes to a subscriber's own result still trigger its rerender.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100