deep signals: emit nested patches when adding new object too

feat/orm-diffs
Laurin Weger 2 days ago
parent fa87233fc9
commit 177e2f2739
No known key found for this signature in database
GPG Key ID: 9B372BB0B792770F
  1. 113
      sdk/js/alien-deepsignals/src/deepSignal.ts
  2. 75
      sdk/js/alien-deepsignals/src/test/watchPatches.test.ts

@ -118,6 +118,50 @@ function queuePatch(patch: DeepPatch) {
} }
} }
/** Recursively emit patches for all nested properties of a newly attached object. */
function queueDeepPatches(
val: any,
rootId: symbol,
basePath: (string | number)[]
) {
if (!val || typeof val !== "object") {
// Emit patch for primitive leaf
queuePatch({
root: rootId,
path: basePath,
op: "add",
value: val,
});
return;
}
// Emit patch for the object/array/Set itself
queuePatch({
root: rootId,
path: basePath,
op: "add",
type: "object",
});
// Recursively process nested properties
if (Array.isArray(val)) {
for (let i = 0; i < val.length; i++) {
queueDeepPatches(val[i], rootId, [...basePath, i]);
}
} else if (val instanceof Set) {
for (const entry of val) {
const key = getSetEntryKey(entry);
queueDeepPatches(entry, rootId, [...basePath, key]);
}
} else if (val.constructor === Object) {
for (const key in val) {
if (Object.prototype.hasOwnProperty.call(val, key)) {
queueDeepPatches(val[key], rootId, [...basePath, key]);
}
}
}
}
/** Subscribe to microtask-batched deep patches for a root (returns unsubscribe). */ /** Subscribe to microtask-batched deep patches for a root (returns unsubscribe). */
export function subscribeDeepMutations( export function subscribeDeepMutations(
root: object | symbol, root: object | symbol,
@ -336,7 +380,7 @@ export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => {
/** Read property without tracking (untracked read). */ /** Read property without tracking (untracked read). */
export const peek = < export const peek = <
T extends DeepSignalObject<object>, T extends DeepSignalObject<object>,
K extends keyof RevertDeepSignalObject<T> K extends keyof RevertDeepSignalObject<T>,
>( >(
obj: T, obj: T,
key: K key: K
@ -365,7 +409,9 @@ const createProxy = (
ignore.add(proxy); ignore.add(proxy);
// Initialize proxy metadata if not present. Root proxies provide a stable root id. // Initialize proxy metadata if not present. Root proxies provide a stable root id.
if (!proxyMeta.has(proxy)) { if (!proxyMeta.has(proxy)) {
proxyMeta.set(proxy, { root: rootId || Symbol("deepSignalDetachedRoot") }); proxyMeta.set(proxy, {
root: rootId || Symbol("deepSignalDetachedRoot"),
});
} }
return proxy; return proxy;
}; };
@ -410,7 +456,10 @@ function getFromSet(
metaNow.parent !== undefined && metaNow.parent !== undefined &&
metaNow.key !== undefined metaNow.key !== undefined
) { ) {
const containerPath = buildPath(metaNow.parent, metaNow.key); const containerPath = buildPath(
metaNow.parent,
metaNow.key
);
if (key === "add") { if (key === "add") {
const entry = args[0]; const entry = args[0];
let synthetic = getSetEntryKey(entry); let synthetic = getSetEntryKey(entry);
@ -473,8 +522,11 @@ function getFromSet(
for (let i = group.length - 1; i >= 0; i--) { for (let i = group.length - 1; i >= 0; i--) {
const p = group[i]; const p = group[i];
if ( if (
p.path.length === containerPath.length + 1 && p.path.length ===
containerPath.every((seg, idx) => p.path[idx] === seg) containerPath.length + 1 &&
containerPath.every(
(seg, idx) => p.path[idx] === seg
)
) { ) {
group.splice(i, 1); group.splice(i, 1);
} }
@ -504,7 +556,10 @@ function getFromSet(
const n = iterable.next(); const n = iterable.next();
if (n.done) return n; if (n.done) return n;
const entry = ensureEntryProxy(n.value); const entry = ensureEntryProxy(n.value);
return { value: pair ? [entry, entry] : entry, done: false }; return {
value: pair ? [entry, entry] : entry,
done: false,
};
}, },
}; };
}, },
@ -516,7 +571,12 @@ function getFromSet(
if (key === "forEach") { if (key === "forEach") {
return function thisForEach(this: any, cb: any, thisArg?: any) { return function thisForEach(this: any, cb: any, thisArg?: any) {
raw.forEach((entry: any) => { raw.forEach((entry: any) => {
cb.call(thisArg, ensureEntryProxy(entry), ensureEntryProxy(entry), raw); cb.call(
thisArg,
ensureEntryProxy(entry),
ensureEntryProxy(entry),
raw
);
}); });
}; };
} }
@ -592,7 +652,11 @@ function normalizeKey(
if (!arrayToArrayOfSignals.has(target)) { if (!arrayToArrayOfSignals.has(target)) {
arrayToArrayOfSignals.set( arrayToArrayOfSignals.set(
target, target,
createProxy(target, arrayHandlers, proxyMeta.get(receiver)?.root) createProxy(
target,
arrayHandlers,
proxyMeta.get(receiver)?.root
)
); );
} }
return { shortCircuit: arrayToArrayOfSignals.get(target) }; return { shortCircuit: arrayToArrayOfSignals.get(target) };
@ -655,7 +719,8 @@ const objectHandlers = {
// Respect original getter/setter semantics // Respect original getter/setter semantics
if (typeof descriptor(target, fullKey)?.set === "function") if (typeof descriptor(target, fullKey)?.set === "function")
return Reflect.set(target, fullKey, val, receiver); return Reflect.set(target, fullKey, val, receiver);
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); if (!proxyToSignals.has(receiver))
proxyToSignals.set(receiver, new Map());
const signals = proxyToSignals.get(receiver); const signals = proxyToSignals.get(receiver);
if (fullKey[0] === "$") { if (fullKey[0] === "$") {
if (!isSignal(val)) throwOnMutation(); if (!isSignal(val)) throwOnMutation();
@ -679,7 +744,11 @@ const objectHandlers = {
proxyMeta.set(receiver, created); proxyMeta.set(receiver, created);
parentMeta = created; parentMeta = created;
} }
const childProxy = createProxy(val, objectHandlers, parentMeta!.root); const childProxy = createProxy(
val,
objectHandlers,
parentMeta!.root
);
const childMeta = proxyMeta.get(childProxy)!; const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver; childMeta.parent = receiver;
childMeta.key = fullKey; childMeta.key = fullKey;
@ -697,28 +766,15 @@ const objectHandlers = {
// Subsequent writes -> update underlying signal. // Subsequent writes -> update underlying signal.
signals.get(fullKey).set(internal); signals.get(fullKey).set(internal);
} }
if (isNew && objToIterable.has(target)) objToIterable.get(target).value++; if (isNew && objToIterable.has(target))
objToIterable.get(target).value++;
if (Array.isArray(target) && signals.has("length")) if (Array.isArray(target) && signals.has("length"))
signals.get("length").set(target.length); signals.get("length").set(target.length);
// Emit patch (after mutation) so subscribers get final value snapshot. // Emit patch (after mutation) so subscribers get final value snapshot.
const meta = proxyMeta.get(receiver); const meta = proxyMeta.get(receiver);
if (meta) { if (meta) {
// Object/Array/Set assignment at property path. // Recursively emit patches for all nested properties of newly attached objects
if (val && typeof val === "object") { queueDeepPatches(val, meta.root, buildPath(receiver, fullKey));
queuePatch({
root: meta.root,
path: buildPath(receiver, fullKey),
op: "add",
type: "object",
});
} else {
queuePatch({
root: meta.root,
path: buildPath(receiver, fullKey),
op: "add",
value: val,
});
}
} }
return result; return result;
} }
@ -810,7 +866,8 @@ type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>;
type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">; type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">;
/** Inverse mapped type removing deepSignal wrapper affordances. */ /** Inverse mapped type removing deepSignal wrapper affordances. */
export type RevertDeepSignal<T> = T extends Array<unknown> export type RevertDeepSignal<T> =
T extends Array<unknown>
? RevertDeepSignalArray<T> ? RevertDeepSignalArray<T>
: T extends object : T extends object
? RevertDeepSignalObject<T> ? RevertDeepSignalObject<T>

@ -105,8 +105,7 @@ describe("watch (patch mode)", () => {
const { stopListening: stop } = watch(state, ({ patches: batch }) => const { stopListening: stop } = watch(state, ({ patches: batch }) =>
patches.push(batch) patches.push(batch)
); );
state.root.child = { level: { value: 1 } }; state.root.child = { level: { value: 1 }, l1: "val" };
state.root.child.level.value = 2;
await Promise.resolve(); await Promise.resolve();
const flat = patches.flat().map((p) => p.path.join(".")); const flat = patches.flat().map((p) => p.path.join("."));
expect(flat).toContain("root.child"); expect(flat).toContain("root.child");
@ -114,6 +113,68 @@ describe("watch (patch mode)", () => {
stop(); stop();
}); });
it("emits patches for deeply nested arrays and objects", async () => {
const state = deepSignal<{ data: any }>({ data: null });
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(state, ({ patches: batch }) =>
patches.push(batch)
);
state.data = {
users: [
{
id: 1,
profile: { name: "Alice", settings: { theme: "dark" } },
},
{
id: 2,
profile: { name: "Bob", settings: { theme: "light" } },
},
],
meta: { count: 2, active: true },
};
await Promise.resolve();
const flat = patches.flat().map((p) => p.path.join("."));
// Check for root object
expect(flat).toContain("data");
// Check for nested array
expect(flat).toContain("data.users");
// Check for array elements
expect(flat).toContain("data.users.0");
expect(flat).toContain("data.users.1");
// Check for deeply nested properties
expect(flat).toContain("data.users.0.profile.settings.theme");
expect(flat).toContain("data.users.1.profile.settings.theme");
expect(flat).toContain("data.meta.count");
expect(flat).toContain("data.meta.active");
stop();
});
it("emits patches for Set with nested objects added as one operation", async () => {
const state = deepSignal<{ container: any }>({ container: {} });
const patches: DeepPatch[][] = [];
const { stopListening: stop } = watch(state, ({ patches: batch }) =>
patches.push(batch)
);
state.container.items = new Set([
{ id: "a", data: { nested: { value: 1 } } },
{ id: "b", data: { nested: { value: 2 } } },
]);
await Promise.resolve();
const flat = patches.flat().map((p) => p.path.join("."));
// Check for the Set itself
expect(flat).toContain("container.items");
// Check for Set entries (using their id as synthetic key)
expect(flat.some((p) => p.startsWith("container.items.a"))).toBe(true);
expect(flat.some((p) => p.startsWith("container.items.b"))).toBe(true);
// Check for deeply nested properties within Set entries
expect(flat).toContain("container.items.a.data.nested.value");
expect(flat).toContain("container.items.b.data.nested.value");
stop();
});
it("emits structural patches for sets of sets", async () => { it("emits structural patches for sets of sets", async () => {
const innerA = new Set<any>([{ id: "node1", x: 1 }]); const innerA = new Set<any>([{ id: "node1", x: 1 }]);
const s = new Set<any>([innerA]); const s = new Set<any>([innerA]);
@ -247,7 +308,11 @@ describe("watch (patch mode)", () => {
}); });
it("values/entries/forEach proxy nested mutation", async () => { it("values/entries/forEach proxy nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() }); const st = deepSignal({ s: new Set<any>() });
const entry = addWithId(st.s as any, { id: "e1", inner: { v: 1 } }, "e1"); const entry = addWithId(
st.s as any,
{ id: "e1", inner: { v: 1 } },
"e1"
);
const batches: DeepPatch[][] = []; const batches: DeepPatch[][] = [];
const { stopListening: stop } = watch(st, ({ patches }) => const { stopListening: stop } = watch(st, ({ patches }) =>
batches.push(patches) batches.push(patches)
@ -277,7 +342,9 @@ describe("watch (patch mode)", () => {
proxied.data.x = 3; proxied.data.x = 3;
await Promise.resolve(); await Promise.resolve();
const afterProxied = batches.flat().map((p) => p.path.join(".")); const afterProxied = batches.flat().map((p) => p.path.join("."));
expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe(true); expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe(
true
);
stop(); stop();
}); });
it("synthetic id collision assigns unique blank node id", async () => { it("synthetic id collision assigns unique blank node id", async () => {

Loading…
Cancel
Save