Packages
React inside Phoenix LiveView — async code splitting, client-side props diffing, and zero-config component discovery.
Retired package: Release invalid - Not production-ready
Current section
Files
Jump to
Current section
Files
assets/jsonPatch.ts
import type { PatchOp } from "./types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Split a JSON Pointer path into its segments (skipping the leading empty string). */
function parsePath(path: string): string[] {
if (path === "" || path === "/") return [];
return path
.split("/")
.slice(1)
.map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
}
/** Walk into `obj` following all segments except the last, returning the
* parent object and the final key. Creates intermediate objects as needed. */
function getParent(
obj: Record<string, unknown>,
segments: string[],
create: boolean,
): { parent: Record<string, unknown>; key: string } | null {
if (segments.length === 0) return null;
let current: Record<string, unknown> = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
if (
current[seg] === undefined ||
current[seg] === null ||
typeof current[seg] !== "object" ||
Array.isArray(current[seg])
) {
if (!create) return null;
current[seg] = {};
}
current = current[seg] as Record<string, unknown>;
}
return { parent: current, key: segments[segments.length - 1] };
}
/** Get the value at a JSON Pointer path inside obj. */
function getAt(
obj: Record<string, unknown>,
segments: string[],
): unknown {
let current: unknown = obj;
for (const seg of segments) {
if (current === null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[seg];
}
return current;
}
/** Deep equality check. */
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a !== typeof b) return false;
if (typeof a !== "object") return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, i) => deepEqual(v, (b as unknown[])[i]));
}
const aObj = a as Record<string, unknown>;
const bObj = b as Record<string, unknown>;
const aKeys = Object.keys(aObj);
const bKeys = Object.keys(bObj);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bObj, k) && deepEqual(aObj[k], bObj[k]));
}
// ---------------------------------------------------------------------------
// applyPatch
// ---------------------------------------------------------------------------
/**
* Mutates `obj` in place by applying an array of patch operations.
*
* Supports standard RFC 6902 ops: add, remove, replace, test.
* Supports custom Phoenix Stream ops: upsert, delete_by_id, limit.
*/
export function applyPatch(
obj: Record<string, unknown>,
patches: PatchOp[],
): void {
for (const patch of patches) {
const segments = parsePath(patch.path);
switch (patch.op) {
case "add":
case "replace": {
const result = getParent(obj, segments, true);
if (result) {
result.parent[result.key] = patch.value;
}
break;
}
case "remove": {
const result = getParent(obj, segments, false);
if (result) {
delete result.parent[result.key];
}
break;
}
case "test": {
// No-op — used for cache-busting only.
break;
}
case "upsert": {
const arr = getAt(obj, segments);
if (!Array.isArray(arr)) break;
const typedArr = arr as Array<Record<string, unknown>>;
const match = patch.match;
const value = patch.value as Record<string, unknown>;
const existingIdx = typedArr.findIndex(
(item) => item.__dom_id === match,
);
if (existingIdx !== -1) {
// Update in place.
typedArr[existingIdx] = value;
} else {
// Insert at given position (from patch.at) or append.
const at = typeof patch.at === "number" ? patch.at : typedArr.length;
const insertAt = Math.min(at, typedArr.length);
typedArr.splice(insertAt, 0, value);
}
break;
}
case "delete_by_id": {
const arr = getAt(obj, segments);
if (!Array.isArray(arr)) break;
const typedArr = arr as Array<Record<string, unknown>>;
const match = patch.match;
const idx = typedArr.findIndex((item) => item.__dom_id === match);
if (idx !== -1) {
typedArr.splice(idx, 1);
}
break;
}
case "limit": {
const arr = getAt(obj, segments);
if (!Array.isArray(arr)) break;
const n = patch.value;
const result2 = getParent(obj, segments, false);
if (!result2) break;
if (n === 0) {
result2.parent[result2.key] = [];
} else if (n > 0) {
result2.parent[result2.key] = (arr as unknown[]).slice(0, n);
} else {
result2.parent[result2.key] = (arr as unknown[]).slice(n);
}
break;
}
}
}
}
// ---------------------------------------------------------------------------
// computeDiff
// ---------------------------------------------------------------------------
/**
* Returns an array of PatchOp describing changes from `prev` to `next`.
*
* - Added keys → `add` op
* - Changed vals → `replace` op (recurses into nested objects)
* - Removed keys → `remove` op
* - Arrays → compared with deepEqual, replaced wholesale if different
*/
export function computeDiff(
prev: Record<string, unknown>,
next: Record<string, unknown>,
basePath = "",
): PatchOp[] {
const ops: PatchOp[] = [];
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
// Removed keys
for (const key of prevKeys) {
if (!Object.prototype.hasOwnProperty.call(next, key)) {
ops.push({ op: "remove", path: `${basePath}/${key}` });
}
}
// Added or changed keys
for (const key of nextKeys) {
const path = `${basePath}/${key}`;
if (!Object.prototype.hasOwnProperty.call(prev, key)) {
// Added
ops.push({ op: "add", path, value: next[key] });
} else {
const prevVal = prev[key];
const nextVal = next[key];
if (deepEqual(prevVal, nextVal)) continue;
// Both plain objects (not arrays, not null) → recurse
if (
prevVal !== null &&
nextVal !== null &&
typeof prevVal === "object" &&
typeof nextVal === "object" &&
!Array.isArray(prevVal) &&
!Array.isArray(nextVal)
) {
const nested = computeDiff(
prevVal as Record<string, unknown>,
nextVal as Record<string, unknown>,
path,
);
ops.push(...nested);
} else {
ops.push({ op: "replace", path, value: nextVal });
}
}
}
return ops;
}