parent
cda988217d
commit
62cc9352cb
@ -1,7 +1,261 @@ |
|||||||
import type { DeepSignalObject } from "alien-deepsignals"; |
import { batch } from "alien-deepsignals"; |
||||||
import type { Diff } from "../types"; |
|
||||||
|
|
||||||
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */ |
export type Patch = { |
||||||
export function applyDiff(currentState: DeepSignalObject<any>, diff: Diff) { |
/** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */ |
||||||
Object.assign(currentState, diff); |
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); |
||||||
|
}); |
||||||
} |
} |
||||||
|
@ -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 { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; |
||||||
import type { Scope, Shape } from "src/ng-mock/js-land/types"; |
import type { Scope, Shape } from "src/ng-mock/js-land/types"; |
||||||
import useDeepSignal from "./useDeepSignal"; |
|
||||||
|
|
||||||
const useShape = (shape: Shape, scope: Scope) => { |
const useShape = (shape: Shape, scope: Scope) => { |
||||||
const signalOfShape = useMemo(() => { |
const shapeSignalRef = useRef<ReturnType<typeof createSignalObjectForShape>>( |
||||||
return createSignalObjectForShape(shape, scope); |
createSignalObjectForShape(shape, scope) |
||||||
}, [shape, scope]); |
); |
||||||
const shapeObject = useDeepSignal(signalOfShape as unknown as object); |
const [, setTick] = useState(0); |
||||||
|
|
||||||
// We don't need the setter.
|
useEffect(() => { |
||||||
// The object is recursively proxied and value changes are recorded there.
|
const deepSignalObj = shapeSignalRef.current.signalObject; |
||||||
return shapeObject; |
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; |
export default useShape; |
||||||
|
@ -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; |
export default useDeepSignal; |
||||||
|
@ -1,16 +1,21 @@ |
|||||||
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; |
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; |
||||||
import type { Scope, Shape } from "src/ng-mock/js-land/types"; |
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) => { |
export function useShape(shape: Shape, scope?: Scope) { |
||||||
const container = createSignalObjectForShape(shape, scope); |
const handle = createSignalObjectForShape(shape, scope); |
||||||
// container is a deepSignal root; we only care about its content field once set.
|
|
||||||
return useDeepSignal(container) as any; |
// Cleanup
|
||||||
}; |
onBeforeUnmount(() => { |
||||||
|
handle.stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
const ref = useDeepSignal(handle.signalObject); |
||||||
|
|
||||||
|
return ref; |
||||||
|
} |
||||||
|
|
||||||
export default useShape; |
export default useShape; |
||||||
|
@ -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({}); |
||||||
|
}); |
||||||
|
}); |
Loading…
Reference in new issue