From d2d9c665f6f547d31172960f4e9a78e37b467c85 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Wed, 22 Oct 2025 22:53:57 +0200 Subject: [PATCH] fix root set path missing @id --- sdk/js/alien-deepsignals/src/deepSignal.ts | 16 +- .../src/test/deepSignalOptions.test.ts | 24 ++- .../src/test/watchPatches.test.ts | 144 +++++++++++++----- sdk/js/alien-deepsignals/test-map-type.js | 20 --- sdk/js/alien-deepsignals/test-set-map.js | 28 ---- sdk/js/alien-deepsignals/test-set-paths.js | 25 --- 6 files changed, 126 insertions(+), 131 deletions(-) delete mode 100644 sdk/js/alien-deepsignals/test-map-type.js delete mode 100644 sdk/js/alien-deepsignals/test-set-map.js delete mode 100644 sdk/js/alien-deepsignals/test-set-paths.js diff --git a/sdk/js/alien-deepsignals/src/deepSignal.ts b/sdk/js/alien-deepsignals/src/deepSignal.ts index 19ce5c4d..95488b5e 100644 --- a/sdk/js/alien-deepsignals/src/deepSignal.ts +++ b/sdk/js/alien-deepsignals/src/deepSignal.ts @@ -538,15 +538,13 @@ function getFromSet( const result = fn.apply(raw, args); if (raw.size !== sizeBefore) { const metaNow = proxyMeta.get(receiver); - if ( - metaNow && - metaNow.parent !== undefined && - metaNow.key !== undefined - ) { - const containerPath = buildPath( - metaNow.parent, - metaNow.key - ); + if (metaNow) { + // For root Set, containerPath is empty; for nested Set, build path from parent + const containerPath = + metaNow.parent !== undefined && + metaNow.key !== undefined + ? buildPath(metaNow.parent, metaNow.key) + : []; if (key === "add") { const entry = args[0]; diff --git a/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts b/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts index d9caa86c..5b3f7c76 100644 --- a/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts +++ b/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - deepSignal, - DeepPatchInternal, - DeepSignalOptions, -} from "../deepSignal"; +import { deepSignal, DeepPatch, DeepSignalOptions } from "../deepSignal"; import { watch } from "../watch"; describe("deepSignal options", () => { @@ -16,7 +12,7 @@ describe("deepSignal options", () => { }; const state = deepSignal({ data: {} as any }, options); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -55,7 +51,7 @@ describe("deepSignal options", () => { }; const state = deepSignal({ s: new Set() }, options); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -80,7 +76,7 @@ describe("deepSignal options", () => { }; const state = deepSignal({ root: {} as any }, options); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -123,7 +119,7 @@ describe("deepSignal options", () => { }; const state = deepSignal({ items: [] as any[] }, options); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -152,7 +148,7 @@ describe("deepSignal options", () => { }; const state = deepSignal({ s: new Set() }, options); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -214,7 +210,7 @@ describe("deepSignal options", () => { }; const state = deepSignal({ container: {} as any }, options); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -287,7 +283,7 @@ describe("deepSignal options", () => { describe("backward compatibility", () => { it("still works without options", async () => { const state = deepSignal({ data: { value: 1 } }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -302,7 +298,7 @@ describe("deepSignal options", () => { // TODO: Delete duplicate logic for `id`. Only accept @id. it("objects with id property still work for Sets", async () => { const state = deepSignal({ s: new Set() }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -319,7 +315,7 @@ describe("deepSignal options", () => { it("@id takes precedence over id property", async () => { const state = deepSignal({ s: new Set() }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); diff --git a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts index efa8a86d..28a425b9 100644 --- a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts +++ b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts @@ -3,14 +3,14 @@ import { deepSignal, setSetEntrySyntheticId, addWithId, - DeepPatchInternal, + DeepPatch, } from "../deepSignal"; import { watch, observe } from "../watch"; describe("watch (patch mode)", () => { it("emits set patches with correct paths and batching", async () => { const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] }); - const received: DeepPatchInternal[][] = []; + const received: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches }) => { received.push(patches); }); @@ -34,7 +34,7 @@ describe("watch (patch mode)", () => { a: { b: 1 }, c: 2, }); - const out: DeepPatchInternal[][] = []; + const out: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches }) => out.push(patches) ); @@ -52,8 +52,8 @@ describe("watch (patch mode)", () => { it("observe patch mode mirrors watch patch mode", async () => { const state = deepSignal({ a: 1 }); - const wp: DeepPatchInternal[][] = []; - const ob: DeepPatchInternal[][] = []; + const wp: DeepPatch[][] = []; + const ob: DeepPatch[][] = []; const { stopListening: stop1 } = watch(state, ({ patches }) => wp.push(patches) ); @@ -72,7 +72,7 @@ describe("watch (patch mode)", () => { it("filters out patches from other roots", async () => { const a = deepSignal({ x: 1 }); const b = deepSignal({ y: 2 }); - const out: DeepPatchInternal[][] = []; + const out: DeepPatch[][] = []; const { stopListening: stop } = watch(a, ({ patches }) => out.push(patches) ); @@ -86,7 +86,7 @@ describe("watch (patch mode)", () => { it("emits patches for Set structural mutations (add/delete)", async () => { const state = deepSignal<{ s: Set }>({ s: new Set([1, 2]) }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches }) => batches.push(patches) ); @@ -110,7 +110,7 @@ describe("watch (patch mode)", () => { it("emits patches for nested objects added after initialization", async () => { const state = deepSignal<{ root: any }>({ root: {} }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -124,7 +124,7 @@ describe("watch (patch mode)", () => { it("emits patches for deeply nested arrays and objects", async () => { const state = deepSignal<{ data: any }>({ data: null }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -161,7 +161,7 @@ describe("watch (patch mode)", () => { it("emits patches for Set with nested objects added as one operation", async () => { const state = deepSignal<{ container: any }>({ container: {} }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches: batch }) => patches.push(batch) ); @@ -188,7 +188,7 @@ describe("watch (patch mode)", () => { const innerA = new Set([{ id: "node1", x: 1 }]); const s = new Set([innerA]); const state = deepSignal<{ graph: Set }>({ graph: s }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches }) => batches.push(patches) ); @@ -204,7 +204,7 @@ describe("watch (patch mode)", () => { it("tracks deep nested object mutation inside a Set entry after iteration", async () => { const rawEntry = { id: "n1", data: { val: 1 } }; const st = deepSignal({ bag: new Set([rawEntry]) }); - const collected: DeepPatchInternal[][] = []; + const collected: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => collected.push(patches) ); @@ -215,9 +215,7 @@ describe("watch (patch mode)", () => { } proxied.data.val = 2; await Promise.resolve(); - const flat = collected - .flat() - .map((p: DeepPatchInternal) => p.path.join(".")); + const flat = collected.flat().map((p: DeepPatch) => p.path.join(".")); expect(flat.some((p: string) => p.endsWith("n1.data.val"))).toBe(true); stop(); }); @@ -225,15 +223,13 @@ describe("watch (patch mode)", () => { it("allows custom synthetic id for Set entry", async () => { const node = { name: "x" }; const state = deepSignal({ s: new Set() }); - const collected2: DeepPatchInternal[][] = []; + const collected2: DeepPatch[][] = []; const { stopListening: stop } = watch(state, ({ patches }) => collected2.push(patches) ); addWithId(state.s as any, node, "custom123"); await Promise.resolve(); - const flat = collected2 - .flat() - .map((p: DeepPatchInternal) => p.path.join(".")); + const flat = collected2.flat().map((p: DeepPatch) => p.path.join(".")); expect(flat.some((p: string) => p === "s.custom123")).toBe(true); stop(); }); @@ -241,7 +237,7 @@ describe("watch (patch mode)", () => { describe("Set", () => { it("emits patches for primitive adds", async () => { const st = deepSignal({ s: new Set() }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -270,7 +266,7 @@ describe("watch (patch mode)", () => { }); it("emits patches for primitive deletes", async () => { const st = deepSignal({ s: new Set([true, 2, "3"]) }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -297,7 +293,7 @@ describe("watch (patch mode)", () => { }); it("does not emit patches for non-existent primitives", async () => { const st = deepSignal({ s: new Set([1, 2]) }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -310,7 +306,7 @@ describe("watch (patch mode)", () => { }); it("does not emit patches for already added primitive", async () => { const st = deepSignal({ s: new Set([1, "test", true]) }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -326,7 +322,7 @@ describe("watch (patch mode)", () => { const st = deepSignal({ s: new Set() }); addWithId(st.s as any, { id: "a", x: 1 }, "a"); addWithId(st.s as any, { id: "b", x: 2 }, "b"); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -344,7 +340,7 @@ describe("watch (patch mode)", () => { it("emits delete patch for object entry", async () => { const st = deepSignal({ s: new Set() }); const obj = { id: "n1", x: 1 }; - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches: batch }) => patches.push(batch) ); @@ -360,7 +356,7 @@ describe("watch (patch mode)", () => { }); it("does not emit patch for duplicate add", async () => { const st = deepSignal({ s: new Set([1]) }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches: batch }) => patches.push(batch) ); @@ -371,7 +367,7 @@ describe("watch (patch mode)", () => { }); it("does not emit patch deleting non-existent entry", async () => { const st = deepSignal({ s: new Set([1]) }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches: batch }) => patches.push(batch) ); @@ -382,7 +378,7 @@ describe("watch (patch mode)", () => { }); it("addWithId primitive returns primitive and emits patch with primitive key", async () => { const st = deepSignal({ s: new Set() }); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches: batch }) => patches.push(batch) ); @@ -400,7 +396,7 @@ describe("watch (patch mode)", () => { const st = deepSignal({ s: new Set() }); const obj = { name: "x" }; setSetEntrySyntheticId(obj, "customX"); - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches: batch }) => patches.push(batch) ); @@ -417,7 +413,7 @@ describe("watch (patch mode)", () => { { id: "e1", inner: { v: 1 } }, "e1" ); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -433,7 +429,7 @@ describe("watch (patch mode)", () => { it("raw reference mutation produces no deep patch while proxied does", async () => { const raw = { id: "id1", data: { x: 1 } }; const st = deepSignal({ s: new Set([raw]) }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -455,7 +451,7 @@ describe("watch (patch mode)", () => { const st = deepSignal({ s: new Set() }); const a1 = { id: "dup", v: 1 }; const a2 = { id: "dup", v: 2 }; - const patches: DeepPatchInternal[][] = []; + const patches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches: batch }) => patches.push(batch) ); @@ -487,7 +483,7 @@ describe("watch (patch mode)", () => { expect(arr[0].inner.v).toBe(1); const spread = [...st.s]; expect(spread[0].inner.v).toBe(1); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -497,12 +493,90 @@ describe("watch (patch mode)", () => { expect(flat.some((p) => p.endsWith("eIter.inner.v"))).toBe(true); stop(); }); + + it("generates correct patches when root is a Set (primitive entries)", async () => { + const rootSet = deepSignal(new Set()); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(rootSet, ({ patches }) => + batches.push(patches) + ); + rootSet.add(1); + rootSet.add("test"); + rootSet.add(true); + await Promise.resolve(); + + expect(batches.length).toBe(1); + const patches = batches[0]; + expect(patches.length).toBe(3); + + // When root is a Set, path should be empty array for primitive adds + patches.forEach((p) => { + expect(p.path).toEqual([]); + expect(p.op).toBe("add"); + expect((p as any).type).toBe("set"); + }); + + const values = patches.map((p: any) => p.value[0]); + expect(values).toContain(1); + expect(values).toContain("test"); + expect(values).toContain(true); + stop(); + }); + + it("generates correct patches when root is a Set (object entries)", async () => { + const rootSet = deepSignal(new Set()); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(rootSet, ({ patches }) => + batches.push(patches) + ); + + const obj1 = { "@id": "obj1", value: 1 }; + const obj2 = { "@id": "obj2", value: 2 }; + rootSet.add(obj1); + rootSet.add(obj2); + await Promise.resolve(); + + const flat = batches.flat().map((p) => p.path.join(".")); + + // When root is a Set, first element of path should be synthetic id + expect(flat).toContain("obj1"); + expect(flat).toContain("obj1.@id"); + expect(flat).toContain("obj1.value"); + expect(flat).toContain("obj2"); + expect(flat).toContain("obj2.@id"); + expect(flat).toContain("obj2.value"); + stop(); + }); + + it("tracks nested mutations when root is a Set", async () => { + const rootSet = deepSignal(new Set()); + const obj = { id: "nested", data: { x: 1 } }; + rootSet.add(obj); + + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(rootSet, ({ patches }) => + batches.push(patches) + ); + + // Get the proxied entry + let proxied: any; + for (const e of rootSet.values()) { + proxied = e; + } + + proxied.data.x = 2; + await Promise.resolve(); + + const flat = batches.flat().map((p) => p.path.join(".")); + expect(flat.some((p) => p === "nested.data.x")).toBe(true); + stop(); + }); }); describe("Arrays & mixed batch", () => { it("emits patches for splice/unshift/shift in single batch", async () => { const st = deepSignal({ arr: [1, 2, 3] }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); @@ -516,7 +590,7 @@ describe("watch (patch mode)", () => { }); it("mixed object/array/Set mutations batch together", async () => { const st = deepSignal({ o: { a: 1 }, arr: [1], s: new Set() }); - const batches: DeepPatchInternal[][] = []; + const batches: DeepPatch[][] = []; const { stopListening: stop } = watch(st, ({ patches }) => batches.push(patches) ); diff --git a/sdk/js/alien-deepsignals/test-map-type.js b/sdk/js/alien-deepsignals/test-map-type.js deleted file mode 100644 index bd04ab70..00000000 --- a/sdk/js/alien-deepsignals/test-map-type.js +++ /dev/null @@ -1,20 +0,0 @@ -import { deepSignal } from './dist/index.js'; - -const root = deepSignal({ - mySet: new Set([ - { "@id": "obj1", value: 10 }, - { "@id": "obj2", value: 20 } - ]) -}); - -const result = root.mySet.values().map(entry => entry); -console.log('Type:', typeof result); -console.log('Constructor:', result.constructor.name); -console.log('Result:', result); -console.log('Has next?:', typeof result.next); -console.log('Is iterable?:', Symbol.iterator in result); - -// Convert to array -const arr = Array.from(result); -console.log('Array:', arr); -console.log('First entry:', arr[0]); diff --git a/sdk/js/alien-deepsignals/test-set-map.js b/sdk/js/alien-deepsignals/test-set-map.js deleted file mode 100644 index 984547ef..00000000 --- a/sdk/js/alien-deepsignals/test-set-map.js +++ /dev/null @@ -1,28 +0,0 @@ -import { deepSignal, subscribeDeepMutations } from './dist/index.js'; - -const root = deepSignal({ - mySet: new Set([ - { "@id": "obj1", value: 10 }, - { "@id": "obj2", value: 20 } - ]) -}); - -subscribeDeepMutations(root, (patches) => { - console.log('Patches:', JSON.stringify(patches, null, 2)); -}); - -// Use .map() to get entries -const entries = root.mySet.values().map(entry => { - console.log('Entry:', entry); - return entry; -}); - -console.log('Got entries:', entries.length); -console.log('Modifying first entry...'); - -// Modify the first one -entries[0].value = 100; - -setTimeout(() => { - console.log('Done'); -}, 100); diff --git a/sdk/js/alien-deepsignals/test-set-paths.js b/sdk/js/alien-deepsignals/test-set-paths.js deleted file mode 100644 index aa559ee4..00000000 --- a/sdk/js/alien-deepsignals/test-set-paths.js +++ /dev/null @@ -1,25 +0,0 @@ -import { deepSignal, subscribeDeepMutations } from './dist/index.js'; - -const root = deepSignal({ - mySet: new Set([ - { "@id": "obj1", value: 10 } - ]) -}); - -subscribeDeepMutations(root, (patches) => { - console.log('Patches:', JSON.stringify(patches, null, 2)); -}); - -// Get the first entry from the set -const entries = root.mySet.values(); -const firstEntry = entries.next().value; - -console.log('First entry:', firstEntry); -console.log('Modifying value...'); - -// Modify it -firstEntry.value = 20; - -setTimeout(() => { - console.log('Done'); -}, 100);