From 320a4b56693aa431ac60499c8390cefb032c2003 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:39:39 +0000 Subject: [PATCH] Keep memo key reads out of untracked callers (#122) A keyed memo whose key had to re-run (on its first read, or after a key input changed) replayed its key reads into the enclosing frame even when the caller had paused tracking with Retree.untracked, so the outer selector or effect subscribed to them. Validated hits already skipped the replay. The check now lives in replayDependencyComparisonAccesses, so every replay honors it. Claude-Session: https://claude.ai/code/session_01Us9f7SPLJUYScpdDnA8oM4 --- .changeset/memo-untracked-cold-key.md | 5 +++ .../src/internals/dependency-tracking.ts | 5 +++ packages/retree-core/src/internals/memo.ts | 2 - packages/retree-core/src/memo.spec.ts | 44 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 .changeset/memo-untracked-cold-key.md 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); + }); +});