From 2b3079ad04c8dd6f3981a51cad9d56824122ca1a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 01:18:08 -0700 Subject: [PATCH] fix(signals): compare symbol keys in UNSTABLE_MEMO_OUTPUT The unstable-output check compared plain objects by Object.keys, which skips symbol keys. A memo that returns a fresh symbol-keyed box each run looked like a run of empty objects, so it warned as new-but-equivalent although every box held a different value. web's dynamic does this for an in-flight promise ({ [FLIGHT]: promise }), so every refetch through dynamic counted toward the warning. shallowEquivalent now compares Reflect.ownKeys. Boxes whose symbol-keyed values are identical still warn. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/unstable-memo-symbol-keys.md | 5 ++++ packages/signals/src/core/attribution.ts | 11 ++++++--- .../tests/attribution-unstable-memo.test.ts | 24 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .changeset/unstable-memo-symbol-keys.md diff --git a/.changeset/unstable-memo-symbol-keys.md b/.changeset/unstable-memo-symbol-keys.md new file mode 100644 index 000000000..4f50c36f0 --- /dev/null +++ b/.changeset/unstable-memo-symbol-keys.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`UNSTABLE_MEMO_OUTPUT` compares symbol-keyed properties too, so a memo returning a fresh symbol-keyed box each run (as `dynamic` does for an in-flight promise) is no longer reported as new-but-equivalent. diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index 769127e33..8862105db 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -1726,10 +1726,15 @@ function shallowEquivalent(a: object, b: object): boolean { for (let i = 0; i < arrA.length; i++) if (arrA[i] !== arrB[i]) return false; return true; } - const keys = Object.keys(a); - if (keys.length > UNSTABLE_KEY_CAP || keys.length !== Object.keys(b).length) return false; + // Every own key, symbols included: a symbol-keyed box holding a fresh value + // each run (web's `dynamic` boxes its in-flight promise) is not equivalent. + const keys = Reflect.ownKeys(a); + if (keys.length > UNSTABLE_KEY_CAP || keys.length !== Reflect.ownKeys(b).length) return false; for (const key of keys) { - if (!(key in b) || (a as Record)[key] !== (b as Record)[key]) + if ( + !(key in b) || + (a as Record)[key] !== (b as Record)[key] + ) return false; } return true; diff --git a/packages/signals/tests/attribution-unstable-memo.test.ts b/packages/signals/tests/attribution-unstable-memo.test.ts index cfc9ec96e..80c50c859 100644 --- a/packages/signals/tests/attribution-unstable-memo.test.ts +++ b/packages/signals/tests/attribution-unstable-memo.test.ts @@ -101,6 +101,30 @@ describe("UNSTABLE_MEMO_OUTPUT", () => { expect(events).toHaveLength(0); }); + it("compares symbol-keyed properties", () => { + const BOX = Symbol("box"); + const [n, setN] = createSignal(1, { name: "n" }); + const shared = {}; + createRoot(() => { + const varying = createMemo(() => ({ [BOX]: { run: n() } }), { name: "varying-symbol" }); + const same = createMemo(() => (n(), { [BOX]: shared }), { name: "same-symbol" }); + createEffect( + () => (varying(), same()), + () => {}, + { name: "consumer" } + ); + }); + flush(); + + const events = captureUnstable(); + for (let i = 2; i <= 8; i++) { + setN(i); + flush(); + } + + expect(events.map(e => e.nodeName)).toEqual(["same-symbol"]); + }); + it("stays quiet for stable references, primitives, and non-plain shapes", () => { const [n, setN] = createSignal(1, { name: "n" }); const stable = { fixed: true };