perf: drop intermediate object copy in useQueries$ transform#138
Merged
Conversation
useQueries$ rebuilds every query option object on every render. It did so
with object-rest destructuring — `({ queryFn, ...query }) => ({ ...query,
queryFn })` — which forces V8 to allocate and populate a second throwaway
object per query (the rest) before the re-spread. The queryFn was then
re-added anyway, so the rest was pure overhead.
Spread the original query once and override queryFn in place. The resulting
option object is identical (same keys, same values; only queryFn's insertion
order changes, which TanStack ignores since it reads options by name), so
behavior is unchanged.
This is O(queries) per render of every useQueries$ consumer — the hook meant
for arrays of parallel queries. A microbenchmark of the transform at 10/30/100
queries shows the map run ~5-10x faster (object-rest is the dominant cost).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHuicehU24mr9cRu2fdUEf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Target
Subsystem: query hooks —
useQueries$(src/lib/queries/useQueries$.ts).User-visible symptom:
useQueries$is the hook for observing an array of parallel queries (dashboards commonly pass dozens). On every render it rebuilds the full array of query-option objects to bridge each observablequeryFnto TanStack's promisequeryFn. The per-query transform did more allocation than necessary, adding avoidable main-thread work and GC pressure that scales with the number of queries × render frequency.Change
One line in the per-query
map:Mechanism: the old form used object-rest destructuring (
{ queryFn, ...query }) and then re-spreadquery. Object-rest forces V8 to allocate and populate a second throwaway object (the rest) per query on top of the spread that follows — andqueryFnwas re-added anyway, so the rest was pure overhead. Spreading the original query once and overridingqueryFnin place produces the same result with one object allocation instead of two.Scale multiplier: O(queries) per render of every
useQueries$consumer. Halves the object allocations in this map for each of N queries, on every render.Impact (measured): a microbenchmark of just the transform (bridge stubbed out) at 10 / 30 / 100 queries over 200k iterations shows the map running ~5–10× faster — object-rest is the dominant cost:
(Absolute time is per 200k transform runs; real render cost is a small fraction of a render, but it scales with query count and is removed for free.)
Behavior
Unchanged. The resulting option object has the same keys and the same values (verified by
===on every property, andqueryFnresolves to the identical bridged function). OnlyqueryFn's insertion order within the object changes; TanStack reads options by property name, so ordering is irrelevant.Verification
main(Node 25, per CI):npm run check,npm run build,npm run test:ciall green.src/lib/queries/useQueries$.test.tsxsuite.Backlog (perf opportunities found, not taken this run)
createLocalStorageAdapter({ key })→createSharedStoreAdapter): every single-keygetItem/setItem/removeItemreads and re-JSON.parses (and, for writes, re-JSON.stringifys) the entire combined blob. Hydrating or persisting N signals into one shared store is therefore O(N × totalSize) — effectively O(N²). No behavior-preserving fix without an interface/caching change (fresh read-modify-write is needed for cross-tab correctness), so left for a design decision.hashKeyon query cache-miss (createObservableQueryFncomputeshashKey(queryKey)for the cache lookup, thenQueryClient$.setQuerycomputes it again). Only on first fetch of a key, so low frequency, butsetQuerycould accept a precomputed hash.useSignalcallsuseSignalReferencedirectly and again inside bothuseSignalValueanduseSetSignal, so a virtual signal is resolved (context read +Map.get) 3× per render. Constant-factor only; not pursued.🤖 Generated with Claude Code
https://claude.ai/code/session_01HHuicehU24mr9cRu2fdUEf
Generated by Claude Code