From ead22c0c3cdf1ec29076288b3ad70d1dbe28cd87 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Fri, 24 Oct 2025 17:21:09 +0200 Subject: [PATCH] deep signals fix proxy object delete --- sdk/js/alien-deepsignals/src/deepSignal.ts | 41 ++++++-- .../src/test/deepSignalOptions.test.ts | 94 +++++++++++++++++++ 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/sdk/js/alien-deepsignals/src/deepSignal.ts b/sdk/js/alien-deepsignals/src/deepSignal.ts index 95488b5e..991f4dad 100644 --- a/sdk/js/alien-deepsignals/src/deepSignal.ts +++ b/sdk/js/alien-deepsignals/src/deepSignal.ts @@ -258,6 +258,8 @@ export function getDeepSignalRootId(obj: any): symbol | undefined { const proxyToSignals = new WeakMap(); // Raw object/array/Set -> stable proxy const objToProxy = new WeakMap(); +// Proxy -> raw object/array/Set (reverse lookup) +const proxyToRaw = new WeakMap(); // Raw array -> `$` meta proxy with index signals const arrayToArrayOfSignals = new WeakMap(); // Objects already proxied or marked shallow @@ -381,22 +383,25 @@ export function setSetEntrySyntheticId(obj: object, id: string | number) { } const getSetEntryKey = (val: any): string | number => { if (val && typeof val === "object") { + // If val is a proxy, get the raw object first + const rawVal = proxyToRaw.get(val) || val; + // First check for explicitly assigned synthetic ID - if (setObjectIds.has(val)) return setObjectIds.get(val)!; + if (setObjectIds.has(rawVal)) return setObjectIds.get(rawVal)!; // Then check for @id property (primary identifier) if ( - typeof (val as any)["@id"] === "string" || - typeof (val as any)["@id"] === "number" + typeof (rawVal as any)["@id"] === "string" || + typeof (rawVal as any)["@id"] === "number" ) - return (val as any)["@id"]; + return (rawVal as any)["@id"]; // Then check for id property (backward compatibility) if ( - typeof (val as any).id === "string" || - typeof (val as any).id === "number" + typeof (rawVal as any).id === "string" || + typeof (rawVal as any).id === "number" ) - return (val as any).id; + return (rawVal as any).id; // Fall back to generating a blank node ID - return assignBlankNodeId(val); + return assignBlankNodeId(rawVal); } return val as any; }; @@ -451,6 +456,7 @@ export const deepSignal = ( // Pre-register an empty signals map so isDeepSignal() is true before any property access. if (!proxyToSignals.has(proxy)) proxyToSignals.set(proxy, new Map()); objToProxy.set(obj, proxy); + proxyToRaw.set(proxy, obj); } return objToProxy.get(obj); }; @@ -523,6 +529,7 @@ function getFromSet( childMeta.parent = receiver; childMeta.key = synthetic; objToProxy.set(entry, childProxy); + proxyToRaw.set(childProxy, entry); return childProxy; } if (objToProxy.has(entry)) return objToProxy.get(entry); @@ -534,6 +541,16 @@ function getFromSet( if (key === "add" || key === "delete" || key === "clear") { const fn: Function = (raw as any)[key]; return function (this: any, ...args: any[]) { + // For delete, keep track of the original entry for patch emission + const originalEntry = key === "delete" ? args[0] : undefined; + + // For delete, if the argument is a proxy, get the raw object for the actual Set operation + if (key === "delete" && args[0] && typeof args[0] === "object") { + const rawArg = proxyToRaw.get(args[0]); + if (rawArg) { + args = [rawArg]; + } + } const sizeBefore = raw.size; const result = fn.apply(raw, args); if (raw.size !== sizeBefore) { @@ -599,6 +616,7 @@ function getFromSet( childMeta.parent = receiver; childMeta.key = synthetic; objToProxy.set(entryVal, childProxy); + proxyToRaw.set(childProxy, entryVal); entryVal = childProxy; } // Set entry add: emit object vs primitive variant. @@ -621,7 +639,8 @@ function getFromSet( }); } } else if (key === "delete") { - const entry = args[0]; + // Use the original entry (before proxy-to-raw conversion) for getting the synthetic key + const entry = originalEntry; const synthetic = getSetEntryKey(entry); // Check if entry is primitive or object if (entry && typeof entry === "object") { @@ -828,6 +847,10 @@ const get = if (target instanceof Set) { return getFromSet(target as Set, fullKey as any, receiver); } + // Special case: accessing `$` on a non-array object returns the raw target + if (fullKey === "$" && !Array.isArray(target)) { + return target; + } const norm = normalizeKey(target, fullKey, isArrayMeta, receiver); if ((norm as any).shortCircuit) return (norm as any).shortCircuit; // returned meta proxy const { key, returnSignal } = norm as { diff --git a/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts b/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts index 5b3f7c76..2b44e723 100644 --- a/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts +++ b/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts @@ -336,5 +336,99 @@ describe("deepSignal options", () => { stop(); }); + + it("emits delete patch when removing objects with @id from Sets", async () => { + const options: DeepSignalOptions = { + addIdToObjects: true, + }; + + const state = deepSignal({ s: new Set() }, options); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + // Add objects with @id + const obj1 = { "@id": "obj-1", value: 1 }; + const obj2 = { "@id": "obj-2", value: 2 }; + const obj3 = { "@id": "obj-3", value: 3 }; + + state.s.add(obj1); + state.s.add(obj2); + state.s.add(obj3); + await Promise.resolve(); + + // Get the proxied objects from the Set + const proxiedObjs = Array.from(state.s); + const proxiedObj2 = proxiedObjs.find( + (o: any) => o["@id"] === "obj-2" + ); + + // Clear patches from additions + patches.length = 0; + + // Delete one object using the proxied object + state.s.delete(proxiedObj2); + await Promise.resolve(); + + // Check that delete patch was emitted with correct path + const deletePaths = patches + .flat() + .filter((p) => p.op === "remove") + .map((p) => p.path.join(".")); + + expect(deletePaths).toContain("s.obj-2"); + expect(deletePaths).not.toContain("s.obj-1"); + expect(deletePaths).not.toContain("s.obj-3"); + + stop(); + }); + + it("emits delete patches when removing objects without explicit @id from Sets", async () => { + const options: DeepSignalOptions = { + idGenerator: () => + `gen-${Math.random().toString(36).substr(2, 9)}`, + addIdToObjects: true, + }; + + const state = deepSignal({ s: new Set() }, options); + + // Add objects without @id - they should get generated IDs + const obj1 = { value: 1 }; + const obj2 = { value: 2 }; + + state.s.add(obj1); + state.s.add(obj2); + + // Get the proxied objects and their generated IDs + const proxiedObjs = Array.from(state.s); + const proxiedObj1 = proxiedObjs[0]; + const proxiedObj2 = proxiedObjs[1]; + const id1 = (proxiedObj1 as any)["@id"]; + const id2 = (proxiedObj2 as any)["@id"]; + + expect(id1).toBeDefined(); + expect(id2).toBeDefined(); + + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + // Delete one object using the proxied object + state.s.delete(proxiedObj1); + await Promise.resolve(); + + // Check that delete patch was emitted with the generated ID + const deletePaths = patches + .flat() + .filter((p) => p.op === "remove") + .map((p) => p.path.join(".")); + + expect(deletePaths).toContain(`s.${id1}`); + expect(deletePaths).not.toContain(`s.${id2}`); + + stop(); + }); }); });