Repository navigation
Defer ReactiveNode hub teardown across React resubscribes - #129
Merged
Merged
Conversation
When an inline useSelect selector's sources move with a prop, the render-phase recompute builds a new store handle and React swaps the subscription on commit by unsubscribing the old store before subscribing the new one. A ReactiveNode both stores held briefly had no hub listener, so it re-ran onUnobserved and onObserved, which costs query nodes a backend resubscribe. The subscription hub now defers a ReactiveNode hub's teardown to a microtask and keeps it if a listener resubscribes first. Hubs for other nodes still tear down synchronously. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF
ryanbliss
added a commit
that referenced
this pull request
Oct 8, 2026
* Honor treeVersion reads in tracked useSelect change filtering A selector-only useSelect that read Retree.treeVersion(node) held the right subtreeChanged subscription, but its relevance check looked up each changed node's own read record and skipped nodes without one. A descendant the selector never read directly had no record, so its write was dropped and the component kept the stale version. Tracked state now keeps the run's subtreeReads and resolves records with core's resolveChangedReadRecord, which falls back to the nearest ancestor whose tree version the run read, matching Retree.select. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF * Prefer a covering tree version read over a descendant's own record resolveChangedReadRecord returned a changed node's own read record whenever one existed. When a selector read Retree.treeVersion(list) and also list[0].title, a write to list[0].done resolved to list[0]'s record, which key scoping skipped because done was never read, even though the tree version the selector read had moved. Retree.select, Retree.effect and useSelect all missed the write. Now the nearest record holding a tree version read, the node's own included, wins over the node's own record. Runs without a tree version read take the existing early return. The @select dependent guard checks reads.has directly so a node the getter read keeps notifying through its own edge only. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF * Restore the @select descendant guard and cover it with a test The previous commit changed the @select dependent guard to bail on any descendant the getter read directly. @select getters keep one edge per read node, and a read node's own edge compares only the keys it read, so a write to an unread field of that node was dropped by both edges. With resolveChangedReadRecord now preferring the tree version record, the original guard already lets the subtree edge decide, and it notifies exactly once. The regression test now uses a dedicated single-getter class so the @select case fails on main and on the broken guard. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF * Follow up on deferred hub teardown review findings Retree.on's unsubscribe ran stopReactiveNode and onUnobserved whenever the node had no listeners left, even when Retree.clearListeners had already removed its callback and run that teardown. With React hub teardown now deferred a microtask, unmount followed by clearListeners in the same tick reached this path and ran onUnobserved twice. The unsubscribe now returns early when its callback is already gone. Also adds a subscriptionHub test for the ReactiveNode deferral, its same-tick cancellation and re-queue after the flag resets, and notes in the changeset that tests faking queueMicrotask must flush it. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF --------- Co-authored-by: Claude <noreply@anthropic.com>
ryanbliss
added a commit
that referenced
this pull request
Oct 8, 2026
When an inline useSelect selector's sources move with a prop, the render-phase recompute builds a new store handle and React swaps the subscription on commit by unsubscribing the old store before subscribing the new one. A ReactiveNode both stores held briefly had no hub listener, so it re-ran onUnobserved and onObserved, which costs query nodes a backend resubscribe. The subscription hub now defers a ReactiveNode hub's teardown to a microtask and keeps it if a listener resubscribes first. Hubs for other nodes still tear down synchronously. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF
ryanbliss
added a commit
that referenced
this pull request
Oct 8, 2026
* Honor treeVersion reads in tracked useSelect change filtering A selector-only useSelect that read Retree.treeVersion(node) held the right subtreeChanged subscription, but its relevance check looked up each changed node's own read record and skipped nodes without one. A descendant the selector never read directly had no record, so its write was dropped and the component kept the stale version. Tracked state now keeps the run's subtreeReads and resolves records with core's resolveChangedReadRecord, which falls back to the nearest ancestor whose tree version the run read, matching Retree.select. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF * Prefer a covering tree version read over a descendant's own record resolveChangedReadRecord returned a changed node's own read record whenever one existed. When a selector read Retree.treeVersion(list) and also list[0].title, a write to list[0].done resolved to list[0]'s record, which key scoping skipped because done was never read, even though the tree version the selector read had moved. Retree.select, Retree.effect and useSelect all missed the write. Now the nearest record holding a tree version read, the node's own included, wins over the node's own record. Runs without a tree version read take the existing early return. The @select dependent guard checks reads.has directly so a node the getter read keeps notifying through its own edge only. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF * Restore the @select descendant guard and cover it with a test The previous commit changed the @select dependent guard to bail on any descendant the getter read directly. @select getters keep one edge per read node, and a read node's own edge compares only the keys it read, so a write to an unread field of that node was dropped by both edges. With resolveChangedReadRecord now preferring the tree version record, the original guard already lets the subtree edge decide, and it notifies exactly once. The regression test now uses a dedicated single-getter class so the @select case fails on main and on the broken guard. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF * Follow up on deferred hub teardown review findings Retree.on's unsubscribe ran stopReactiveNode and onUnobserved whenever the node had no listeners left, even when Retree.clearListeners had already removed its callback and run that teardown. With React hub teardown now deferred a microtask, unmount followed by clearListeners in the same tick reached this path and ran onUnobserved twice. The unsubscribe now returns early when its callback is already gone. Also adds a subscriptionHub test for the ReactiveNode deferral, its same-tick cancellation and re-queue after the flag resets, and notes in the changeset that tests faking queueMicrotask must flush it. Claude-Session: https://claude.ai/code/session_01Soy3br8g2C5cCzNF5H54DF ---------
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.
Problem
When a prop moves an inline
useSelectselector's other dependencies, the render-phase recompute builds a new store handle. On commit,useSyncExternalStoreunsubscribes the old store before it subscribes the new one. For that moment, aReactiveNodeheld by both stores has no listeners in the subscription hub, so Retree runs itsonUnobservedand thenonObservedagain.QueryNode/ConvexQueryNodeclose and reopen their backend subscription in those hooks, so each such re-render costs a Convex resubscribe.#127 fixed the render-phase
swapSourcespath. This PR fixes the separate commit-phase path.Fix
In
subscriptionHub.ts, when a hub on aReactiveNodeloses its last listener, the Retree unsubscribe now waits one microtask. If a new subscription arrives before then, it reuses the existing hub and the node stays observed. A flag on the hub makes sure only one deferred teardown is queued at a time. Hubs on other nodes still tear down immediately.As a side effect, StrictMode's mount → unmount → mount no longer runs the observe hooks twice.
Behaviour change
For
ReactiveNodes,onUnobservednow runs one microtask after unmount instead of during it. Two tests that asserted the old timing now wait one microtask:useSelect.spec.tsxsamples/04.convex-react-nextjs/app/page.spec.tsxTests
onObservedran 3 times before this fix and runs once with it.npm run test: 1745 tests pass.lint:architectureandnpm run doctorare clean.The changeset is
.changeset/keep-observed-across-resubscribe.md(@retreejs/reactpatch).