Skip to content

Defer ReactiveNode hub teardown across React resubscribes - #129

Merged
ryanbliss merged 1 commit into
mainfrom
claude/sweet-babbage-40w5z0
Oct 8, 2026
Merged

ryanbliss merged 1 commit into
mainfrom
claude/sweet-babbage-40w5z0

Conversation

@ryanbliss

@ryanbliss ryanbliss commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Problem

When a prop moves an inline useSelect selector's other dependencies, the render-phase recompute builds a new store handle. On commit, useSyncExternalStore unsubscribes the old store before it subscribes the new one. For that moment, a ReactiveNode held by both stores has no listeners in the subscription hub, so Retree runs its onUnobserved and then onObserved again. QueryNode/ConvexQueryNode close and reopen their backend subscription in those hooks, so each such re-render costs a Convex resubscribe.

#127 fixed the render-phase swapSources path. This PR fixes the separate commit-phase path.

Fix

In subscriptionHub.ts, when a hub on a ReactiveNode loses 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, onUnobserved now runs one microtask after unmount instead of during it. Two tests that asserted the old timing now wait one microtask:

  • "observes a node it reads through its owner" in useSelect.spec.tsx
  • the unsubscribe-on-unmount check in samples/04.convex-react-nextjs/app/page.spec.tsx

Tests

  • New regression test: an inline selector reads a lazily created query node plus a dependency that depends on a prop. With the prop toggled false → true → false, onObserved ran 3 times before this fix and runs once with it.
  • npm run test: 1745 tests pass.
  • Typecheck, lint:architecture and npm run doctor are clean.

The changeset is .changeset/keep-observed-across-resubscribe.md (@retreejs/react patch).

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
ryanbliss merged commit 29d2c64 into main Oct 8, 2026
3 checks passed
@ryanbliss
ryanbliss deleted the claude/sweet-babbage-40w5z0 branch October 8, 2026 22:36
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

---------
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.

2 participants