From 36a2f1c98cc41d0eb2b3eb585a7595c8cd522b66 Mon Sep 17 00:00:00 2001 From: Ryan Bliss Date: Tue, 8 Sep 2026 14:47:29 -0700 Subject: [PATCH] Let an explicit equals decide when tracked selections notify The tracked forms of Retree.select and useSelect notified and re-rendered when a dependency changed even when the caller's equals reported the previous and next selections equal. The dependency check exists so a selector returning a stable node reference still notifies when that node's content changes; it now applies only to the default comparison. With equals supplied, an equal re-run keeps the previous reference and stays silent. --- .changeset/equals-gates-tracked-select.md | 8 ++++ packages/retree-core/src/Retree.spec.ts | 37 +++++++++++++++++ packages/retree-core/src/internals/select.ts | 41 ++++++++++++++----- packages/retree-react/src/useSelect.spec.tsx | 42 ++++++++++++++++++++ packages/retree-react/src/useSelect.ts | 23 +++++------ 5 files changed, 126 insertions(+), 25 deletions(-) create mode 100644 .changeset/equals-gates-tracked-select.md diff --git a/.changeset/equals-gates-tracked-select.md b/.changeset/equals-gates-tracked-select.md new file mode 100644 index 0000000..a19aedc --- /dev/null +++ b/.changeset/equals-gates-tracked-select.md @@ -0,0 +1,8 @@ +--- +"@retreejs/core": patch +"@retreejs/react": patch +--- + +An explicit `equals` now decides whether the tracked forms of `Retree.select` and `useSelect` notify. + +When a tracked selector re-ran because a dependency changed, both forms notified (and `useSelect` re-rendered) even when the `equals` option reported the previous and next selections equal. The dependency check existed so a selector returning a stable node reference still notifies when that node's content changes; it now applies only to the default comparison. With `equals` supplied, a re-run whose result compares equal keeps the previous reference and stays silent, which is what lets a component select a derived value such as a sorted order and re-render only when that order changes. diff --git a/packages/retree-core/src/Retree.spec.ts b/packages/retree-core/src/Retree.spec.ts index 584e881..e1584d0 100644 --- a/packages/retree-core/src/Retree.spec.ts +++ b/packages/retree-core/src/Retree.spec.ts @@ -1711,6 +1711,43 @@ describe("Retree.select", () => { }); }); +describe("Retree.select tracked equals", () => { + it("does not notify while equals holds across a dependency change", () => { + const root = Retree.root({ + items: [ + { id: "a", rank: 1 }, + { id: "b", rank: 2 }, + ], + }); + const selected = vi.fn(); + Retree.select( + () => + [...root.items] + .sort((left, right) => left.rank - right.rank) + .map((item) => item.id), + selected, + { equals: sameIds } + ); + + root.items[1].rank = 3; + expect(selected).not.toHaveBeenCalled(); + + root.items[1].rank = 0; + expect(selected).toHaveBeenCalledTimes(1); + expect(selected).toHaveBeenLastCalledWith(["b", "a"], ["a", "b"]); + }); +}); + +function sameIds( + previous: readonly string[], + next: readonly string[] +): boolean { + return ( + previous.length === next.length && + previous.every((id, index) => id === next[index]) + ); +} + describe("Retree.untracked", () => { it("pauses dependency collection inside tracked selectors", () => { const root = Retree.root({ diff --git a/packages/retree-core/src/internals/select.ts b/packages/retree-core/src/internals/select.ts index 71e12b4..84e4a46 100644 --- a/packages/retree-core/src/internals/select.ts +++ b/packages/retree-core/src/internals/select.ts @@ -162,6 +162,31 @@ export function defaultTrackedSelectedChanged( return !Object.is(previous, next); } +/** + * Whether a tracked re-run changed what the selector produced. An explicit + * `equals` is the whole answer. The default comparison also counts changed + * reads, since a selector that returns a stable node reference is selecting + * that node's content. + */ +export function hasTrackedSelectionChanged( + previous: TrackedSelectionValue, + next: TrackedSelectionValue, + equals: RetreeSelectEquals | undefined +): boolean { + if (equals !== undefined) { + return !equals(previous.selected, next.selected); + } + return ( + defaultTrackedSelectedChanged(previous.selected, next.selected) || + !areTrackedReadsEqual(previous.reads, next.reads) + ); +} + +interface TrackedSelectionValue { + selected: TSelected; + reads: ReadonlyMap; +} + export function stabilizeSelectedRetreeReferences( previous: TSelected, next: TSelected @@ -451,16 +476,10 @@ export function createRetreeTrackedSelectionObserver(options: { next.selected ); subscriptions.update(next.sources); - const selectedChanged = - options.equals !== undefined - ? !options.equals(previous.selected, nextSelected) - : defaultTrackedSelectedChanged( - previous.selected, - nextSelected - ); - const dependenciesChanged = !areTrackedReadsEqual( - previous.reads, - next.reads + const changed = hasTrackedSelectionChanged( + previous, + { selected: nextSelected, reads: next.reads }, + options.equals ); const previousToEmit = previous; previous = { @@ -469,7 +488,7 @@ export function createRetreeTrackedSelectionObserver(options: { reads: next.reads, subtreeReads: next.subtreeReads, }; - if (!selectedChanged && !dependenciesChanged) { + if (!changed) { return; } options.onChange(nextSelected, previousToEmit.selected); diff --git a/packages/retree-react/src/useSelect.spec.tsx b/packages/retree-react/src/useSelect.spec.tsx index 6773c50..681ea30 100644 --- a/packages/retree-react/src/useSelect.spec.tsx +++ b/packages/retree-react/src/useSelect.spec.tsx @@ -192,6 +192,48 @@ describe("useSelect", () => { expect(renderCount).toBe(2); }); + it("does not re-render the tracked form while equals holds across a dependency change", () => { + const root = trackRoot( + Retree.root({ + items: [ + { id: "a", rank: 1 }, + { id: "b", rank: 2 }, + ], + }) + ); + let renderCount = 0; + + function Probe() { + renderCount += 1; + const ids = useSelect( + () => + [...root.items] + .sort((left, right) => left.rank - right.rank) + .map((item) => item.id), + { + equals: (previous, next) => + previous.length === next.length && + previous.every((id, index) => id === next[index]), + } + ); + return
{ids.join(",")}
; + } + + render(); + expect(renderCount).toBe(1); + + act(() => { + root.items[1].rank = 3; + }); + expect(renderCount).toBe(1); + + act(() => { + root.items[1].rank = 0; + }); + expect(screen.getByTestId("value").textContent).toBe("b,a"); + expect(renderCount).toBe(2); + }); + it("can select across descendant changes with treeChanged", () => { const root = trackRoot( Retree.root({ diff --git a/packages/retree-react/src/useSelect.ts b/packages/retree-react/src/useSelect.ts index b20e410..86723c9 100644 --- a/packages/retree-react/src/useSelect.ts +++ b/packages/retree-react/src/useSelect.ts @@ -12,7 +12,6 @@ import { RetreeSelectOptions, TreeNode } from "@retreejs/core"; import { - areTrackedReadsEqual, canSkipTrackedDependencyChange, DependencySubscriptionKind, getUnproxiedNode, @@ -20,6 +19,7 @@ import { defaultTrackedSelectedChanged, getBaseProxy, getReproxyNode, + hasTrackedSelectionChanged, normalizeDependencyEntry, normalizeSelectDependencies, runTrackedSelection, @@ -521,30 +521,25 @@ function refreshTrackedSelectState( state.container.selected, nextSelection.selected ); - const selectedEqual = - equals !== undefined - ? equals(state.container.selected, stabilizedSelected) - : !defaultTrackedSelectedChanged( - state.container.selected, - stabilizedSelected - ); - const dependenciesEqual = areTrackedReadsEqual( - previousReads, - nextSelection.reads + const selectedEqual = !hasTrackedSelectionChanged( + { selected: state.container.selected, reads: previousReads }, + { selected: stabilizedSelected, reads: nextSelection.reads }, + equals ); const nextSources = getTrackedSelectionSources(nextSelection); const sourcesEqual = areRetreeExternalStoreSourcesEqual( state.sources, nextSources ); - if (selectedEqual && dependenciesEqual && sourcesEqual) { + if (selectedEqual && sourcesEqual) { state.snapshot = snapshot; return state.container; } let selected = stabilizedSelected; if (selectedEqual) { - // Reference stabilization: dependencies or sources changed but the - // selected value did not, so keep handing out the previous reference. + // Reference stabilization: the selected value is unchanged, so keep + // handing out the previous reference even though the subscription + // sources moved. selected = state.container.selected; } if (!sourcesEqual) {