Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/equals-gates-tracked-select.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions packages/retree-core/src/Retree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
41 changes: 30 additions & 11 deletions packages/retree-core/src/internals/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,31 @@ export function defaultTrackedSelectedChanged<TSelected>(
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<TSelected>(
previous: TrackedSelectionValue<TSelected>,
next: TrackedSelectionValue<TSelected>,
equals: RetreeSelectEquals<TSelected> | undefined
): boolean {
if (equals !== undefined) {
return !equals(previous.selected, next.selected);
}
return (
defaultTrackedSelectedChanged(previous.selected, next.selected) ||
!areTrackedReadsEqual(previous.reads, next.reads)
);
}

interface TrackedSelectionValue<TSelected> {
selected: TSelected;
reads: ReadonlyMap<TreeNode, NodeReadRecord>;
}

export function stabilizeSelectedRetreeReferences<TSelected>(
previous: TSelected,
next: TSelected
Expand Down Expand Up @@ -451,16 +476,10 @@ export function createRetreeTrackedSelectionObserver<TSelected>(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 = {
Expand All @@ -469,7 +488,7 @@ export function createRetreeTrackedSelectionObserver<TSelected>(options: {
reads: next.reads,
subtreeReads: next.subtreeReads,
};
if (!selectedChanged && !dependenciesChanged) {
if (!changed) {
return;
}
options.onChange(nextSelected, previousToEmit.selected);
Expand Down
42 changes: 42 additions & 0 deletions packages/retree-react/src/useSelect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div data-testid="value">{ids.join(",")}</div>;
}

render(<Probe />);
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({
Expand Down
23 changes: 9 additions & 14 deletions packages/retree-react/src/useSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@

import { RetreeSelectOptions, TreeNode } from "@retreejs/core";
import {
areTrackedReadsEqual,
canSkipTrackedDependencyChange,
DependencySubscriptionKind,
getUnproxiedNode,
defaultSelectShouldNotify,
defaultTrackedSelectedChanged,
getBaseProxy,
getReproxyNode,
hasTrackedSelectionChanged,
normalizeDependencyEntry,
normalizeSelectDependencies,
runTrackedSelection,
Expand Down Expand Up @@ -521,30 +521,25 @@ function refreshTrackedSelectState<TSelected>(
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) {
Expand Down
Loading