diff --git a/.changeset/memo-untracked-cold-key.md b/.changeset/memo-untracked-cold-key.md new file mode 100644 index 0000000..4a4b656 --- /dev/null +++ b/.changeset/memo-untracked-cold-key.md @@ -0,0 +1,5 @@ +--- +"@retreejs/core": patch +--- + +A `@memo` or `@fnMemo` read inside `Retree.untracked` no longer subscribes the surrounding selector or effect to its key's reads. Before, a memo whose key had to re-run (on its first read, or after a key input changed) leaked those reads, so whether a selector stayed asleep depended on what had warmed the memo. diff --git a/packages/retree-core/src/internals/dependency-tracking.ts b/packages/retree-core/src/internals/dependency-tracking.ts index 14f7152..0e663b5 100644 --- a/packages/retree-core/src/internals/dependency-tracking.ts +++ b/packages/retree-core/src/internals/dependency-tracking.ts @@ -1172,6 +1172,11 @@ export function replayDependencyComparisonAccesses( comparisons: unknown[], comparisonValues?: readonly (readonly unknown[])[] ): void { + // Paused by `Retree.untracked` or a memo body: the memo's own frame + // tracked these reads, and the paused caller must not inherit them. + if (!isDependencyTrackingActive()) { + return; + } const currentFrame = dependencyAccessStack[dependencyAccessStack.length - 1]; if (currentFrame === undefined) { diff --git a/packages/retree-core/src/internals/memo.ts b/packages/retree-core/src/internals/memo.ts index 5a37329..1cfd005 100644 --- a/packages/retree-core/src/internals/memo.ts +++ b/packages/retree-core/src/internals/memo.ts @@ -19,7 +19,6 @@ import { collectDependencyComparisonAccesses, createNodeVersionRead, replayDependencyComparisonAccesses, - isDependencyTrackingActive, runMemoBody, } from "./dependency-tracking.js"; import { getDependencyComparisonValues } from "./dependencies.js"; @@ -172,7 +171,6 @@ function validateTrappedMemo(entry: IMemoCacheEntry): boolean { } function replayTrappedMemo(entry: IMemoCacheEntry): void { - if (!isDependencyTrackingActive()) return; if (entry.comparisonAccessors === undefined) return; replayDependencyComparisonAccesses( entry.comparisonAccessors, diff --git a/packages/retree-core/src/memo.spec.ts b/packages/retree-core/src/memo.spec.ts index 3e0b77c..26a7225 100644 --- a/packages/retree-core/src/memo.spec.ts +++ b/packages/retree-core/src/memo.spec.ts @@ -2366,3 +2366,47 @@ describe("reads through memos with hidden bodies", () => { expect(root.consumer.value).toBe("Grace"); }); }); + +describe("memo reads under Retree.untracked", () => { + class Inner extends ReactiveNode { + public a: object | null = null; + get dependencies() { + return []; + } + } + class Outer extends ReactiveNode { + public inner = new Inner(); + get dependencies() { + return []; + } + @memo((self: Outer) => [self.inner.a]) + get memoized() { + return this.inner.a; + } + } + type TTrack = (run: () => void) => () => void; + const select: TTrack = (run) => Retree.select(run, () => {}); + const effect: TTrack = (run) => Retree.effect(run); + + it.each([ + ["cold", "Retree.select", select], + ["cold", "Retree.effect", effect], + ["stale", "Retree.select", select], + ])("keeps a %s keyed memo's key reads out of %s", (state, _, track) => { + const root = trackRoot(Retree.root(new Outer())); + if (state === "stale") { + void root.memoized; + root.inner.a = {}; + } + let runs = 0; + const stop = track(() => { + runs++; + Retree.untracked(() => root.memoized); + }); + + root.inner.a = {}; + stop(); + + expect(runs).toBe(1); + }); +});