Repository navigation
Conversation
Tracked selectors subscribe only to their read covers, nodes whose parent the run did not read. A ReactiveNode reached through a read parent therefore had no listener of its own and never got onObserved, whether it existed before the run or was created during it. Query nodes created lazily inside a selector never subscribed. Covered ReactiveNodes now become observe-only sources. Retree.select and Retree.effect hold a no-op nodeChanged listener on them, and useSelect holds an observed hub entry that never notifies, so changes still arrive once through the cover. Observation makes synchronous onObserved writes common, so two re-entrancy gaps are closed too: a tracked select re-runs after its subscriptions settle instead of replacing them mid-update, and useSelect subscribes moved sources before releasing the old ones so a kept node never loses its last listener. Sources are now resolved lazily, so @select getter runs skip the cover pass. 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
Tracked selectors subscribe only to their read covers, nodes whose parent the run did not read. A ReactiveNode reached through a read parent therefore had no listener of its own and never got onObserved, whether it existed before the run or was created during it. Query nodes created lazily inside a selector never subscribed. Covered ReactiveNodes now become observe-only sources. Retree.select and Retree.effect hold a no-op nodeChanged listener on them, and useSelect holds an observed hub entry that never notifies, so changes still arrive once through the cover. Observation makes synchronous onObserved writes common, so two re-entrancy gaps are closed too: a tracked select re-runs after its subscriptions settle instead of replacing them mid-update, and useSelect subscribes moved sources before releasing the old ones so a kept node never loses its last listener. Sources are now resolved lazily, so @select getter runs skip the cover pass. 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 #123. Stacked on #126.
Root cause
Creation timing doesn't matter. Tracked runs subscribe only to their read covers: nodes whose parent the run didn't read (
resolveCovers). Retree's observed lifecycle is driven by listeners. So aReactiveNodereached through a parent the selector also read never got a listener oronObserved. That includesowner.nodes.a.state, even when the node existed before the selector ran. The issue's control case only worked because it readnode.statedirectly, which made the node a cover.Fix
resolveCoversadds a newDependencySubscriptionKind.Observesource for every coveredReactiveNode.Retree.select(selector-only form) andRetree.effecthold a no-opnodeChangedlistener on those sources.useSelectmaps them to a new"observed"hub listener type. It holds the listener but never notifies and has a constant version, so a change is still delivered once, through the cover.Observation makes synchronous
onObservedwrites common (QueryNodedoes one when the source has a cached value), which exposed two gaps:Retree.select: a write duringsubscriptions.updateran a nestedevaluate(), which replaced the subscription set mid-update and dropped dependencies. Changes during an update now re-run once the update settles, mirroringRetree.effect's guard.useSelectswapSources: it unsubscribed the old sources before subscribing the new ones. A kept node lost its last listener, soonUnobservedandonObservedran again: a Convex resubscribe on every dependency move. It now subscribes first; the hub is ref-counted.ITrackedSelectionAccesses.sourcesis now resolved lazily, so@selectgetter runs, which never subscribe, skip the cover pass.Performance
ReactiveNoderowsThe extra ~2µs per node is the existing observe path (listener,
onObserved, dependency wiring) now running where the issue expects it.Not in this PR
A
useSelectwhose inline selector moves sources at commit time gets a new store handle, souseSyncExternalStoreunsubscribes the old store before subscribing the new one. That can still re-run a kept node's lifecycle. Covers that were alreadyReactiveNodes had the same behavior before this PR. Fixing it needs deferred teardown in the subscription hub, so I've left it for a follow-up.The dev warning for writes during a selector is unchanged; the lazy-create pattern now works, so its wording still fits.
Tests
ReactiveNode.spec.ts: observation for nodes created during a run and for existing nodes read through their owner, across select and effect, asserting the re-run andonUnobserved. Also a synchronousonObservedwrite followed by a later dependency write.useSelect.spec.tsx: observe and unobserve through React, and no lifecycle churn when other dependencies move.Each test fails without its part of the fix.
npm run test(withNODE_PATHunset, see #124),npm run typecheck,npm run lint:architectureandnpm run doctorpass.An Opus subagent reviewed this. It also confirmed a lazily created
QueryNodesubscribes under select, effect,useSelectand StrictMode. I fixed its findings on re-entrancy, swap churn, lazy sources, changeset scope and tests.