perf: match the bridge query by identity instead of re-hashing per cache entry - #157
perf: match the bridge query by identity instead of re-hashing per cache entry#157mbret wants to merge 5 commits into
Conversation
refetchQueries/cancelQueries with { queryKey, exact: true } iterate every
query in the TanStack cache and re-hash the key against each entry. The
observable bridge runs the refetch once per emission of a live stream, so
the lookup is replaced with a direct queryCache.get(queryHash), and the
per-emission map lookup in the cache-entry subscription is replaced with
the closed-over entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f25d26b099
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
TanStack stores a query under the hash produced by its configured queryKeyHashFn, while the bridge computes the default hashKey, so the direct queryCache.get lookup missed those queries entirely: live streams published their first value and were never refetched again, and the teardown cancel was skipped. Keep the O(1) lookup for the default hashing case and fall back to refetchQueries/cancelQueries when it misses, rather than re-deriving the configured hash here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm
…-query-bridge-cache-scans
refetchQueries and cancelQueries wrap their work in notifyManager.batch, which queues the observer notifications raised during the call and flushes them in a single React batched update. Calling query.fetch and query.cancel bare notified per event instead, changing how renders coalesce for live streams. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm
|
Update after the CI failure on a320846 — merged The CI failure is a pre-existing flaky test, not this PRCI failed on It does not reproduce unloaded (0/10 isolated runs, 0/3 full-suite runs on this branch). Under identical 6-way CPU load, 12 runs each:
But the investigation did surface a real parity bug (cfc1900)Chasing whether I could have caused it, I found a genuine semantic difference I'd missed: Both call sites are now wrapped, matching upstream exactly. My earlier claim that the change was "invisible except in speed" was wrong before this commit — thanks to the CI red for prompting the check, even though it turned out to be flaky for unrelated reasons. State
Generated by Claude Code |
The cost of refetchQueries/cancelQueries with { queryKey, exact: true }
is not the iteration but the re-hashing: matchQuery JSON.stringifies the
key again for every query in the cache, since each one may carry its own
queryKeyHashFn. An identity predicate skips hashing entirely, as
context.queryKey is the very array the target query holds.
This replaces the direct queryCache.get + query.fetch path. Batching,
the disabled/static skip, cancelRefetch and error swallowing all go back
to living inside the query client rather than being duplicated here, so
the custom queryKeyHashFn fallback and the notifyManager.batch
replication are no longer needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm
Target
Subsystem: queries — the Observable → TanStack Query bridge (
createObservableQueryFn,QueryClient$).User-visible symptom: main-thread work that grows with the size of the query cache, paid on the hottest loop the library has — a live stream bridged through
useQuery$triggers one refetch cycle per emission.The mechanism
refetchQueries/cancelQuerieswith{ queryKey, exact: true }are not expensive because they iterate the cache. They are expensive becausematchQueryre-derives the hash for the filter key against every query in the cache (hashQueryKeyByOptions→ key-sortedJSON.stringify), since each query may carry its ownqueryKeyHashFn.So the fix is to stop hashing, not to stop calling
refetchQueries.context.queryKeyis the very array the target query holds (query.js:226), so an identity predicate is an exact match that hashes nothing:All refetch semantics —
notifyManagerbatching, the disabled/static skip,cancelRefetch, error swallowing — stay inside the query client instead of being reproduced here. The same change is applied to the cancel inQueryClient$.deleteQuery.Measured impact
Benchmarked against the installed
@tanstack/query-core5.100.14, object-shaped keys (["entity", { id, scope }]), locating one query:{ queryKey, exact: true }cache.get(hash)The predicate captures ~99% of the available win. Going the last step to a direct
queryCache.get(queryHash)saves a further ~13 µs but requires hand-reproducingrefetchQueries' per-query behavior — an earlier revision of this PR did exactly that and it cost two behavioral bugs (see below), so it was reverted in favor of the predicate.History of this PR (what was tried and dropped)
The first revision replaced the call with
queryCache.get(queryHash)+query.fetch(...). Two defects came out of duplicating those internals:queryKeyHashFnqueries were never refetched. TanStack stores a query under its configured hash; the bridge computed the defaulthashKey, so the lookup returnedundefinedand live streams published their first value and then stopped. Caught in review by @chatgpt-codex-connector.notifyManager.batch.refetchQueriesbatches the observer notifications raised during the fetch into one React update; the barequery.fetch()notified per event, changing how renders coalesce.The identity predicate makes both structurally impossible — nothing hashes, and nothing is reproduced — which is why it is the version being proposed.
Verification
npm run check(biome) — cleannpm run build(tsc + vite) — succeedsnpm run test:ci— 140/140, six consecutive full-suite runsIncludes a regression test (
re-renders when the query uses a custom queryKeyHashFn) added while fixing defect 1; it still passes and now guards the property for free.Note on CI: an earlier run failed on
useQuery$.test.tsx"should return consecutive results". That test asserts on every intermediate render of aninterval(5)and is flaky under CPU contention independently of this PR — under identical 6-way load, 12 runs each: cleanmain1/12 failures, this branch 1/12. Worth a separate issue to make it deterministic.One semantic difference to weigh
Identity matching will not match a query that was removed and rebuilt with a fresh
queryKeyarray while the stream is still alive, where hash matching would. In practice the teardown path deletes the bridge entry (and stops the refetch loop) before that window opens, and the clear/invalidation/cleanup/unmount suites all pass — but it is a real difference, not a no-op.Backlog (found but not taken)
observerRemoved— pre-existing onmain.QueryClient$.queryMapis keyed by the defaulthashKey, but the cache subscription callsdeleteQuery(event.query.queryHash)with TanStack's configured hash, so the two never match and the observable keeps running after the last observer unmounts. Measured: unmounting only the consumer leavesqueryMap.sizeat 1 with a custom hash vs 0 with the default, identically onmain. A behavior fix, not a perf one.createObservableQueryFncomputeshashKey(context.queryKey)andQueryClient$.setQueryimmediately recomputes it on the cache-miss path — a duplicate stringify per query creation (per new query, not per emission).delay(1)+take(1)refetch loop costs two timers per emission by design; coalescing bursts would change delivery timing.useObservekeeps its eagerObservableStoresubscription alongside theuseSyncExternalStoreone, so each emission reaches one extra no-op subscriber.🤖 Generated with Claude Code
https://claude.ai/code/session_01FqgGygsYCHiwqmWqFj3wYm