From 2a665741d2808cddf07e82de167265b684eec7de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:38:19 +0000 Subject: [PATCH 1/4] 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 --- .changeset/use-select-tree-version.md | 5 ++++ packages/retree-react/src/useSelect.spec.tsx | 27 ++++++++++++++++++++ packages/retree-react/src/useSelect.ts | 14 ++++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 .changeset/use-select-tree-version.md diff --git a/.changeset/use-select-tree-version.md b/.changeset/use-select-tree-version.md new file mode 100644 index 0000000..f0e2c37 --- /dev/null +++ b/.changeset/use-select-tree-version.md @@ -0,0 +1,5 @@ +--- +"@retreejs/react": patch +--- + +A selector-only `useSelect` that reads `Retree.treeVersion(node)` now re-renders when a descendant of `node` changes, matching `Retree.select`. Before, it dropped writes to descendants the selector never read directly. diff --git a/packages/retree-react/src/useSelect.spec.tsx b/packages/retree-react/src/useSelect.spec.tsx index 5a41879..7d8d26f 100644 --- a/packages/retree-react/src/useSelect.spec.tsx +++ b/packages/retree-react/src/useSelect.spec.tsx @@ -282,6 +282,33 @@ describe("useSelect", () => { expect(screen.getByTestId("value").textContent).toBe("1"); }); + it("re-renders a tracked treeVersion read on a descendant write", () => { + const root = trackRoot( + Retree.root({ list: [{ done: false }], label: "one" }) + ); + let renderCount = 0; + + function Probe() { + renderCount += 1; + const version = useSelect(() => Retree.treeVersion(root.list)); + return
{version}
; + } + + render(); + const initial = screen.getByTestId("value").textContent; + + act(() => { + root.label = "two"; + }); + expect(renderCount).toBe(1); + + act(() => { + root.list[0].done = true; + }); + expect(screen.getByTestId("value").textContent).not.toBe(initial); + expect(renderCount).toBe(2); + }); + it("selects with trapped dependencies when only a selector function is passed", () => { const root = trackRoot( Retree.root({ diff --git a/packages/retree-react/src/useSelect.ts b/packages/retree-react/src/useSelect.ts index 4e37774..adedb40 100644 --- a/packages/retree-react/src/useSelect.ts +++ b/packages/retree-react/src/useSelect.ts @@ -22,6 +22,7 @@ import { hasTrackedSelectionChanged, normalizeDependencyEntry, normalizeSelectDependencies, + resolveChangedReadRecord, runTrackedSelection, stabilizeSelectedRetreeReferences, TrackedSelection, @@ -191,7 +192,8 @@ function getTrackedListenerType( /** * True when a moved source can still affect the selection. With the changed - * nodes known, only their records validate (nodes without a record are + * nodes known, only their records validate, or the nearest ancestor whose + * tree version the selector read (other nodes without a record are * descendants of a subtree cover the selector never read). Without them every * record under a moved source re-reads; validation is raw reads, far cheaper * than re-running the selector. @@ -204,7 +206,11 @@ function isTrackedSnapshotChangeRelevant( const changes = snapshot.changes; if (changes !== undefined) { for (const change of changes) { - const record = reads.get(change.rawNode); + const record = resolveChangedReadRecord( + reads, + state.subtreeReads, + change.rawNode + ); if (record === undefined) { continue; } @@ -482,6 +488,7 @@ function recomputeNodeSelectStateForSelector( */ interface TrackedSelectState { reads: TrackedSelection["reads"]; + subtreeReads: TrackedSelection["subtreeReads"]; sources: readonly RetreeExternalStoreSource[]; /** * Swappable for the same reason as {@link NodeSelectState.store}: a @@ -501,6 +508,7 @@ function createTrackedSelectState( const store = createRetreeSwappableCompositeExternalStore(sources); return { reads: selection.reads, + subtreeReads: selection.subtreeReads, sources, store, snapshot: store.getSnapshot(), @@ -524,6 +532,7 @@ function refreshTrackedSelectState( const nextSelection = runTrackedSelection(selector); const previousReads = state.reads; state.reads = nextSelection.reads; + state.subtreeReads = nextSelection.subtreeReads; const stabilizedSelected = stabilizeSelectedRetreeReferences( state.container.selected, nextSelection.selected @@ -616,6 +625,7 @@ function recomputeTrackedSelectStateForSelector( } return { reads: nextSelection.reads, + subtreeReads: nextSelection.subtreeReads, sources, store, snapshot: store.getSnapshot(), From f841a9c843832e6188dd08c5699aea9b682ea36c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:45:48 +0000 Subject: [PATCH 2/4] 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 --- .changeset/use-select-tree-version.md | 5 ++- packages/retree-core/src/Retree.ts | 3 +- packages/retree-core/src/internals/select.ts | 12 ++++--- packages/retree-core/src/node-version.spec.ts | 36 +++++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.changeset/use-select-tree-version.md b/.changeset/use-select-tree-version.md index f0e2c37..a3bf7fa 100644 --- a/.changeset/use-select-tree-version.md +++ b/.changeset/use-select-tree-version.md @@ -1,5 +1,8 @@ --- +"@retreejs/core": patch "@retreejs/react": patch --- -A selector-only `useSelect` that reads `Retree.treeVersion(node)` now re-renders when a descendant of `node` changes, matching `Retree.select`. Before, it dropped writes to descendants the selector never read directly. +A selector that reads `Retree.treeVersion(node)` now re-runs on every write under `node`. + +Before, a selector-only `useSelect` dropped writes to descendants the selector never read directly, unlike `Retree.select`. And `useSelect`, `Retree.select` and `Retree.effect` skipped a write to a descendant's field when the selector also read a different field of that descendant. diff --git a/packages/retree-core/src/Retree.ts b/packages/retree-core/src/Retree.ts index 0f34782..ed07e2a 100644 --- a/packages/retree-core/src/Retree.ts +++ b/packages/retree-core/src/Retree.ts @@ -3197,8 +3197,7 @@ export class Retree { // read directly notifies through its own edge. if ( descendant && - (record === undefined || - record.rawNode === changedUnproxiedNode) + (record === undefined || reads.has(changedUnproxiedNode)) ) { return false; } diff --git a/packages/retree-core/src/internals/select.ts b/packages/retree-core/src/internals/select.ts index af2b339..37fb7e5 100644 --- a/packages/retree-core/src/internals/select.ts +++ b/packages/retree-core/src/internals/select.ts @@ -725,9 +725,11 @@ export function runTrackedSelection( } /** - * The record that decides a `nodeChanged` from `changedRawNode`: the node's - * own, or else the nearest ancestor record whose tree version the run read, - * which re-reads that version. Undefined when the run read neither. + * The record that decides a `nodeChanged` from `changedRawNode`: the nearest + * record, the node's own included, whose tree version the run read, which + * re-reads that version; else the node's own. A tree version read wins over + * the node's own record, which may skip a key it never read even though the + * write moved the tree version. Undefined when the run read neither. */ export function resolveChangedReadRecord( reads: ReadonlyMap, @@ -735,7 +737,7 @@ export function resolveChangedReadRecord( changedRawNode: TreeNode ): NodeReadRecord | undefined { const record = reads.get(changedRawNode); - if (record !== undefined || subtreeReads.length === 0) { + if (subtreeReads.length === 0 || record?.subtreeRead) { return record; } let handler = getBaseHandlerForUnproxiedNode(changedRawNode)?.parentHandler; @@ -746,7 +748,7 @@ export function resolveChangedReadRecord( } handler = handler.parentHandler; } - return undefined; + return record; } /** diff --git a/packages/retree-core/src/node-version.spec.ts b/packages/retree-core/src/node-version.spec.ts index 1d552cb..d8a8bc6 100644 --- a/packages/retree-core/src/node-version.spec.ts +++ b/packages/retree-core/src/node-version.spec.ts @@ -113,6 +113,10 @@ describe("tracked version reads", () => { get booksTreeVersion(): number { return Retree.treeVersion(this.books); } + @select + get booksTreeVersionAndTitle(): string { + return `${Retree.treeVersion(this.books)}:${this.books[0].title}`; + } } const roots: object[] = []; afterEach(() => { @@ -192,6 +196,38 @@ describe("tracked version reads", () => { stop(); }); + it("re-runs on an unread field of a node also read beneath a tree version read", () => { + const root = Retree.root<{ rows: { title: string; done: boolean }[] }>({ + rows: [{ title: "a", done: false }], + }); + roots.push(root); + const selected: string[] = []; + const stopSelect = Retree.select( + () => `${Retree.treeVersion(root.rows)}:${root.rows[0].title}`, + (value) => selected.push(value) + ); + let effectRuns = 0; + const stopEffect = Retree.effect(() => { + effectRuns++; + Retree.treeVersion(root.rows); + void root.rows[0].title; + }); + const library = makeLibrary(); + const initial = library.booksTreeVersionAndTitle; + const changed = vi.fn(); + Retree.on(library, "nodeChanged", changed); + + root.rows[0].done = true; + library.books[0].tags = ["z"]; + + expect(selected).toHaveLength(1); + expect(effectRuns).toBe(2); + expect(changed).toHaveBeenCalledTimes(1); + expect(library.booksTreeVersionAndTitle).not.toBe(initial); + stopSelect(); + stopEffect(); + }); + it("re-runs a @select getter on writes under a tree version read", () => { const root = Retree.root({ library: new Library(), From 8ac666a45da6d3894cbae87ebd66baa0bb46a301 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:51:56 +0000 Subject: [PATCH 3/4] 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 --- .changeset/use-select-tree-version.md | 2 +- packages/retree-core/src/Retree.ts | 3 +- packages/retree-core/src/node-version.spec.ts | 31 +++++++++++-------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.changeset/use-select-tree-version.md b/.changeset/use-select-tree-version.md index a3bf7fa..e78caf4 100644 --- a/.changeset/use-select-tree-version.md +++ b/.changeset/use-select-tree-version.md @@ -5,4 +5,4 @@ A selector that reads `Retree.treeVersion(node)` now re-runs on every write under `node`. -Before, a selector-only `useSelect` dropped writes to descendants the selector never read directly, unlike `Retree.select`. And `useSelect`, `Retree.select` and `Retree.effect` skipped a write to a descendant's field when the selector also read a different field of that descendant. +Before, a selector-only `useSelect` dropped writes to descendants the selector never read directly, unlike `Retree.select`. And `useSelect`, `Retree.select`, `Retree.effect` and `@select` getters all skipped a write to a descendant's field when the selector also read a different field of that descendant. diff --git a/packages/retree-core/src/Retree.ts b/packages/retree-core/src/Retree.ts index ed07e2a..0f34782 100644 --- a/packages/retree-core/src/Retree.ts +++ b/packages/retree-core/src/Retree.ts @@ -3197,7 +3197,8 @@ export class Retree { // read directly notifies through its own edge. if ( descendant && - (record === undefined || reads.has(changedUnproxiedNode)) + (record === undefined || + record.rawNode === changedUnproxiedNode) ) { return false; } diff --git a/packages/retree-core/src/node-version.spec.ts b/packages/retree-core/src/node-version.spec.ts index d8a8bc6..5ac2efc 100644 --- a/packages/retree-core/src/node-version.spec.ts +++ b/packages/retree-core/src/node-version.spec.ts @@ -113,10 +113,6 @@ describe("tracked version reads", () => { get booksTreeVersion(): number { return Retree.treeVersion(this.books); } - @select - get booksTreeVersionAndTitle(): string { - return `${Retree.treeVersion(this.books)}:${this.books[0].title}`; - } } const roots: object[] = []; afterEach(() => { @@ -197,10 +193,21 @@ describe("tracked version reads", () => { }); it("re-runs on an unread field of a node also read beneath a tree version read", () => { - const root = Retree.root<{ rows: { title: string; done: boolean }[] }>({ - rows: [{ title: "a", done: false }], - }); - roots.push(root); + class Shelf extends ReactiveNode { + public books = [{ title: "a", tags: ["x"] }]; + get dependencies() { + return []; + } + @select + get summary(): string { + return `${Retree.treeVersion(this.books)}:${ + this.books[0].title + }`; + } + } + const root = Retree.root({ rows: [{ title: "a", done: false }] }); + const shelf = Retree.root(new Shelf()); + roots.push(root, shelf); const selected: string[] = []; const stopSelect = Retree.select( () => `${Retree.treeVersion(root.rows)}:${root.rows[0].title}`, @@ -212,18 +219,16 @@ describe("tracked version reads", () => { Retree.treeVersion(root.rows); void root.rows[0].title; }); - const library = makeLibrary(); - const initial = library.booksTreeVersionAndTitle; + void shelf.summary; const changed = vi.fn(); - Retree.on(library, "nodeChanged", changed); + Retree.on(shelf, "nodeChanged", changed); root.rows[0].done = true; - library.books[0].tags = ["z"]; + shelf.books[0].tags = ["z"]; expect(selected).toHaveLength(1); expect(effectRuns).toBe(2); expect(changed).toHaveBeenCalledTimes(1); - expect(library.booksTreeVersionAndTitle).not.toBe(initial); stopSelect(); stopEffect(); }); From 12df7e5c23c2c42f4144b8d490cd8bffbd63def9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:51:56 +0000 Subject: [PATCH 4/4] 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 --- .../keep-observed-across-resubscribe.md | 2 +- .../unsubscribe-after-clear-listeners.md | 5 +++ packages/retree-core/src/ReactiveNode.spec.ts | 10 +++++ packages/retree-core/src/Retree.ts | 22 ++++++---- .../src/internals/subscriptionHub.spec.ts | 41 ++++++++++++++++++- 5 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 .changeset/unsubscribe-after-clear-listeners.md diff --git a/.changeset/keep-observed-across-resubscribe.md b/.changeset/keep-observed-across-resubscribe.md index 0bb9a5b..889b57d 100644 --- a/.changeset/keep-observed-across-resubscribe.md +++ b/.changeset/keep-observed-across-resubscribe.md @@ -4,4 +4,4 @@ `useSelect` no longer re-runs `onUnobserved` and `onObserved` on a `ReactiveNode` it keeps reading when a prop moves the selector's other dependencies. Query nodes such as `ConvexQueryNode` no longer close and reopen their backend subscription on those re-renders. -React hooks now release a `ReactiveNode` one microtask after its last listener unsubscribes, so `onUnobserved` runs just after unmount rather than during it. +React hooks now release a `ReactiveNode` one microtask after its last listener unsubscribes, so `onUnobserved` runs just after unmount rather than during it. Tests that fake `queueMicrotask`, as Jest's fake timers do by default, need to flush microtasks (`jest.runAllTicks()`) before asserting on it. diff --git a/.changeset/unsubscribe-after-clear-listeners.md b/.changeset/unsubscribe-after-clear-listeners.md new file mode 100644 index 0000000..c707162 --- /dev/null +++ b/.changeset/unsubscribe-after-clear-listeners.md @@ -0,0 +1,5 @@ +--- +"@retreejs/core": patch +--- + +Calling a `Retree.on` unsubscribe after `Retree.clearListeners` already removed that listener no longer runs the node's `onUnobserved` a second time. diff --git a/packages/retree-core/src/ReactiveNode.spec.ts b/packages/retree-core/src/ReactiveNode.spec.ts index f6fc567..a9741e1 100644 --- a/packages/retree-core/src/ReactiveNode.spec.ts +++ b/packages/retree-core/src/ReactiveNode.spec.ts @@ -511,6 +511,16 @@ describe("ReactiveNode", () => { expect(root.unobservedCount).toBe(1); }); + it("runs onUnobserved once when clearListeners precedes an unsubscribe", () => { + const root = trackRoot(Retree.root(new ObservedLifecycleNode())); + const unsubscribe = Retree.on(root, "nodeChanged", vi.fn()); + + Retree.clearListeners(root); + unsubscribe(); + + expect(root.unobservedCount).toBe(1); + }); + it("lazily proxies ReactiveNode object and array fields with parent metadata", () => { const root = trackRoot(Retree.root(new NestedPayloadNode())); diff --git a/packages/retree-core/src/Retree.ts b/packages/retree-core/src/Retree.ts index 0f34782..a80517e 100644 --- a/packages/retree-core/src/Retree.ts +++ b/packages/retree-core/src/Retree.ts @@ -1730,15 +1730,19 @@ export class Retree { if (unsubscribed) return; unsubscribed = true; const _listeners = relevantListenerMap.get(unproxiedNode); - if (_listeners) { - const findIndex = _listeners.findIndex((l) => l === callback); - if (findIndex !== -1) { - _listeners.splice(findIndex, 1); - this.handleListenerCountChanged(relevantListenerMap, -1); - } - if (_listeners.length === 0) { - relevantListenerMap.delete(unproxiedNode); - } + const findIndex = + _listeners === undefined + ? -1 + : _listeners.findIndex((l) => l === callback); + if (_listeners === undefined || findIndex === -1) { + // `clearListeners` already removed it and ran the teardown + // below; running it again would repeat `onUnobserved`. + return; + } + _listeners.splice(findIndex, 1); + this.handleListenerCountChanged(relevantListenerMap, -1); + if (_listeners.length === 0) { + relevantListenerMap.delete(unproxiedNode); } const isReactiveChangeListenerMap = relevantListenerMap !== this.nodeRemovedListeners; diff --git a/packages/retree-react/src/internals/subscriptionHub.spec.ts b/packages/retree-react/src/internals/subscriptionHub.spec.ts index b8b463e..242e5f5 100644 --- a/packages/retree-react/src/internals/subscriptionHub.spec.ts +++ b/packages/retree-react/src/internals/subscriptionHub.spec.ts @@ -4,10 +4,49 @@ */ import { describe, expect, it, vi } from "vitest"; -import { Retree } from "@retreejs/core"; +import { ReactiveNode, Retree } from "@retreejs/core"; import { subscribeToNode } from "./subscriptionHub.js"; +class LifecycleNode extends ReactiveNode { + public count = 0; + public observed = 0; + public unobserved = 0; + get dependencies() { + return []; + } + protected onObserved(): void { + this.observed++; + } + protected onUnobserved(): void { + this.unobserved++; + } +} + describe("subscriptionHub", () => { + it("defers a ReactiveNode teardown so a same-tick resubscribe keeps it observed", async () => { + const node = Retree.root(new LifecycleNode()); + const listener = vi.fn(); + subscribeToNode(node, "nodeChanged", vi.fn())(); + subscribeToNode(node, "nodeChanged", vi.fn())(); + const unsubscribe = subscribeToNode(node, "nodeChanged", listener); + await Promise.resolve(); + + expect(node.observed).toBe(1); + expect(node.unobserved).toBe(0); + node.count = 1; + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + expect(node.unobserved).toBe(0); + await Promise.resolve(); + expect(node.unobserved).toBe(1); + + subscribeToNode(node, "nodeChanged", vi.fn())(); + await Promise.resolve(); + expect(node.observed).toBe(2); + expect(node.unobserved).toBe(2); + }); + it("does not remove another listener when unsubscribe is called twice", () => { const root = Retree.root({ count: 0 }); const listenerA = vi.fn();