running frontend tests

main
Laurin Weger 1 week ago
parent cda988217d
commit 62cc9352cb
No known key found for this signature in database
GPG Key ID: 9B372BB0B792770F
  1. 1
      src/frontends/react/HelloWorld.tsx
  2. 5
      src/frontends/svelte/HelloWorld.svelte
  3. 4
      src/frontends/tests/reactiveCrossFramework.spec.ts
  4. 6
      src/frontends/utils/flattenObject.ts
  5. 80
      src/frontends/vue/HelloWorld.vue
  6. 264
      src/ng-mock/js-land/connector/applyDiff.ts
  7. 131
      src/ng-mock/js-land/connector/createSignalObjectForShape.ts
  8. 119
      src/ng-mock/js-land/frontendAdapters/react/useDeepSignal.ts
  9. 29
      src/ng-mock/js-land/frontendAdapters/react/useShape.ts
  10. 27
      src/ng-mock/js-land/frontendAdapters/svelte/useShape.svelte.ts
  11. 26
      src/ng-mock/js-land/frontendAdapters/vue/deepComputed.ts
  12. 67
      src/ng-mock/js-land/frontendAdapters/vue/useDeepSignal.ts
  13. 23
      src/ng-mock/js-land/frontendAdapters/vue/useShape.ts
  14. 4
      src/ng-mock/js-land/types.ts
  15. 218
      src/ng-mock/tests/applyDiff.test.ts
  16. 3
      src/ng-mock/wasm-land/types.ts

@ -7,7 +7,6 @@ export function HelloWorldReact() {
// @ts-expect-error
window.reactState = state;
// console.log("[react] rendering", state);
if (!state) return <>Loading state</>;

@ -1,14 +1,9 @@
<script lang="ts">
import useShape from "../../ng-mock/js-land/frontendAdapters/svelte/useShape.svelte";
import flattenObject from "../utils/flattenObject";
import type { Writable } from "svelte/store";
const shapeObject = useShape("TestShape");
$effect(() => {
console.log("[svelte]", $shapeObject.objectValue.nestedString);
});
function getNestedValue(obj: any, path: string) {
return path
.split(".")

@ -121,10 +121,6 @@ test.describe("cross framework propagation", () => {
if (source === target) continue;
test(`${source} edits propagate to ${target}`, async ({ page }) => {
test.fail(
source === "vue" || target === "vue",
"Vue propagation currently expected to fail"
);
await page.goto("/");
await page.waitForSelector(".vue astro-island");

@ -14,9 +14,9 @@ const flattenObject = (
options: FlattenOptions = {},
seen = new Set<any>(),
depth = 0
): Array<[string, any]> => {
): Array<[string, any, string, any]> => {
const { maxDepth = 8, skipDollarKeys = true } = options;
const result: Array<[string, any]> = [];
const result: Array<[string, any, string, any]> = [];
if (!obj || typeof obj !== "object") return result;
if (seen.has(obj)) return result; // cycle detected
seen.add(obj);
@ -34,7 +34,7 @@ const flattenObject = (
) {
result.push(...flattenObject(value, fullKey, options, seen, depth + 1));
} else {
result.push([fullKey, value]);
result.push([fullKey, value, key, obj]);
}
}
return result;

@ -1,7 +1,6 @@
<script setup lang="ts">
import { watch } from 'vue';
import { computed } from 'vue';
import useShape from '../../ng-mock/js-land/frontendAdapters/vue/useShape';
import { deepComputed } from '../../ng-mock/js-land/frontendAdapters/vue/deepComputed';
import flattenObject from '../utils/flattenObject';
// Acquire deep signal object (proxy) for a shape; scope second arg left empty string for parity
@ -11,35 +10,21 @@ const shapeObj = useShape('TestShape', '');
// @ts-ignore
window.vueState = shapeObj;
// Helpers to read / write nested properties given a dot path produced by flattenObject
function getNestedValue(obj: any, path: string) {
return path.split('.').reduce((cur, k) => (cur == null ? cur : cur[k]), obj);
}
function setNestedValue(obj: any, path: string, value: any) {
const keys = path.split('.');
let cur = obj;
for (let i = 0; i < keys.length - 1; i++) {
cur = cur[keys[i]];
if (cur == null) return;
}
cur[keys[keys.length - 1]] = value;
}
const flatEntries = computed(() => flattenObject(shapeObj));
// Reactive flattened entries built via deepComputed bridging deepSignal dependencies to Vue.
const flatEntries = deepComputed(() => (shapeObj ? flattenObject(shapeObj.value as any) : []));
// log flatEntries
watch(flatEntries, (newVal) => {
console.log('flatEntries changed:', newVal);
}, { deep: true });
</script>
<template>
<div class="vue">
<p>Rendered in Vue</p>
<template v-if="shapeObj">
<template v-if="shapeObj && shapeObj.type">
<!-- Direct property access -->
<input type="text" v-model="shapeObj.type" />
<input type="text" v-model="shapeObj.objectValue.nestedString" />
<!-- Property access through object recursion -->
<table border="1" cellpadding="5" style="margin-top:1rem; max-width:100%; font-size:0.9rem;">
<thead>
<tr>
@ -50,9 +35,9 @@ watch(flatEntries, (newVal) => {
</thead>
<tbody>
<tr v-for="([key, value]) in flatEntries" :key="key">
<tr v-for="([path, value, key, parent]) in flatEntries" :key="path">
<!-- Key-->
<td style="white-space:nowrap;">{{ key }}</td>
<td style="white-space:nowrap;">{{ path }}</td>
<!-- Value -->
<td>
@ -71,52 +56,63 @@ watch(flatEntries, (newVal) => {
<td>
<!-- String editing -->
<template v-if="typeof value === 'string'">
<template v-if="key.indexOf('.') === -1">
<template v-if="path.indexOf('.') === -1">
<input type="text" v-model="(shapeObj)[key]" />
</template>
<template v-else>
<input type="text" v-bind:value="value" />
<input type="text" v-bind:value="(parent)[key]"
v-on:input="(e) => { (parent)[key] = (e.target as any).value; }" />
</template>
</template>
<!-- Number editing -->
<template v-else-if="typeof value === 'number'">
<template v-if="key.indexOf('.') === -1">
<input type="number" v-model.number="(shapeObj)[key]" />
<template v-if="path.indexOf('.') === -1">
<input type="number" v-model="(shapeObj)[key]" />
</template>
<template v-else>
<input type="number" v-bind:value="value" />
<input type="number" v-bind:value="(parent)[key]"
v-on:input="(e) => { (parent)[key] = +(e.target as any).value; }" />
</template>
</template>
<!-- Boolean editing -->
<template v-else-if="typeof value === 'boolean'">
<template v-if="key.indexOf('.') === -1">
<input type="checkbox" v-model="(shapeObj as any)[key]" />
<template v-if="path.indexOf('.') === -1">
<input type="checkbox" v-model="(shapeObj)[key]" />
</template>
<template v-else>
<input type="checkbox" v-bind:value="value" />
<input type="checkbox" v-bind:value="value"
v-on:input="(e) => { (parent)[key] = (e.target as any).value; }" />
</template>
</template>
<!-- Array editing -->
<template v-else-if="Array.isArray(value)">
<div style="display:flex; gap:.5rem;">
<button
@click="() => { const current = getNestedValue(shapeObj, key) || []; setNestedValue(shapeObj, key, [...current, current.length + 1]); }">Add</button>
<button
@click="() => { const current = getNestedValue(shapeObj, key) || []; if (current.length) setNestedValue(shapeObj, key, current.slice(0, -1)); }">Remove</button>
</div>
<template v-if="path.indexOf('.') === -1">
<div style="display:flex; gap:.5rem;">
<button @click="() => { parent[key] = [...value, value.length + 1] }">Add</button>
<button @click="() => { parent[key] = value.slice(1) }">Remove</button>
</div>
</template>
<template v-else>
<div style="display:flex; gap:.5rem;">
<button @click="() => { parent[key] = [...value, value.length + 1] }">Add</button>
<button @click="() => { parent[key] = value.slice(1) }">Remove</button>
</div>
</template>
</template>
<!-- Set editing -->
<template v-else-if="value instanceof Set">
<div style="display:flex; gap:.5rem;">
<button @click="() => { value.add(`item${value.size + 1}`); }">Add</button>
<button
@click="() => { const currentSet: Set<any> = getNestedValue(shapeObj, key); currentSet.add(`item${currentSet.size + 1}`); }">Add</button>
<button
@click="() => { const currentSet: Set<any> = getNestedValue(shapeObj, key); const last = Array.from(currentSet).pop(); if (last !== undefined) currentSet.delete(last); }">Remove</button>
@click="() => { const last = Array.from(value).pop(); if (last !== undefined) value.delete(last); }">Remove</button>
</div>
</template>
<template v-else>
N/A
</template>
</td>
</tr>
</tbody>

@ -1,7 +1,261 @@
import type { DeepSignalObject } from "alien-deepsignals";
import type { Diff } from "../types";
import { batch } from "alien-deepsignals";
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */
export function applyDiff(currentState: DeepSignalObject<any>, diff: Diff) {
Object.assign(currentState, diff);
export type Patch = {
/** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */
path: string;
type?: string & {};
value?: unknown;
} & (
| SetAddPatch
| SetRemovePatch
| ObjectAddPatch
| RemovePatch
| LiteralAddPatch
);
export interface SetAddPatch {
/** Mutation kind applied at the resolved `path`. */
op: "add";
type: "set";
/**
* New value for set mutations:
* - A single primitive
* - An array of primitives
* - An object (id -> object) for object "set" additions
*/
value:
| number
| string
| boolean
| (number | string | boolean)[]
| { [id: string]: object };
}
export interface SetRemovePatch {
/** Mutation kind applied at the resolved `path`. */
op: "remove";
type: "set";
/**
* The value(s) to be removed from the set. Either:
* - A single primitive / id
* - An array of primitives / ids
*/
value: number | string | boolean | (number | string | boolean)[];
}
export interface ObjectAddPatch {
/** Mutation kind applied at the resolved `path`. */
op: "add";
type: "object";
}
export interface RemovePatch {
/** Mutation kind applied at the resolved `path`. */
op: "remove";
}
export interface LiteralAddPatch {
/** Mutation kind applied at the resolved `path`. */
op: "add";
/** The literal value to be added at the resolved `path` */
value: string | number | boolean;
}
function isPrimitive(v: unknown): v is string | number | boolean {
return (
typeof v === "string" || typeof v === "number" || typeof v === "boolean"
);
}
/**
* Apply a diff to an object.
*
* * The syntax is inspired by RFC 6902 but it is not compatible.
*
* It supports sets:
* - Primitive values are added as sets,
* - Sets of objects are represented as objects with their id being the key.
* @example operations
* ```jsonc
* // Add one or more objects to a set.
* { "op": "add", "type": "set", "path": "/address", "value": { "ID1": {...}, "ID2": {...} } },
* // Remove one or more objects from a set.
* { "op": "remove", "type": "set", "path": "/address", "value": ["ID1","ID2"] }
* // Add primitive types to a sets (URIs are treated just like strings)
* { "op": "add", "type": "set", "path": "/address", "value": [1,2,3] }
* // Remove primitive types from a set.
* { "op": "remove", "type": "set", "path": "/address", "value": [1,2] }
*
* // Creating an object.
* { "op": "add", "path": "/address", "type": "object" }
* // Adding primitives.
* { "op": "add", "path": "/address/street", value: "1st street" }
* { "op": "add", "path": "/address/country", value: "Greece" }
* // Remove a primitive.
* { "op": "remove", "path": "/address/street" }
* // Remove an object
* { "op": "remove", "path": "/address" }
* ```
*
* @param currentState The object before the patch
* @param diff An array of patches to apply to the object.
* @param ensurePathExists If true, create nested objects along the path if the path does not exist.
*/
export function applyDiff(
currentState: Record<string, any>,
diff: Patch[],
ensurePathExists: boolean = false
) {
for (const patch of diff) {
if (!patch.path.startsWith("/")) continue;
const pathParts = patch.path.slice(1).split("/").filter(Boolean);
if (pathParts.length === 0) continue; // root not supported
const lastKey = pathParts[pathParts.length - 1];
let parentVal: any = currentState;
let parentMissing = false;
// Traverse only intermediate segments
for (let i = 0; i < pathParts.length - 1; i++) {
const seg = pathParts[i];
if (
parentVal != null &&
typeof parentVal === "object" &&
Object.prototype.hasOwnProperty.call(parentVal, seg)
) {
parentVal = parentVal[seg];
continue;
}
if (ensurePathExists) {
if (parentVal != null && typeof parentVal === "object") {
parentVal[seg] = {};
parentVal = parentVal[seg];
} else {
parentMissing = true;
break;
}
} else {
parentMissing = true;
break;
}
}
if (parentMissing) {
console.warn(
`[applyDiff] Skipping patch due to missing parent path segment(s): ${patch.path}`
);
continue;
}
// parentVal now should be an object into which we apply lastKey
if (parentVal == null || typeof parentVal !== "object") {
console.warn(
`[applyDiff] Skipping patch because parent is not an object: ${patch.path}`
);
continue;
}
const key = lastKey;
// If parent does not exist and we cannot create it, skip this patch
if (parentVal == null || typeof parentVal !== "object") continue;
// Handle set additions
if (patch.op === "add" && patch.type === "set") {
const existing = parentVal[key];
// Normalize value
const raw = (patch as SetAddPatch).value;
if (raw == null) continue;
// Object-set (id -> object)
if (typeof raw === "object" && !Array.isArray(raw) && !isPrimitive(raw)) {
if (existing && (existing instanceof Set || Array.isArray(existing))) {
// Replace incompatible representation
parentVal[key] = {};
}
if (!parentVal[key] || typeof parentVal[key] !== "object") {
parentVal[key] = {};
}
Object.assign(parentVal[key], raw);
continue;
}
// Set primitive(s)
const toAdd: (string | number | boolean)[] = Array.isArray(raw)
? raw.filter(isPrimitive)
: isPrimitive(raw)
? [raw]
: [];
if (!toAdd.length) continue;
if (existing instanceof Set) {
for (const v of toAdd) existing.add(v);
} else if (
existing &&
typeof existing === "object" &&
!Array.isArray(existing) &&
!(existing instanceof Set)
) {
// Existing is object-set (objects); adding primitives -> replace with Set
parentVal[key] = new Set(toAdd);
} else {
// No existing or incompatible -> create a Set
parentVal[key] = new Set(toAdd);
}
continue;
}
// Handle set removals
if (patch.op === "remove" && patch.type === "set") {
const existing = parentVal[key];
const raw = (patch as SetRemovePatch).value;
if (raw == null) continue;
const toRemove: (string | number | boolean)[] = Array.isArray(raw)
? raw
: [raw];
if (existing instanceof Set) {
for (const v of toRemove) existing.delete(v);
} else if (existing && typeof existing === "object") {
for (const v of toRemove) delete existing[v as any];
}
continue;
}
// Add object (ensure object exists)
if (patch.op === "add" && patch.type === "object") {
const cur = parentVal[key];
if (
cur === undefined ||
cur === null ||
typeof cur !== "object" ||
cur instanceof Set
) {
parentVal[key] = {};
}
continue;
}
// Literal add
if (patch.op === "add") {
parentVal[key] = (patch as LiteralAddPatch).value;
continue;
}
// Generic remove (property or value)
if (patch.op === "remove") {
if (Object.prototype.hasOwnProperty.call(parentVal, key)) {
delete parentVal[key];
}
continue;
}
}
}
/**
* See documentation for applyDiff
*/
export function applyDiffToDeepSignal(currentState: object, diff: Patch[]) {
batch(() => {
applyDiff(currentState as Record<string, any>, diff);
});
}

@ -3,74 +3,123 @@ import type { Connection, Diff, Scope, Shape } from "../types";
import requestShape from "src/ng-mock/wasm-land/requestShape";
import { applyDiff } from "./applyDiff";
import { deepSignal, watch, batch } from "alien-deepsignals";
import { signal as createSignal } from "alien-signals";
type ShapeObject = {};
import type { DeepPatch } from "alien-deepsignals";
type ShapeObject = Record<string, any>;
type ShapeObjectSignal = ReturnType<typeof deepSignal<ShapeObject>>;
const openConnections: Partial<Record<Shape, ShapeObjectSignal>> = {};
interface PoolEntry {
key: string;
shape: Shape;
scopeKey: string;
signalObject: ShapeObjectSignal;
refCount: number;
stopListening: (() => void) | null;
registerCleanup?: (fn: () => void) => void;
connectionId?: string;
ready: Promise<string>; // resolves to connectionId
resolveReady: (id: string) => void;
suspendDeepWatcher: boolean;
}
const pool = new Map<string, PoolEntry>();
function canonicalScope(scope: Scope | undefined): string {
if (scope == null) return "";
return Array.isArray(scope) ? scope.slice().sort().join(",") : String(scope);
}
export function deepPatchesToDiff(patches: DeepPatch[]): Diff {
return patches.map((patch) => {
const path = "/" + patch.path.join("/");
return { ...patch, path };
}) as Diff;
}
/**
* Create a signal for a shape object.
* The function returns a shape object that is proxied by deepSignal
* and keeps itself updated with the backend.
**/
export function createSignalObjectForShape(
shape: Shape,
scope?: Scope,
poolSignal = true
) {
if (poolSignal && openConnections[shape]) return openConnections[shape];
const scopeKey = canonicalScope(scope);
const key = `${shape}::${scopeKey}`;
// Single deepSignal root container that will hold (and become) the live shape.
const signalObject = deepSignal({});
if (poolSignal) {
const existing = pool.get(key);
if (existing) {
existing.refCount++;
return buildReturn(existing);
}
}
let stopWatcher: any = null;
let suspendDeepWatcher = false;
const signalObject = deepSignal<ShapeObject>({});
let resolveReady!: (id: string) => void;
const ready = new Promise<string>((res) => (resolveReady = res));
const entry: PoolEntry = {
key,
shape,
scopeKey,
signalObject,
refCount: 1,
stopListening: null,
registerCleanup: undefined,
connectionId: undefined,
ready,
resolveReady,
suspendDeepWatcher: false,
};
if (poolSignal) pool.set(key, entry);
const onUpdateFromDb = (diff: Diff, connectionId: Connection["id"]) => {
// eslint-disable-next-line no-console
console.debug("[shape][diff] applying", connectionId, diff);
suspendDeepWatcher = true;
batch(() => {
applyDiff(signalObject, diff);
});
entry.suspendDeepWatcher = true;
batch(() => applyDiff(signalObject, diff));
queueMicrotask(() => {
suspendDeepWatcher = false;
entry.suspendDeepWatcher = false;
});
};
// Do the actual db request.
requestShape(shape, scope, onUpdateFromDb).then(
({ connectionId, shapeObject }) => {
// Populate the root container with the initial shape (plain assignment so deepSignal wraps nested structures lazily)
suspendDeepWatcher = true;
entry.connectionId = connectionId;
entry.suspendDeepWatcher = true;
batch(() => {
Object.keys(shapeObject).forEach((k) => {
// @ts-ignore
for (const k of Object.keys(shapeObject)) {
(signalObject as any)[k] = (shapeObject as any)[k];
});
}
});
// Deep watch on the single root to propagate user edits back.
stopWatcher = watch(
signalObject,
(newVal) => {
if (!suspendDeepWatcher) updateShape(connectionId, newVal as any);
},
{ deep: true }
);
const watcher = watch(signalObject, ({ patches }) => {
if (entry.suspendDeepWatcher || !patches.length) return;
const diff = deepPatchesToDiff(patches);
updateShape(connectionId as any, diff as any);
});
entry.stopListening = watcher.stopListening;
entry.registerCleanup = watcher.registerCleanup;
queueMicrotask(() => {
suspendDeepWatcher = false;
entry.suspendDeepWatcher = false;
});
entry.resolveReady(connectionId);
}
);
if (poolSignal) openConnections[shape] = signalObject;
return buildReturn(entry);
// TODO: Dispose deepSignal and stop watcher.
return signalObject;
function buildReturn(entry: PoolEntry) {
const release = () => {
if (entry.refCount > 0) entry.refCount--;
if (entry.refCount === 0) {
entry.stopListening?.();
if (poolSignal) pool.delete(entry.key);
}
};
return {
signalObject: entry.signalObject,
stop: release,
ready: entry.ready, // Promise<string>
get connectionId() {
return entry.connectionId;
},
registerCleanup: entry.registerCleanup,
};
}
}

@ -1,119 +0,0 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { subscribeDeepMutations, getDeepSignalRootId } from "alien-deepsignals";
/**
* Basic hook: re-renders whenever any deep patch for the provided deepSignal root occurs.
* Returns ONLY the deep proxy (React-like primitive hook contract).
*/
export function useDeepSignal<T extends object>(deepProxy: T): T {
const rootIdRef = useRef(getDeepSignalRootId(deepProxy as any));
const versionRef = useRef(0);
const listenersRef = useRef(new Set<() => void>());
useMemo(() => {
const unsubscribe = subscribeDeepMutations((batch) => {
if (!rootIdRef.current) return;
if (batch.some((p) => p.root === rootIdRef.current)) {
versionRef.current++;
listenersRef.current.forEach((l) => l());
}
});
return unsubscribe;
}, []);
const subscribe = useCallback((cb: () => void) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
}, []);
const getSnapshot = useCallback(() => versionRef.current, []);
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return deepProxy;
}
/** Internal representation of a tracked path as an array of keys. */
type Path = (string | number)[];
/** Convert path to a cache key string. */
const pathKey = (p: Path) => p.join("\u001f");
/**
* Selective hook: only re-renders when a patch path intersects (prefix in either direction)
* with a property path actually read during the last render. Returns a proxy view for tracking.
*/
export function useDeepSignalSelective<T extends object>(deepProxy: T): T {
const rootIdRef = useRef(getDeepSignalRootId(deepProxy as any));
const versionRef = useRef(0);
const listenersRef = useRef(new Set<() => void>());
const accessedRef = useRef<Set<string>>(new Set());
// Cache proxies per path for identity stability within a render.
const proxyCacheRef = useRef(new Map<string, any>());
// Build a tracking proxy for a target at a given path.
const buildProxy = useCallback((target: any, path: Path): any => {
const key = pathKey(path);
if (proxyCacheRef.current.has(key)) return proxyCacheRef.current.get(key);
const prox = new Proxy(target, {
get(_t, prop, recv) {
if (typeof prop === "symbol") return Reflect.get(_t, prop, recv);
const nextPath = path.concat(prop as any);
// Record full path and all prefixes for descendant change detection.
for (let i = 1; i <= nextPath.length; i++) {
accessedRef.current.add(pathKey(nextPath.slice(0, i)));
}
const val = Reflect.get(_t, prop, recv);
if (val && typeof val === "object") {
return buildProxy(val, nextPath);
}
return val;
},
});
proxyCacheRef.current.set(key, prox);
return prox;
}, []);
// Patch subscription (once)
useMemo(() => {
const unsubscribe = subscribeDeepMutations((batch) => {
if (!rootIdRef.current) return;
const relevant = batch.filter((p) => p.root === rootIdRef.current);
if (!relevant.length) return;
// Test intersection.
const used = accessedRef.current;
let hit = false;
outer: for (const patch of relevant) {
const pPath = patch.path as Path;
const pKey = pathKey(pPath);
if (used.has(pKey)) {
hit = true;
break;
}
// Check prefix/descendant
for (const usedKey of used) {
if (pKey.startsWith(usedKey) || usedKey.startsWith(pKey)) {
hit = true;
break outer;
}
}
}
if (hit) {
versionRef.current++;
listenersRef.current.forEach((l) => l());
}
});
return unsubscribe;
}, []);
const subscribe = useCallback((cb: () => void) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
}, []);
const getSnapshot = useCallback(() => versionRef.current, []);
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
// Each render reset tracking containers so next render collects fresh usage.
accessedRef.current = new Set();
proxyCacheRef.current = new Map();
return buildProxy(deepProxy, []);
}
export default useDeepSignal;

@ -1,17 +1,28 @@
import { useMemo, useRef } from "react";
import { watch } from "alien-deepsignals";
import { useEffect, useRef, useState } from "react";
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types";
import useDeepSignal from "./useDeepSignal";
const useShape = (shape: Shape, scope: Scope) => {
const signalOfShape = useMemo(() => {
return createSignalObjectForShape(shape, scope);
}, [shape, scope]);
const shapeObject = useDeepSignal(signalOfShape as unknown as object);
const shapeSignalRef = useRef<ReturnType<typeof createSignalObjectForShape>>(
createSignalObjectForShape(shape, scope)
);
const [, setTick] = useState(0);
// We don't need the setter.
// The object is recursively proxied and value changes are recorded there.
return shapeObject;
useEffect(() => {
const deepSignalObj = shapeSignalRef.current.signalObject;
const { stopListening } = watch(deepSignalObj, () => {
// trigger a React re-render when the deep signal updates
setTick((t) => t + 1);
});
return () => {
shapeSignalRef.current.stop();
stopListening();
};
}, []);
return shapeSignalRef.current.signalObject;
};
export default useShape;

@ -34,14 +34,16 @@ export function useDeepSignal<T = any>(deepProxy: T): UseDeepSignalResult<T> {
const version = writable(0);
const patchesStore = writable<DeepPatch[]>([]);
const unsubscribe = subscribeDeepMutations((batch) => {
if (!rootId) return;
const filtered = batch.filter((p) => p.root === rootId);
if (filtered.length) {
patchesStore.set(filtered);
version.update((n) => n + 1);
const unsubscribe = subscribeDeepMutations(
deepProxy as any,
(batch: DeepPatch[]) => {
if (!rootId) return;
if (batch.length) {
patchesStore.set(batch);
version.update((n) => n + 1);
}
}
});
);
const deep = derived(version, () => deepProxy);
const select = <U>(selector: (tree: T) => U): Readable<U> =>
@ -89,9 +91,16 @@ export function useShapeRune<T = any>(
shape: Shape,
scope?: Scope
): UseShapeRuneResult<T> {
const rootSignal = createSignalObjectForShape(shape, scope);
const { signalObject: rootSignal, stop } = createSignalObjectForShape(
shape,
scope
);
// Cleanup
onDestroy(stop);
// rootSignal is already a deepSignal proxy root (object returned by createSignalObjectForShape)
const ds = useDeepSignal<T>(rootSignal as unknown as T);
const ds = useDeepSignal<T>(rootSignal as T);
return { root: rootSignal, ...ds } as UseShapeRuneResult<T>;
}

@ -1,26 +0,0 @@
import { shallowRef, onMounted, onUnmounted } from "vue";
import { subscribeDeepMutations } from "alien-deepsignals";
/**
* Coarse-grained bridge: recompute the getter on every deep mutation batch.
* Simpler than dual dependency systems (Vue + alien) and sufficient for editor panel.
* Optimize later by filtering patches / caching accessed top-level keys.
*/
export function deepComputed<T>(getter: () => T) {
const r = shallowRef<T>(getter());
let unsubscribe: (() => void) | null = null;
onMounted(() => {
unsubscribe = subscribeDeepMutations(() => {
try {
r.value = getter();
} catch (e) {
// eslint-disable-next-line no-console
console.warn("deepComputed recompute failed", e);
}
});
});
onUnmounted(() => unsubscribe?.());
return r;
}
export default deepComputed;

@ -1,9 +1,68 @@
import { ref } from "vue";
import { ref, onBeforeUnmount } from "vue";
import { watch } from "alien-deepsignals";
export function useDeepSignal<T extends object>(deepProxy: T) {
// TODO: Subscribe to and synchronize changes between deepProxy and ref.
/**
* Bridge a deepSignal root into Vue with per top-level property granularity.
* Each accessed property is a Ref internally; only patches touching that
* property will trigger its dependents. Returned value is a plain object (not a Ref)
* shaped like the original deepSignal root. Nested changes trigger the top-level
* property whose subtree changed.
*/
export function useDeepSignal<T extends Record<string, any>>(deepProxy: T): T {
// Version refs per top-level property; increment to trigger dependents.
const versionRefs = new Map<
string | symbol,
ReturnType<typeof ref<number>>
>();
return ref(deepProxy);
function ensureVersion(key: string | symbol) {
if (!versionRefs.has(key)) versionRefs.set(key, ref(0));
return versionRefs.get(key)!;
}
// Initialize known keys
Object.keys(deepProxy).forEach((k) => ensureVersion(k));
const stopHandle = watch(deepProxy, ({ patches }) => {
for (const p of patches) {
if (!p.path.length) continue;
const top = p.path[0] as string | symbol;
const vr = ensureVersion(top);
vr.value = (vr.value || 0) + 1;
}
});
const proxy = new Proxy({} as T, {
get(_t, key: string | symbol) {
if (key === "__raw") return deepProxy;
// Establish dependency via version ref; ignore its numeric value.
ensureVersion(key).value; // accessed for tracking only
return (deepProxy as any)[key];
},
set(_t, key: string | symbol, value: any) {
(deepProxy as any)[key] = value;
// Bump version immediately for sync updates (before patch batch flush)
const vr = ensureVersion(key);
vr.value = (vr.value || 0) + 1;
return true;
},
has(_t, key) {
return key in deepProxy;
},
ownKeys() {
return Reflect.ownKeys(deepProxy);
},
getOwnPropertyDescriptor() {
return { configurable: true, enumerable: true };
},
});
onBeforeUnmount(() => {
stopHandle.stopListening();
versionRefs.clear();
});
return proxy;
}
export default useDeepSignal;

@ -1,16 +1,21 @@
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types";
import { computed } from "vue";
import { useDeepSignal } from "./useDeepSignal";
import useDeepSignal from "./useDeepSignal";
import { onBeforeUnmount } from "vue";
/**
* Vue adapter returning a wrapped deepSignal root whose property reads become Vue-reactive
* via a version dependency injected in the proxy layer (see useDeepSignal).
*/
const useShape = (shape: Shape, scope: Scope) => {
const container = createSignalObjectForShape(shape, scope);
// container is a deepSignal root; we only care about its content field once set.
return useDeepSignal(container) as any;
};
export function useShape(shape: Shape, scope?: Scope) {
const handle = createSignalObjectForShape(shape, scope);
// Cleanup
onBeforeUnmount(() => {
handle.stop();
});
const ref = useDeepSignal(handle.signalObject);
return ref;
}
export default useShape;

@ -1,3 +1,5 @@
import type { Patch } from "./connector/applyDiff";
/** The shape of an object requested. */
export type Shape = "Shape1" | "Shape2" | "TestShape";
@ -5,7 +7,7 @@ export type Shape = "Shape1" | "Shape2" | "TestShape";
export type Scope = string | string[];
/** The diff format used to communicate updates between wasm-land and js-land. */
export type Diff = object;
export type Diff = Patch[];
/** A connection established between wasm-land and js-land for subscription of a shape. */
export type Connection = {

@ -0,0 +1,218 @@
import { describe, test, expect } from "vitest";
import {
applyDiff,
applyDiffToDeepSignal,
} from "../js-land/connector/applyDiff";
import type { Patch } from "../js-land/connector/applyDiff";
/**
* Build a patch path string from segments (auto-prefix /)
*/
function p(...segs: (string | number)[]) {
return "/" + segs.map(String).join("/");
}
describe("applyDiff - set operations (primitives)", () => {
test("add single primitive into new set", () => {
const state: any = {};
const diff: Patch[] = [
{ op: "add", type: "set", path: p("tags"), value: "a" },
];
applyDiff(state, diff);
expect(state.tags).toBeInstanceOf(Set);
expect([...state.tags]).toEqual(["a"]);
});
test("add multiple primitives into new set", () => {
const state: any = {};
const diff: Patch[] = [
{ op: "add", type: "set", path: p("nums"), value: [1, 2, 3] },
];
applyDiff(state, diff);
expect([...state.nums]).toEqual([1, 2, 3]);
});
test("add primitives merging into existing set", () => {
const state: any = { nums: new Set([1]) };
const diff: Patch[] = [
{ op: "add", type: "set", path: p("nums"), value: [2, 3] },
];
applyDiff(state, diff);
expect([...state.nums].sort()).toEqual([1, 2, 3]);
});
test("remove single primitive from set", () => {
const state: any = { tags: new Set(["a", "b"]) };
const diff: Patch[] = [
{ op: "remove", type: "set", path: p("tags"), value: "a" },
];
applyDiff(state, diff);
expect([...state.tags]).toEqual(["b"]);
});
test("remove multiple primitives from set", () => {
const state: any = { nums: new Set([1, 2, 3, 4]) };
const diff: Patch[] = [
{ op: "remove", type: "set", path: p("nums"), value: [2, 4] },
];
applyDiff(state, diff);
expect([...state.nums].sort()).toEqual([1, 3]);
});
});
describe("applyDiff - set operations (object sets)", () => {
test("add object entries to new object-set", () => {
const state: any = {};
const diff: Patch[] = [
{
op: "add",
type: "set",
path: p("users"),
value: { u1: { id: "u1", n: 1 }, u2: { id: "u2", n: 2 } },
},
];
applyDiff(state, diff);
expect(state.users.u1).toEqual({ id: "u1", n: 1 });
expect(state.users.u2).toEqual({ id: "u2", n: 2 });
});
test("merge object entries into existing object-set", () => {
const state: any = { users: { u1: { id: "u1", n: 1 } } };
const diff: Patch[] = [
{
op: "add",
type: "set",
path: p("users"),
value: { u2: { id: "u2", n: 2 } },
},
];
applyDiff(state, diff);
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]);
});
test("remove object entries from object-set", () => {
const state: any = { users: { u1: {}, u2: {}, u3: {} } };
const diff: Patch[] = [
{ op: "remove", type: "set", path: p("users"), value: ["u1", "u3"] },
];
applyDiff(state, diff);
expect(Object.keys(state.users)).toEqual(["u2"]);
});
test("adding primitives to existing object-set replaces with Set", () => {
const state: any = { mixed: { a: {}, b: {} } };
const diff: Patch[] = [
{ op: "add", type: "set", path: p("mixed"), value: [1, 2] },
];
applyDiff(state, diff);
expect(state.mixed).toBeInstanceOf(Set);
expect([...state.mixed]).toEqual([1, 2]);
});
});
describe("applyDiff - object & literal operations", () => {
test("add object (create empty object)", () => {
const state: any = {};
const diff: Patch[] = [{ op: "add", path: p("address"), type: "object" }];
applyDiff(state, diff);
expect(state.address).toEqual({});
});
test("add nested object path with ensurePathExists", () => {
const state: any = {};
const diff: Patch[] = [
{ op: "add", path: p("a", "b", "c"), type: "object" },
];
applyDiff(state, diff, true);
expect(state.a.b.c).toEqual({});
});
test("add primitive value", () => {
const state: any = { address: {} };
const diff: Patch[] = [
{ op: "add", path: p("address", "street"), value: "1st" },
];
applyDiff(state, diff);
expect(state.address.street).toBe("1st");
});
test("overwrite primitive value", () => {
const state: any = { address: { street: "old" } };
const diff: Patch[] = [
{ op: "add", path: p("address", "street"), value: "new" },
];
applyDiff(state, diff);
expect(state.address.street).toBe("new");
});
test("remove primitive", () => {
const state: any = { address: { street: "1st", country: "Greece" } };
const diff: Patch[] = [{ op: "remove", path: p("address", "street") }];
applyDiff(state, diff);
expect(state.address.street).toBeUndefined();
expect(state.address.country).toBe("Greece");
});
test("remove object branch", () => {
const state: any = { address: { street: "1st" }, other: 1 };
const diff: Patch[] = [{ op: "remove", path: p("address") }];
applyDiff(state, diff);
expect(state.address).toBeUndefined();
expect(state.other).toBe(1);
});
});
describe("applyDiff - multiple mixed patches in a single diff", () => {
test("sequence of mixed set/object/literal add & remove", () => {
const state: any = {
users: { u1: { id: "u1" } },
tags: new Set(["old"]),
};
const diff: Patch[] = [
{ op: "add", type: "set", path: p("users"), value: { u2: { id: "u2" } } },
{ op: "add", path: p("profile"), type: "object" },
{ op: "add", path: p("profile", "name"), value: "Alice" },
{ op: "add", type: "set", path: p("tags"), value: ["new"] },
{ op: "remove", type: "set", path: p("tags"), value: "old" },
];
applyDiff(state, diff);
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]);
expect(state.profile.name).toBe("Alice");
expect([...state.tags]).toEqual(["new"]);
});
test("complex nested path creation and mutations with ensurePathExists", () => {
const state: any = {};
const diff: Patch[] = [
{ op: "add", path: p("a", "b"), type: "object" },
{ op: "add", path: p("a", "b", "c"), value: 1 },
{ op: "add", type: "set", path: p("a", "nums"), value: [1, 2, 3] },
{ op: "remove", type: "set", path: p("a", "nums"), value: 2 },
{ op: "add", path: p("a", "b", "d"), value: 2 },
{ op: "remove", path: p("a", "b", "c") },
];
applyDiff(state, diff, true);
expect(state.a.b.c).toBeUndefined();
expect(state.a.b.d).toBe(2);
expect(state.a.nums).toBeInstanceOf(Set);
expect([...state.a.nums].sort()).toEqual([1, 3]);
});
});
describe("applyDiff - ignored / invalid scenarios", () => {
test("skip patch with non-leading slash path", () => {
const state: any = {};
const diff: Patch[] = [{ op: "add", path: "address/street", value: "x" }];
applyDiff(state, diff);
expect(state).toEqual({});
});
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => {
const state: any = {};
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }];
applyDiff(state, diff, false);
expect(state).toEqual({});
});
});
describe("applyDiff - ignored / invalid scenarios", () => {
test("skip patch with non-leading slash path", () => {
const state: any = {};
const diff: Patch[] = [{ op: "add", path: "address/street", value: "x" }];
applyDiff(state, diff);
expect(state).toEqual({});
});
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => {
const state: any = {};
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }];
applyDiff(state, diff, false);
expect(state).toEqual({});
});
});

@ -1,10 +1,11 @@
import type { Patch } from "../js-land/connector/applyDiff";
import type { Shape } from "../js-land/types";
/** The Scope of a shape request */
export type Scope = string | string[];
/** The diff format used to communicate updates between wasm-land and js-land. */
export type Diff = object;
export type Diff = Patch[];
export type ObjectState = object;

Loading…
Cancel
Save