Skip to content

Copy what query state adopts from shared source results (#118) - #125

Merged
ryanbliss merged 1 commit into
claude/peaceful-bardeen-isdb3afrom
claude/peaceful-bardeen-isdb3a-118
Oct 8, 2026
Merged

ryanbliss merged 1 commit into
claude/peaceful-bardeen-isdb3afrom
claude/peaceful-bardeen-isdb3a-118

Conversation

@ryanbliss

@ryanbliss ryanbliss commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

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:

Re-emission, 10k rows main Whole-emission clone This PR
No rows changed 13ms 37–53ms 11ms
Every row's field changed 80ms 95–102ms 68ms

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:

  • New detach helper (internals/detach.ts): copies arrays, plain records, Date, Map, Set and class instances. It shares frozen objects, opaque built-ins (core's isLeafObject, now exported from @retreejs/core/internal), functions, and nodes the tree already manages. 10k rows take 4ms, against 22ms for structuredClone.
  • Where it's applied:
    • wholesale state writes;
    • replacements returned by a reconciler;
    • initialState;
    • rows reconcileArray inserts and fields reconcileObject rewrites;
    • ConvexPaginatedQueryNode.restoreState, through a new protected QueryNode.detach.
  • Same-emission skip: a new @ignore stateSource skips restoring when the source repeats the emission state already mirrors, for example a cached value on resubscribe.
  • Fewer baseline copies: built-in paths never alias the baseline any more, so markOptimisticDirty clones it only when a custom reconciler is configured. Rollback without revert passes the baseline straight to restoreState instead 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 fetchQueryNode and 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 write next's objects into current directly. The docs and changeset point them to reconcileArray or a returned replacement. Single-query rollback keeps working for reconcilers that still do.

Known gap: direct writes to node.state survive a repeated identical emission. Before this PR the same write went into the backend's cache object instead.

Tests

New cases in QueryNode.spec.ts and ConvexPaginatedQueryNode.optimistic.spec.ts:

  • a cached object shared by two queries;
  • rows reconciliation inserts;
  • nested fields reconciliation rewrites;
  • a replacement reconciler that reuses rows;
  • rollback with a reconciler that adopts objects from next;
  • a shared initialState;
  • the same-emission skip;
  • frozen leaves;
  • paginated pages.

I checked that each one fails when its part of the fix is removed.

npm run test (with NODE_PATH unset, see #124), npm run typecheck, npm run lint:architecture and npm run doctor all pass. An Opus subagent reviewed this; I fixed its findings on reused-row identity, rollback with adopting reconcilers, initialState sharing and test gaps.

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
ryanbliss added this pull request to stack #128 October 8, 2026 21:18
@ryanbliss
ryanbliss merged commit dd68639 into main Oct 8, 2026
3 checks passed
@ryanbliss
ryanbliss deleted the claude/peaceful-bardeen-isdb3a-118 branch October 8, 2026 21:24
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shared backend results collide across query states and alias reconciled children

2 participants