Repository navigation
Copy what query state adopts from shared source results (#118) - #125
Merged
ryanbliss merged 1 commit intoOct 8, 2026
Merged
Conversation
Sources hand the same result object to every query and keep it cached, so adopting it into one query's state broke the next query that received it, and rows inserted by reconciliation were shared across queries. Query state now copies objects only where it adopts them: wholesale replacement, initial state, rows reconciliation inserts, and fields it rewrites. Unchanged rows cost no copy, frozen leaves and nodes the tree already manages stay shared, and a repeated emission that state already mirrors skips restoring. Built-in paths no longer alias the baseline, so the dirty-window clone now runs only for custom reconcilers and rollback no longer copies twice. Claude-Session: https://claude.ai/code/session_01Us9f7SPLJUYScpdDnA8oM4
ryanbliss
added this pull request to stack #128
October 8, 2026 21:18
ryanbliss
added a commit
that referenced
this pull request
Oct 8, 2026
Sources hand the same result object to every query and keep it cached, so adopting it into one query's state broke the next query that received it, and rows inserted by reconciliation were shared across queries. Query state now copies objects only where it adopts them: wholesale replacement, initial state, rows reconciliation inserts, and fields it rewrites. Unchanged rows cost no copy, frozen leaves and nodes the tree already manages stay shared, and a repeated emission that state already mirrors skips restoring. Built-in paths no longer alias the baseline, so the dirty-window clone now runs only for custom reconcilers and rollback no longer copies twice. Claude-Session: https://claude.ai/code/session_01Us9f7SPLJUYScpdDnA8oM4
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.
Fixes #118. Stacked on #124 (#117).
Query state adopted the objects a source emitted. Sources hand the same cached object to every query, so the second query threw
already has a structural parent. Separately, rows that reconciliation inserted were shared, so editing one query changed the other and the backend cache.Approach: copy at adoption points, not the whole emission
The issue's local patch cloned every accepted emission. I measured that against reconciling 10k Convex-like rows:
Convex re-emits the whole result on any change, so "few rows changed" is the common case. This PR copies only where state adopts something:
detachhelper (internals/detach.ts): copies arrays, plain records,Date,Map,Setand class instances. It shares frozen objects, opaque built-ins (core'sisLeafObject, now exported from@retreejs/core/internal), functions, and nodes the tree already manages. 10k rows take 4ms, against 22ms forstructuredClone.statewrites;initialState;reconcileArrayinserts and fieldsreconcileObjectrewrites;ConvexPaginatedQueryNode.restoreState, through a new protectedQueryNode.detach.@ignore stateSourceskips restoring when the source repeats the emissionstatealready mirrors, for example a cached value on resubscribe.markOptimisticDirtyclones it only when a custom reconciler is configured. Rollback withoutrevertpasses the baseline straight torestoreStateinstead of copying it twice.Trade-off: paths that replace state wholesale now copy the payload on every changed emission instead of adopting it in O(1). That covers
fetchQueryNodeand Convex queries that return an object rather than a document array. It's needed for correctness, and costs about 4ms per 10k rows.Contract change: custom
IStateReconcilers shouldn't writenext's objects intocurrentdirectly. The docs and changeset point them toreconcileArrayor a returned replacement. Single-query rollback keeps working for reconcilers that still do.Known gap: direct writes to
node.statesurvive a repeated identical emission. Before this PR the same write went into the backend's cache object instead.Tests
New cases in
QueryNode.spec.tsandConvexPaginatedQueryNode.optimistic.spec.ts:next;initialState;I checked that each one fails when its part of the fix is removed.
npm run test(withNODE_PATHunset, see #124),npm run typecheck,npm run lint:architectureandnpm run doctorall pass. An Opus subagent reviewed this; I fixed its findings on reused-row identity, rollback with adopting reconcilers,initialStatesharing and test gaps.