parent
cda988217d
commit
62cc9352cb
@ -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); |
||||
}); |
||||
} |
||||
|
@ -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; |
||||
|
@ -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; |
||||
|
@ -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