Skip to content

Observe ReactiveNodes a tracked run reads through a parent (#123) - #127

Merged
ryanbliss merged 1 commit into
claude/peaceful-bardeen-isdb3a-122from
claude/peaceful-bardeen-isdb3a-123
Oct 8, 2026
Merged

ryanbliss merged 1 commit into
claude/peaceful-bardeen-isdb3a-122from
claude/peaceful-bardeen-isdb3a-123

Conversation

@ryanbliss

@ryanbliss ryanbliss commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

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 a ReactiveNode reached through a parent the selector also read never got a listener or onObserved. That includes owner.nodes.a.state, even when the node existed before the selector ran. The issue's control case only worked because it read node.state directly, which made the node a cover.

Fix

  • resolveCovers adds a new DependencySubscriptionKind.Observe source for every covered ReactiveNode.
  • Retree.select (selector-only form) and Retree.effect hold a no-op nodeChanged listener on those sources.
  • useSelect maps 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 onObserved writes common (QueryNode does one when the source has a cached value), which exposed two gaps:

  • Tracked Retree.select: a write during subscriptions.update ran a nested evaluate(), which replaced the subscription set mid-update and dropped dependencies. Changes during an update now re-run once the update settles, mirroring Retree.effect's guard.
  • useSelect swapSources: it unsubscribed the old sources before subscribing the new ones. A kept node lost its last listener, so onUnobserved and onObserved ran again: a Convex resubscribe on every dependency move. It now subscribes first; the hub is ref-counted.

ITrackedSelectionAccesses.sources is now resolved lazily, so @select getter runs, which never subscribe, skip the cover pass.

Performance

Case Before After
Selector re-run over 10k plain rows ~11ms ~11ms
Subscribe and stop a selector over 1k ReactiveNode rows 0.45ms 2.5ms

The 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 useSelect whose inline selector moves sources at commit time gets a new store handle, so useSyncExternalStore unsubscribes the old store before subscribing the new one. That can still re-run a kept node's lifecycle. Covers that were already ReactiveNodes 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 and onUnobserved. Also a synchronous onObserved write 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 (with NODE_PATH unset, see #124), npm run typecheck, npm run lint:architecture and npm run doctor pass.

An Opus subagent reviewed this. It also confirmed a lazily created QueryNode subscribes under select, effect, useSelect and StrictMode. I fixed its findings on re-entrancy, swap churn, lazy sources, changeset scope and tests.

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
ryanbliss added this pull request to stack #128 October 8, 2026 21:18
@ryanbliss
ryanbliss merged commit 8445f74 into main Oct 8, 2026
3 checks passed
@ryanbliss
ryanbliss deleted the claude/peaceful-bardeen-isdb3a-123 branch October 8, 2026 21:24
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
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.

onObserved never fires for a node created while a selector runs

2 participants