Skip to content

Fix dropped treeVersion updates and follow up on #129 - #130

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

ryanbliss merged 4 commits into
mainfrom
claude/sweet-babbage-40w5z0

Conversation

@ryanbliss

@ryanbliss ryanbliss commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

This PR has two parts. Part 1 fixes selectors that read Retree.treeVersion(node) and miss updates. Part 2 follows up on a review of the already-merged #129.

Part 1: treeVersion reads that miss writes

Bug A: useSelect drops descendant writes (React only)

A selector-only useSelect that reads Retree.treeVersion(node) never re-rendered when a descendant changed. Retree.select with the same selector does fire.

const root = Retree.root({ list: [{ done: false }] });
useSelect(() => Retree.treeVersion(root.list)); // stayed stale
root.list[0].done = true;

The subscription was correct. The change was lost in isTrackedSnapshotChangeRelevant, which looked up each changed node's own read record and skipped nodes without one. list[0] has no record because the selector never read it directly.

Fix: tracked useSelect state keeps the run's subtreeReads and looks records up with core's resolveChangedReadRecord, the same function Retree.select uses.

Bug B: a direct read hides the tree version read (core and React)

A reviewer found this one while checking Bug A. A selector reads Retree.treeVersion(list) and also list[0].title. A write to list[0].done was skipped by useSelect, Retree.select, Retree.effect and @select getters:

  • resolveChangedReadRecord returned list[0]'s own record.
  • That record skips by key, and done was never read on it.
  • The tree version the selector read had still moved.

Fix: resolveChangedReadRecord now prefers the nearest record that read a tree version, the node's own included, over the node's own record. That record re-reads the version, which always moves on a write under it, so it can't wrongly skip a change. Runs without a tree version read take the existing early return.

@select getters keep one edge per node they read. Their descendant guard in Retree.ts is unchanged on purpose. With the new lookup, the edge holding the tree version read decides, and the getter notifies exactly once. A test covers that.

Impact of the bugs

I checked each case below against the code on main.

  • The UI shows stale data with no error. The main use of treeVersion is to read it once and then compute from Retree.raw(...), which avoids deep proxy traps on large subtrees. A done count computed this way stayed at 0 after an item was marked done.
    • For Bug A, the same selector in Retree.select or Retree.effect updated correctly. View-model logic tested against core passed while the React UI went stale.
  • It punishes the performance fix. Swapping a selector's proxied reads for treeVersion + Retree.raw is the recommended way to cut proxy-trap cost. Doing that in a useSelect silently broke its updates.
  • Staleness lasts until something unrelated happens:
    • a write to a node and field the selector reads directly,
    • a remount,
    • or, for an inline selector only, a parent re-render.
    • A stable selector (module-level or useCallback) stayed stale even when its parent re-rendered.
  • Not affected: @memo getters keyed on treeVersion, and a @select getter whose only treeVersion read is its sole read. Also the node form, useSelect(node, selector).

What to expect after merging

  • Selectors that read treeVersion now re-run on every write under that node, as documented. The selector re-runs once per write. The component re-renders only when the selected value changes. A write that left a derived count unchanged re-ran the selector without a re-render.
  • Apps may see more selector runs and renders where they relied on the stale behaviour without knowing it. One example is a treeVersion read on a large, frequently written subtree, such as a whole table while someone types in a cell. Those selectors were wrong before. If the new cost shows up, read treeVersion on a narrower node, or use Retree.version when only the node's own fields matter.
  • Workarounds can be removed but don't need to be. Extra direct reads or manual revision counters added to force updates keep working.
  • Cost: selectors without a treeVersion read are unchanged. Selectors with one walk up to the node's depth for each changed node.
  • No API changes.

Part 2: follow-ups to #129 (deferred hub teardown)

A fresh review of #129 found no blocking issues, and these follow-ups:

  • Core: onUnobserved could run twice.
    • The problem: a Retree.on unsubscribe ran stopReactiveNode and onUnobserved whenever the node had no listeners left. It did this even when Retree.clearListeners had already removed its callback and run the same teardown.
    • That order (clearListeners, then unsubscribe) always double-fired.
    • Defer ReactiveNode hub teardown across React resubscribes #129 made the reverse order hit it too: unmount followed by clearListeners in the same tick, as in test cleanup or app teardown.
    • First-party query nodes are idempotent, but user onUnobserved code may not be.
    • Fix: the unsubscribe now returns early when its callback is already gone. A core test covers it.
  • Test gap: a new subscriptionHub.spec.ts case covers the ReactiveNode deferral, a same-tick resubscribe cancelling it, and re-queueing after the flag resets. Without the flag reset, every ReactiveNode hub would leak after its first cancelled teardown, and nothing would have caught it. I confirmed the test fails if the reset is removed.
  • Changeset note: test runners that fake queueMicrotask must flush microtasks before asserting on onUnobserved after unmount. Jest's fake timers fake it by default; vitest's don't.

Known and left alone: a pending hub that is reused right after clearListeners in the same tick has no live Retree listener. A hub with active listeners behaves the same way after clearListeners today, and the sequence is contrived.

Tests

  • New tests:
    • React: a treeVersion read re-renders on a descendant write, and not on writes outside the subtree.
    • Core: Retree.select, Retree.effect and a @select getter re-run on a write to an unread field of a node also read beneath a tree version read. The @select getter notifies exactly once.
    • Core: onUnobserved runs once when clearListeners precedes an unsubscribe.
    • React: hub deferral and cancellation.
  • Each new test fails without its fix.
  • npm run test: 1751 tests pass.
  • Typecheck, lint:architecture and npm run doctor are clean.

Changesets:

claude added 4 commits October 8, 2026 22:38
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
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
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
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
@ryanbliss ryanbliss changed the title Honor treeVersion reads in tracked useSelect change filtering Fix dropped treeVersion updates and follow up on #129 Oct 8, 2026
@ryanbliss
ryanbliss merged commit b010355 into main Oct 8, 2026
3 checks passed
@ryanbliss
ryanbliss deleted the claude/sweet-babbage-40w5z0 branch October 8, 2026 22:56
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