deep signals improvements

feat/orm-diffs^2
Laurin Weger 4 hours ago
parent b4bcbecaaf
commit e979f0233a
No known key found for this signature in database
GPG Key ID: 9B372BB0B792770F
  1. 8
      sdk/js/alien-deepsignals/README.md
  2. 8
      sdk/js/alien-deepsignals/src/deepSignal.ts
  3. 62
      sdk/js/alien-deepsignals/src/test/patchOptimized.test.ts
  4. 234
      sdk/js/alien-deepsignals/src/test/tier3.test.ts
  5. 154
      sdk/js/alien-deepsignals/src/test/watch.test.ts
  6. 268
      sdk/js/alien-deepsignals/src/watch.ts

@ -53,7 +53,7 @@ console.log(state.$count!()); // read via signal function
```ts
type DeepSignalOptions = {
idGenerator?: () => string | number; // Custom ID generator function
idGenerator?: (pathToObject: (string | number)[]) => string | number; // Custom ID generator function
addIdToObjects?: boolean; // Automatically add @id to plain objects
};
```
@ -84,7 +84,7 @@ When `addIdToObjects: true`, plain objects automatically receive a readonly, enu
const state = deepSignal(
{ data: {} },
{
idGenerator: () => `urn:uuid:${crypto.randomUUID()}`,
idGenerator: (_path) => `urn:uuid:${crypto.randomUUID()}`,
addIdToObjects: true
}
);
@ -191,7 +191,7 @@ import { addWithId, setSetEntrySyntheticId } from "@ng-org/alien-deepsignals";
const state = deepSignal(
{ items: new Set() },
{
idGenerator: () => `urn:uuid:${crypto.randomUUID()}`,
idGenerator: (_path) => `urn:uuid:${crypto.randomUUID()}`,
addIdToObjects: true
}
);
@ -250,7 +250,7 @@ state.s.add({ data: "test" });
const state = deepSignal(
{ users: new Set() },
{
idGenerator: () => `urn:user:${crypto.randomUUID()}`,
idGenerator: (path) => `urn:user:${path.join("-")}:${crypto.randomUUID()}`,
addIdToObjects: true
}
);

@ -59,7 +59,7 @@ export type DeepPatchSubscriber = (patches: DeepPatch[]) => void;
/** Options for configuring deepSignal behavior. */
export interface DeepSignalOptions {
/** Custom function to generate synthetic IDs for objects without @id. */
idGenerator?: () => string | number;
idGenerator?: (pathToObject: (string | number)[]) => string | number;
/** If true, add @id property to all objects in the tree. */
addIdToObjects?: boolean;
}
@ -156,7 +156,7 @@ function queueDeepPatches(
) {
let syntheticId: string | number;
if (options.idGenerator) {
syntheticId = options.idGenerator();
syntheticId = options.idGenerator(basePath);
} else {
syntheticId = assignBlankNodeId(val);
}
@ -516,6 +516,7 @@ function getFromSet(
};
// Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration)
if (meta) raw.forEach(ensureEntryProxy);
if (key === "add" || key === "delete" || key === "clear") {
const fn: Function = (raw as any)[key];
return function (this: any, ...args: any[]) {
@ -545,7 +546,8 @@ function getFromSet(
) {
let syntheticId: string | number;
if (metaNow.options.idGenerator) {
syntheticId = metaNow.options.idGenerator();
syntheticId =
metaNow.options.idGenerator(containerPath);
} else {
syntheticId = assignBlankNodeId(entry);
}

@ -8,40 +8,40 @@ import { watch } from "../watch";
// times traverse() executes under each strategy.
describe("watch patch-only simplified performance placeholder", () => {
let store: any;
const build = (breadth = 3, depth = 3) => {
const make = (d: number): any => {
if (d === 0) return { v: 0 };
const obj: any = {};
for (let i = 0; i < breadth; i++) obj["k" + i] = make(d - 1);
return obj;
let store: any;
const build = (breadth = 3, depth = 3) => {
const make = (d: number): any => {
if (d === 0) return { v: 0 };
const obj: any = {};
for (let i = 0; i < breadth; i++) obj["k" + i] = make(d - 1);
return obj;
};
return make(depth);
};
return make(depth);
};
beforeEach(() => {
store = deepSignal(build());
});
beforeEach(() => {
store = deepSignal(build());
});
function mutateAll(breadth = 3, depth = 3) {
const visit = (node: any, d: number) => {
if (d === 0) {
node.v++;
return;
}
for (let i = 0; i < breadth; i++) visit(node["k" + i], d - 1);
};
visit(store, depth);
}
function mutateAll(breadth = 3, depth = 3) {
const visit = (node: any, d: number) => {
if (d === 0) {
node.v++;
return;
}
for (let i = 0; i < breadth; i++) visit(node["k" + i], d - 1);
};
visit(store, depth);
}
it("receives a single batch of patches after deep mutations", async () => {
let batches = 0;
const { stopListening: stop } = watch(store, ({ patches }) => {
if (patches.length) batches++;
it("receives a single batch of patches after deep mutations", async () => {
let batches = 0;
const { stopListening: stop } = watch(store, ({ patches }) => {
if (patches.length) batches++;
});
mutateAll();
await Promise.resolve();
expect(batches).toBe(1);
stop();
});
mutateAll();
await Promise.resolve();
expect(batches).toBe(1);
stop();
});
});

@ -1,148 +1,148 @@
import { describe, it, expect } from "vitest";
import { deepSignal, getDeepSignalRootId, peek } from "../deepSignal";
import {
watch,
__traverseCount,
__resetTraverseCount,
traverse,
watch,
__traverseCount,
__resetTraverseCount,
traverse,
} from "../watch";
import { effect } from "../core";
describe("watch advanced", () => {
it("basic patch watcher fires on deep mutations", async () => {
const st = deepSignal({ a: { b: { c: 1 } } });
let batches: number = 0;
watch(st, ({ patches }) => {
if (patches.length) batches++;
it("basic patch watcher fires on deep mutations", async () => {
const st = deepSignal({ a: { b: { c: 1 } } });
let batches: number = 0;
watch(st, ({ patches }) => {
if (patches.length) batches++;
});
st.a.b.c = 2;
st.a.b = { c: 3 } as any;
await Promise.resolve();
expect(batches).toBeGreaterThan(0);
});
st.a.b.c = 2;
st.a.b = { c: 3 } as any;
await Promise.resolve();
expect(batches).toBeGreaterThan(0);
});
// multi-source value mode removed; patch-only now - skip equivalent
// multi-source value mode removed; patch-only now - skip equivalent
// getter source value mode removed in patch-only watcher
// getter source value mode removed in patch-only watcher
it("watch once option still stops after first batch", async () => {
const st = deepSignal({ a: 1 });
let count = 0;
watch(
st,
() => {
count++;
},
{ once: true, immediate: true }
);
st.a = 2;
st.a = 3;
await Promise.resolve();
expect(count).toBe(1);
});
it("watch once option still stops after first batch", async () => {
const st = deepSignal({ a: 1 });
let count = 0;
watch(
st,
() => {
count++;
},
{ once: true, immediate: true }
);
st.a = 2;
st.a = 3;
await Promise.resolve();
expect(count).toBe(1);
});
// observe value mode removed; observe is alias of watch
// observe value mode removed; observe is alias of watch
});
describe("patches & root ids", () => {
it("root ids are unique", () => {
const a = deepSignal({});
const b = deepSignal({});
expect(getDeepSignalRootId(a)).not.toBe(getDeepSignalRootId(b));
});
it("root ids are unique", () => {
const a = deepSignal({});
const b = deepSignal({});
expect(getDeepSignalRootId(a)).not.toBe(getDeepSignalRootId(b));
});
// legacy watchPatches API removed; patch mode only valid for deepSignal roots
it("watch throws on non-deepSignal input", () => {
expect(() => watch({} as any, () => {})).toThrow();
});
// legacy watchPatches API removed; patch mode only valid for deepSignal roots
it("watch throws on non-deepSignal input", () => {
expect(() => watch({} as any, () => {})).toThrow();
});
it("Map unsupported does not emit patches", async () => {
const m = new Map<string, number>();
const st = deepSignal({ m });
const patches: any[] = [];
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
m.set("a", 1);
await Promise.resolve();
await Promise.resolve();
expect(patches.length).toBe(0);
stop();
});
it("Map unsupported does not emit patches", async () => {
const m = new Map<string, number>();
const st = deepSignal({ m });
const patches: any[] = [];
const { stopListening: stop } = watch(st, ({ patches: batch }) =>
patches.push(batch)
);
m.set("a", 1);
await Promise.resolve();
await Promise.resolve();
expect(patches.length).toBe(0);
stop();
});
});
describe("tier3: Set iteration variants", () => {
it("entries() iteration proxies nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "eEnt", inner: { v: 1 } });
const paths: string[] = [];
const { stopListening: stop } = watch(st, ({ patches }) =>
paths.push(...patches.map((pp: any) => pp.path.join(".")))
);
for (const [val] of st.s.entries()) {
(val as any).inner.v;
} // ensure proxy
for (const [val] of st.s.entries()) {
(val as any).inner.v = 2;
}
await Promise.resolve();
await Promise.resolve();
expect(paths.some((p) => p.endsWith("eEnt.inner.v"))).toBe(true);
stop();
});
it("entries() iteration proxies nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "eEnt", inner: { v: 1 } });
const paths: string[] = [];
const { stopListening: stop } = watch(st, ({ patches }) =>
paths.push(...patches.map((pp: any) => pp.path.join(".")))
);
for (const [val] of st.s.entries()) {
(val as any).inner.v;
} // ensure proxy
for (const [val] of st.s.entries()) {
(val as any).inner.v = 2;
}
await Promise.resolve();
await Promise.resolve();
expect(paths.some((p) => p.endsWith("eEnt.inner.v"))).toBe(true);
stop();
});
it("forEach iteration proxies nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "fe1", data: { n: 1 } });
const { stopListening: stop } = watch(st, () => {});
st.s.forEach((e) => (e as any).data.n); // access
st.s.forEach((e) => {
(e as any).data.n = 2;
it("forEach iteration proxies nested mutation", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "fe1", data: { n: 1 } });
const { stopListening: stop } = watch(st, () => {});
st.s.forEach((e) => (e as any).data.n); // access
st.s.forEach((e) => {
(e as any).data.n = 2;
});
await Promise.resolve();
await Promise.resolve();
stop();
});
await Promise.resolve();
await Promise.resolve();
stop();
});
it("keys() iteration returns proxies", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "k1", foo: { x: 1 } });
const { stopListening: stop } = watch(st, () => {});
for (const e of st.s.keys()) {
(e as any).foo.x = 2;
}
await Promise.resolve();
await Promise.resolve();
stop();
});
it("keys() iteration returns proxies", async () => {
const st = deepSignal({ s: new Set<any>() });
st.s.add({ id: "k1", foo: { x: 1 } });
const { stopListening: stop } = watch(st, () => {});
for (const e of st.s.keys()) {
(e as any).foo.x = 2;
}
await Promise.resolve();
await Promise.resolve();
stop();
});
});
describe("tier3: peek behavior", () => {
it("peek does not create reactive dependency on property", async () => {
const st = deepSignal({ a: 1 });
let runs = 0;
effect(() => {
runs++;
peek(st, "a");
it("peek does not create reactive dependency on property", async () => {
const st = deepSignal({ a: 1 });
let runs = 0;
effect(() => {
runs++;
peek(st, "a");
});
expect(runs).toBe(1);
st.a = 2;
// Flush microtasks
await Promise.resolve();
await Promise.resolve();
expect(runs).toBe(1); // no rerun
});
expect(runs).toBe(1);
st.a = 2;
// Flush microtasks
await Promise.resolve();
await Promise.resolve();
expect(runs).toBe(1); // no rerun
});
});
describe("tier3: traverse helper direct calls (symbols & sets)", () => {
it("traverse counts and respects depth param", () => {
__resetTraverseCount();
const obj: any = { a: { b: { c: 1 } } };
traverse(obj, 1);
const shallowCount = __traverseCount;
__resetTraverseCount();
traverse(obj, 3);
const deepCount = __traverseCount;
expect(deepCount).toBeGreaterThan(shallowCount);
});
it("traverse counts and respects depth param", () => {
__resetTraverseCount();
const obj: any = { a: { b: { c: 1 } } };
traverse(obj, 1);
const shallowCount = __traverseCount;
__resetTraverseCount();
traverse(obj, 3);
const deepCount = __traverseCount;
expect(deepCount).toBeGreaterThan(shallowCount);
});
});

@ -4,88 +4,88 @@ import { watch } from "../watch";
import { watchEffect } from "../watchEffect";
describe("watch", () => {
it("watch immediate", () => {
const store = deepSignal({
userinfo: {
name: "tom",
},
it("watch immediate", () => {
const store = deepSignal({
userinfo: {
name: "tom",
},
});
let val!: string;
watch(
store,
({ newValue }) => {
val = newValue.userinfo.name;
},
{ immediate: true }
);
expect(val).toEqual("tom");
});
let val!: string;
watch(
store,
({ newValue }) => {
val = newValue.userinfo.name;
},
{ immediate: true }
);
expect(val).toEqual("tom");
});
it("watch deep", () => {
const store = deepSignal({
userinfo: {
name: "tom",
},
it("watch deep", () => {
const store = deepSignal({
userinfo: {
name: "tom",
},
});
let val!: string;
watch(
store,
({ newValue }) => {
val = newValue.userinfo.name;
},
{ immediate: true }
);
let value2!: string;
watch(
store,
({ newValue }) => {
value2 = newValue.userinfo.name;
},
{ immediate: true }
);
expect(val).toEqual("tom");
store.userinfo.name = "jon";
// patch delivery async (microtask)
return Promise.resolve().then(() => {
expect(val).toEqual("jon");
// With refactored watch using native effect, shallow watcher now also updates root reference
expect(value2).toEqual("jon");
});
});
let val!: string;
watch(
store,
({ newValue }) => {
val = newValue.userinfo.name;
},
{ immediate: true }
);
let value2!: string;
watch(
store,
({ newValue }) => {
value2 = newValue.userinfo.name;
},
{ immediate: true }
);
expect(val).toEqual("tom");
store.userinfo.name = "jon";
// patch delivery async (microtask)
return Promise.resolve().then(() => {
expect(val).toEqual("jon");
// With refactored watch using native effect, shallow watcher now also updates root reference
expect(value2).toEqual("jon");
});
});
it("watch once", () => {
const store = deepSignal({
userinfo: {
name: "tom",
},
it("watch once", () => {
const store = deepSignal({
userinfo: {
name: "tom",
},
});
let val!: string;
watch(
store,
({ newValue }) => {
val = newValue.userinfo.name;
},
{ immediate: true, once: true }
);
expect(val).toEqual("tom");
store.userinfo.name = "jon";
// once watcher shouldn't update after first run
expect(val).toEqual("tom");
});
let val!: string;
watch(
store,
({ newValue }) => {
val = newValue.userinfo.name;
},
{ immediate: true, once: true }
);
expect(val).toEqual("tom");
store.userinfo.name = "jon";
// once watcher shouldn't update after first run
expect(val).toEqual("tom");
});
it("watch effect", () => {
const store = deepSignal({
userinfo: {
name: "tom",
},
});
let x = undefined;
watchEffect(() => {
x = store.userinfo.name;
});
it("watch effect", () => {
const store = deepSignal({
userinfo: {
name: "tom",
},
expect(x).toEqual("tom");
store.userinfo.name = "jon";
expect(x).toEqual("jon");
});
let x = undefined;
watchEffect(() => {
x = store.userinfo.name;
});
expect(x).toEqual("tom");
store.userinfo.name = "jon";
expect(x).toEqual("jon");
});
});

@ -1,10 +1,10 @@
import { isObject, isPlainObject, isSet, isMap, isArray } from "./utils";
import { isSignal } from "./core";
import {
isDeepSignal,
subscribeDeepMutations,
getDeepSignalRootId,
DeepPatch,
isDeepSignal,
subscribeDeepMutations,
getDeepSignalRootId,
DeepPatch,
} from "./deepSignal";
import { ReactiveFlags } from "./contents";
@ -15,125 +15,125 @@ export type WatchEffect = (registerCleanup: RegisterCleanup) => void;
/** Options for {@link watch}. */
export interface WatchOptions {
/** Trigger the callback immediately with the current value (default: false). */
immediate?: boolean;
/** Auto-stop the watcher after the first callback run that delivers patches (or immediate call if no patches). */
once?: boolean;
/** Allow legacy/unknown options (ignored) to avoid hard breaks while migrating. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[legacy: string]: any;
/** Trigger the callback immediately with the current value (default: false). */
immediate?: boolean;
/** Auto-stop the watcher after the first callback run that delivers patches (or immediate call if no patches). */
once?: boolean;
/** Allow legacy/unknown options (ignored) to avoid hard breaks while migrating. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[legacy: string]: any;
}
export interface WatchPatchEvent<Root = any> {
/** Patch batch that triggered this callback (may be empty for immediate). */
patches: DeepPatch[];
/** Previous snapshot (deep-cloned) of the root value before these patches. Undefined on first call. */
oldValue: Root | undefined;
/** Current root value (live proxy). */
newValue: Root;
/** Patch batch that triggered this callback (may be empty for immediate). */
patches: DeepPatch[];
/** Previous snapshot (deep-cloned) of the root value before these patches. Undefined on first call. */
oldValue: Root | undefined;
/** Current root value (live proxy). */
newValue: Root;
}
export type WatchPatchCallback<Root = any> = (
event: WatchPatchEvent<Root>
event: WatchPatchEvent<Root>
) => any;
// Internal helper kept for external compatibility.
export const remove = <T>(arr: T[], el: T): void => {
const i = arr.indexOf(el);
if (i > -1) arr.splice(i, 1);
const i = arr.indexOf(el);
if (i > -1) arr.splice(i, 1);
};
/** Observe patch batches on a deep signal root. */
export function watch<Root = any>(
source: Root,
callback: WatchPatchCallback<Root>,
options: WatchOptions = {}
source: Root,
callback: WatchPatchCallback<Root>,
options: WatchOptions = {}
) {
if (!isDeepSignal(source)) {
throw new Error(
"watch() now only supports deepSignal roots (patch mode only)"
);
}
const { immediate, once } = options;
const rootId = getDeepSignalRootId(source as any)!;
let active = true;
let cleanup: (() => void) | undefined;
const registerCleanup: RegisterCleanup = (fn) => {
cleanup = fn;
};
const runCleanup = () => {
if (cleanup) {
try {
cleanup();
} catch {
/* ignore */
} finally {
cleanup = undefined;
}
if (!isDeepSignal(source)) {
throw new Error(
"watch() now only supports deepSignal roots (patch mode only)"
);
}
};
// Deep clone snapshot helper (JSON clone sufficient for typical reactive plain data)
const clone = (v: any) => {
try {
return JSON.parse(JSON.stringify(v));
} catch {
return undefined as any;
}
};
let lastSnapshot: Root | undefined = clone(source);
const stopListening = () => {
if (!active) return;
active = false;
runCleanup();
unsubscribe && unsubscribe();
};
const deliver = (patches: DeepPatch[]) => {
if (!active) return;
runCleanup();
const prev = lastSnapshot;
const next = source as any as Root; // live proxy
try {
callback({
patches,
oldValue: prev,
newValue: next,
});
} finally {
if (active) lastSnapshot = clone(next);
if (once) stopListening();
const { immediate, once } = options;
const rootId = getDeepSignalRootId(source as any)!;
let active = true;
let cleanup: (() => void) | undefined;
const registerCleanup: RegisterCleanup = (fn) => {
cleanup = fn;
};
const runCleanup = () => {
if (cleanup) {
try {
cleanup();
} catch {
/* ignore */
} finally {
cleanup = undefined;
}
}
};
// Deep clone snapshot helper (JSON clone sufficient for typical reactive plain data)
const clone = (v: any) => {
try {
return JSON.parse(JSON.stringify(v));
} catch {
return undefined as any;
}
};
let lastSnapshot: Root | undefined = clone(source);
const stopListening = () => {
if (!active) return;
active = false;
runCleanup();
unsubscribe && unsubscribe();
};
const deliver = (patches: DeepPatch[]) => {
if (!active) return;
runCleanup();
const prev = lastSnapshot;
const next = source as any as Root; // live proxy
try {
callback({
patches,
oldValue: prev,
newValue: next,
});
} finally {
if (active) lastSnapshot = clone(next);
if (once) stopListening();
}
};
const unsubscribe = subscribeDeepMutations(rootId, (patches) => {
if (!patches.length) return; // ignore empty batches
deliver(patches);
});
if (immediate) {
// Immediate call with empty patch list (snapshot only)
deliver([]);
}
};
const unsubscribe = subscribeDeepMutations(rootId, (patches) => {
if (!patches.length) return; // ignore empty batches
deliver(patches);
});
if (immediate) {
// Immediate call with empty patch list (snapshot only)
deliver([]);
}
return {
/** Stop listening to future patch batches; idempotent. */
stopListening,
/** Register a cleanup callback run before the next invocation / stop. */
registerCleanup,
};
return {
/** Stop listening to future patch batches; idempotent. */
stopListening,
/** Register a cleanup callback run before the next invocation / stop. */
registerCleanup,
};
}
// observe alias
export function observe(
source: any,
cb: WatchPatchCallback,
options?: WatchOptions
source: any,
cb: WatchPatchCallback,
options?: WatchOptions
) {
return watch(source, cb, options);
return watch(source, cb, options);
}
// Instrumentation counter for performance tests (number of traverse invocations)
@ -141,7 +141,7 @@ export function observe(
export let __traverseCount = 0; // retained for external tooling/tests although watch no longer uses traversal
/** Reset the traversal instrumentation counter back to 0. */
export function __resetTraverseCount() {
__traverseCount = 0;
__traverseCount = 0;
}
/**
@ -149,40 +149,40 @@ export function __resetTraverseCount() {
* Depth-limited; protects against cycles via `seen` set; respects ReactiveFlags.SKIP opt-out.
*/
export function traverse(
value: unknown,
depth: number = Infinity,
seen?: Set<unknown>
value: unknown,
depth: number = Infinity,
seen?: Set<unknown>
): unknown {
__traverseCount++;
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value;
}
seen = seen || new Set();
if (seen.has(value)) {
return value;
}
seen.add(value);
depth--;
if (isSignal(value)) {
traverse((value as any)(), depth, seen);
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], depth, seen);
__traverseCount++;
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value;
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => {
traverse(v, depth, seen);
});
} else if (isPlainObject(value)) {
for (const key in value) {
traverse(value[key], depth, seen);
seen = seen || new Set();
if (seen.has(value)) {
return value;
}
for (const key of Object.getOwnPropertySymbols(value)) {
if (Object.prototype.propertyIsEnumerable.call(value, key)) {
traverse(value[key as any], depth, seen);
}
seen.add(value);
depth--;
if (isSignal(value)) {
traverse((value as any)(), depth, seen);
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], depth, seen);
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => {
traverse(v, depth, seen);
});
} else if (isPlainObject(value)) {
for (const key in value) {
traverse(value[key], depth, seen);
}
for (const key of Object.getOwnPropertySymbols(value)) {
if (Object.prototype.propertyIsEnumerable.call(value, key)) {
traverse(value[key as any], depth, seen);
}
}
}
}
return value;
return value;
}

Loading…
Cancel
Save