deep signals: emit nested patches when adding new object too

refactor
Laurin Weger 1 day 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). */
export function subscribeDeepMutations(
root: object | symbol,
@ -336,7 +380,7 @@ export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => {
/** Read property without tracking (untracked read). */
export const peek = <
T extends DeepSignalObject<object>,
K extends keyof RevertDeepSignalObject<T>
K extends keyof RevertDeepSignalObject<T>,
>(
obj: T,
key: K
@ -365,7 +409,9 @@ const createProxy = (
ignore.add(proxy);
// Initialize proxy metadata if not present. Root proxies provide a stable root id.
if (!proxyMeta.has(proxy)) {
proxyMeta.set(proxy, { root: rootId || Symbol("deepSignalDetachedRoot") });
proxyMeta.set(proxy, {
root: rootId || Symbol("deepSignalDetachedRoot"),
});
}
return proxy;
};
@ -410,7 +456,10 @@ function getFromSet(
metaNow.parent !== undefined &&
metaNow.key !== undefined
) {
const containerPath = buildPath(metaNow.parent, metaNow.key);
const containerPath = buildPath(
metaNow.parent,
metaNow.key
);
if (key === "add") {
const entry = args[0];
let synthetic = getSetEntryKey(entry);
@ -473,8 +522,11 @@ function getFromSet(
for (let i = group.length - 1; i >= 0; i--) {
const p = group[i];
if (
p.path.length === containerPath.length + 1 &&
containerPath.every((seg, idx) => p.path[idx] === seg)
p.path.length ===
containerPath.length + 1 &&
containerPath.every(
(seg, idx) => p.path[idx] === seg
)
) {
group.splice(i, 1);
}
@ -504,7 +556,10 @@ function getFromSet(
const n = iterable.next();
if (n.done) return n;
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") {
return function thisForEach(this: any, cb: any, thisArg?: 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)) {
arrayToArrayOfSignals.set(
target,
createProxy(target, arrayHandlers, proxyMeta.get(receiver)?.root)
createProxy(
target,
arrayHandlers,
proxyMeta.get(receiver)?.root
)
);
}
return { shortCircuit: arrayToArrayOfSignals.get(target) };
@ -655,7 +719,8 @@ const objectHandlers = {
// Respect original getter/setter semantics
if (typeof descriptor(target, fullKey)?.set === "function")
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);
if (fullKey[0] === "$") {
if (!isSignal(val)) throwOnMutation();
@ -679,7 +744,11 @@ const objectHandlers = {
proxyMeta.set(receiver, created);
parentMeta = created;
}
const childProxy = createProxy(val, objectHandlers, parentMeta!.root);
const childProxy = createProxy(
val,
objectHandlers,
parentMeta!.root
);
const childMeta = proxyMeta.get(childProxy)!;
childMeta.parent = receiver;
childMeta.key = fullKey;
@ -697,28 +766,15 @@ const objectHandlers = {
// Subsequent writes -> update underlying signal.
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"))
signals.get("length").set(target.length);
// Emit patch (after mutation) so subscribers get final value snapshot.
const meta = proxyMeta.get(receiver);
if (meta) {
// Object/Array/Set assignment at property path.
if (val && typeof val === "object") {
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,
});
}
// Recursively emit patches for all nested properties of newly attached objects
queueDeepPatches(val, meta.root, buildPath(receiver, fullKey));
}
return result;
}
@ -810,7 +866,8 @@ type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>;
type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">;
/** 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>
: T extends object
? RevertDeepSignalObject<T>

@ -105,8 +105,7 @@ describe("watch (patch mode)", () => {
const { stopListening: stop } = watch(state, ({ patches: batch }) =>
patches.push(batch)
);
state.root.child = { level: { value: 1 } };
state.root.child.level.value = 2;
state.root.child = { level: { value: 1 }, l1: "val" };
await Promise.resolve();
const flat = patches.flat().map((p) => p.path.join("."));
expect(flat).toContain("root.child");
@ -114,6 +113,68 @@ describe("watch (patch mode)", () => {
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 () => {
const innerA = new Set<any>([{ id: "node1", x: 1 }]);
const s = new Set<any>([innerA]);
@ -247,7 +308,11 @@ describe("watch (patch mode)", () => {
});
it("values/entries/forEach proxy nested mutation", async () => {
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 { stopListening: stop } = watch(st, ({ patches }) =>
batches.push(patches)
@ -277,7 +342,9 @@ describe("watch (patch mode)", () => {
proxied.data.x = 3;
await Promise.resolve();
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();
});
it("synthetic id collision assigns unique blank node id", async () => {

Loading…
Cancel
Save