Repository navigation
Fix dropped treeVersion updates and follow up on #129 - #130
Merged
Merged
Conversation
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
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.
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:
treeVersionreads that miss writesBug A:
useSelectdrops descendant writes (React only)A selector-only
useSelectthat readsRetree.treeVersion(node)never re-rendered when a descendant changed.Retree.selectwith the same selector does fire.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
useSelectstate keeps the run'ssubtreeReadsand looks records up with core'sresolveChangedReadRecord, the same functionRetree.selectuses.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 alsolist[0].title. A write tolist[0].donewas skipped byuseSelect,Retree.select,Retree.effectand@selectgetters:resolveChangedReadRecordreturnedlist[0]'s own record.donewas never read on it.Fix:
resolveChangedReadRecordnow 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.@selectgetters keep one edge per node they read. Their descendant guard inRetree.tsis 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.treeVersionis to read it once and then compute fromRetree.raw(...), which avoids deep proxy traps on large subtrees. A done count computed this way stayed at0after an item was marked done.Retree.selectorRetree.effectupdated correctly. View-model logic tested against core passed while the React UI went stale.treeVersion+Retree.rawis the recommended way to cut proxy-trap cost. Doing that in auseSelectsilently broke its updates.useCallback) stayed stale even when its parent re-rendered.@memogetters keyed ontreeVersion, and a@selectgetter whose onlytreeVersionread is its sole read. Also the node form,useSelect(node, selector).What to expect after merging
treeVersionnow 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.treeVersionread 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, readtreeVersionon a narrower node, or useRetree.versionwhen only the node's own fields matter.treeVersionread are unchanged. Selectors with one walk up to the node's depth for each changed node.Part 2: follow-ups to #129 (deferred hub teardown)
A fresh review of #129 found no blocking issues, and these follow-ups:
onUnobservedcould run twice.Retree.onunsubscribe ranstopReactiveNodeandonUnobservedwhenever the node had no listeners left. It did this even whenRetree.clearListenershad already removed its callback and run the same teardown.clearListeners, then unsubscribe) always double-fired.clearListenersin the same tick, as in test cleanup or app teardown.onUnobservedcode may not be.subscriptionHub.spec.tscase 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.queueMicrotaskmust flush microtasks before asserting ononUnobservedafter 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
clearListenersin the same tick has no live Retree listener. A hub with active listeners behaves the same way afterclearListenerstoday, and the sequence is contrived.Tests
treeVersionread re-renders on a descendant write, and not on writes outside the subtree.Retree.select,Retree.effectand a@selectgetter re-run on a write to an unread field of a node also read beneath a tree version read. The@selectgetter notifies exactly once.onUnobservedruns once whenclearListenersprecedes an unsubscribe.npm run test: 1751 tests pass.lint:architectureandnpm run doctorare clean.Changesets:
.changeset/use-select-tree-version.md(core + react patch).changeset/unsubscribe-after-clear-listeners.md(core patch).changeset/keep-observed-across-resubscribe.md(from Defer ReactiveNode hub teardown across React resubscribes #129, updated)