diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ca8f72a5..892e27bb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -134,7 +134,8 @@ export type PatchOp = "add" | "remove" | "replace" | "set"; */ export interface JsonPatch { op: PatchOp; - path: string; + path?: string; + dataPath?: string; value?: unknown; } diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index deb71f28..bd40d24d 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -2,80 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react"; import type { UITree, UIElement, JsonPatch } from "@json-render/core"; -import { setByPath } from "@json-render/core"; - -/** - * Parse a single JSON patch line - */ -function parsePatchLine(line: string): JsonPatch | null { - try { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("//")) { - return null; - } - return JSON.parse(trimmed) as JsonPatch; - } catch { - return null; - } -} - -/** - * Apply a JSON patch to the current tree - */ -function applyPatch(tree: UITree, patch: JsonPatch): UITree { - const newTree = { ...tree, elements: { ...tree.elements } }; - - switch (patch.op) { - case "set": - case "add": - case "replace": { - // Handle root path - if (patch.path === "/root") { - newTree.root = patch.value as string; - return newTree; - } - - // Handle elements paths - if (patch.path.startsWith("/elements/")) { - const pathParts = patch.path.slice("/elements/".length).split("/"); - const elementKey = pathParts[0]; - - if (!elementKey) return newTree; - - if (pathParts.length === 1) { - // Setting entire element - newTree.elements[elementKey] = patch.value as UIElement; - } else { - // Setting property of element - const element = newTree.elements[elementKey]; - if (element) { - const propPath = "/" + pathParts.slice(1).join("/"); - const newElement = { ...element }; - setByPath( - newElement as unknown as Record, - propPath, - patch.value, - ); - newTree.elements[elementKey] = newElement; - } - } - } - break; - } - case "remove": { - if (patch.path.startsWith("/elements/")) { - const elementKey = patch.path.slice("/elements/".length).split("/")[0]; - if (elementKey) { - const { [elementKey]: _, ...rest } = newTree.elements; - newTree.elements = rest; - } - } - break; - } - } - - return newTree; -} +import { parsePatchLine, processPatch } from "./utils"; /** * Options for useUIStream @@ -87,6 +14,8 @@ export interface UseUIStreamOptions { onComplete?: (tree: UITree) => void; /** Callback on error */ onError?: (error: Error) => void; + /** Callback for data patches */ + onDataPatch?: (patch: JsonPatch) => void; } /** @@ -112,6 +41,7 @@ export function useUIStream({ api, onComplete, onError, + onDataPatch, }: UseUIStreamOptions): UseUIStreamReturn { const [tree, setTree] = useState(null); const [isStreaming, setIsStreaming] = useState(false); @@ -173,8 +103,11 @@ export function useUIStream({ for (const line of lines) { const patch = parsePatchLine(line); if (patch) { - currentTree = applyPatch(currentTree, patch); - setTree({ ...currentTree }); + const nextTree = processPatch(patch, currentTree, onDataPatch); + if (nextTree !== currentTree) { + currentTree = nextTree; + setTree({ ...currentTree }); + } } } } @@ -183,8 +116,11 @@ export function useUIStream({ if (buffer.trim()) { const patch = parsePatchLine(buffer); if (patch) { - currentTree = applyPatch(currentTree, patch); - setTree({ ...currentTree }); + const nextTree = processPatch(patch, currentTree, onDataPatch); + if (nextTree !== currentTree) { + currentTree = nextTree; + setTree({ ...currentTree }); + } } } @@ -200,7 +136,7 @@ export function useUIStream({ setIsStreaming(false); } }, - [api, onComplete, onError], + [api, onComplete, onError, onDataPatch], ); // Cleanup on unmount diff --git a/packages/react/src/utils.test.ts b/packages/react/src/utils.test.ts new file mode 100644 index 00000000..28459c0c --- /dev/null +++ b/packages/react/src/utils.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from "vitest"; +import { processPatch, parsePatchLine } from "./utils"; +import type { UITree, JsonPatch } from "@json-render/core"; + +describe("processPatch", () => { + it("calls onDataPatch when dataPath is present", () => { + const tree: UITree = { root: "", elements: {} }; + const patch: JsonPatch = { op: "add", dataPath: "/user/name", value: "Alice" }; + const onDataPatch = vi.fn(); + + const result = processPatch(patch, tree, onDataPatch); + + expect(onDataPatch).toHaveBeenCalledWith(patch); + expect(result).toBe(tree); // Should not modify tree + }); + + it("applies patch to tree when dataPath is missing", () => { + const tree: UITree = { root: "", elements: {} }; + const patch: JsonPatch = { op: "add", path: "/root", value: "newRoot" }; + const onDataPatch = vi.fn(); + + const result = processPatch(patch, tree, onDataPatch); + + expect(onDataPatch).not.toHaveBeenCalled(); + expect(result.root).toBe("newRoot"); + }); + + it("ignores patch if dataPath is present but onDataPatch is missing", () => { + const tree: UITree = { root: "", elements: {} }; + const patch: JsonPatch = { op: "add", dataPath: "/user/name", value: "Alice" }; + + const result = processPatch(patch, tree); + + // processPatch falls through to applyPatch, which ignores patches without path + // If dataPath is present, path is undefined (in this test case). + // applyPatch returns tree if path is missing. + expect(result).toBe(tree); + // However, what if path IS present? + }); + + it("applies patch to tree if dataPath is present but onDataPatch is missing, AND path is present", () => { + // This behavior is debatable. Current implementation: + /* + if (patch.dataPath && onDataPatch) { + onDataPatch(patch); + return currentTree; + } + return applyPatch(currentTree, patch); + */ + // So if onDataPatch is missing, it calls applyPatch. + // applyPatch checks patch.path. + + const tree: UITree = { root: "", elements: {} }; + const patch: JsonPatch = { op: "add", dataPath: "/user/name", path: "/root", value: "Alice" }; + + const result = processPatch(patch, tree); + + expect(result.root).toBe("Alice"); + }); +}); + +describe("parsePatchLine", () => { + it("parses valid JSON patch", () => { + const line = '{"op": "add", "path": "/root", "value": "test"}'; + expect(parsePatchLine(line)).toEqual({ + op: "add", + path: "/root", + value: "test", + }); + }); + + it("returns null for comment lines", () => { + expect(parsePatchLine("// comment")).toBeNull(); + }); + + it("returns null for empty lines", () => { + expect(parsePatchLine(" ")).toBeNull(); + }); + + it("returns null for invalid JSON", () => { + expect(parsePatchLine("{invalid}")).toBeNull(); + }); +}); diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts new file mode 100644 index 00000000..2866a212 --- /dev/null +++ b/packages/react/src/utils.ts @@ -0,0 +1,95 @@ +import type { UITree, UIElement, JsonPatch } from "@json-render/core"; +import { setByPath } from "@json-render/core"; + +/** + * Parse a single JSON patch line + */ +export function parsePatchLine(line: string): JsonPatch | null { + try { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("//")) { + return null; + } + return JSON.parse(trimmed) as JsonPatch; + } catch { + return null; + } +} + +/** + * Apply a JSON patch to the current tree + */ +export function applyPatch(tree: UITree, patch: JsonPatch): UITree { + if (!patch.path) { + return tree; + } + + const newTree = { ...tree, elements: { ...tree.elements } }; + + switch (patch.op) { + case "set": + case "add": + case "replace": { + // Handle root path + if (patch.path === "/root") { + newTree.root = patch.value as string; + return newTree; + } + + // Handle elements paths + if (patch.path.startsWith("/elements/")) { + const pathParts = patch.path.slice("/elements/".length).split("/"); + const elementKey = pathParts[0]; + + if (!elementKey) return newTree; + + if (pathParts.length === 1) { + // Setting entire element + newTree.elements[elementKey] = patch.value as UIElement; + } else { + // Setting property of element + const element = newTree.elements[elementKey]; + if (element) { + const propPath = "/" + pathParts.slice(1).join("/"); + const newElement = { ...element }; + setByPath( + newElement as unknown as Record, + propPath, + patch.value, + ); + newTree.elements[elementKey] = newElement; + } + } + } + break; + } + case "remove": { + if (patch.path.startsWith("/elements/")) { + const elementKey = patch.path.slice("/elements/".length).split("/")[0]; + if (elementKey) { + const { [elementKey]: _, ...rest } = newTree.elements; + newTree.elements = rest; + } + } + break; + } + } + + return newTree; +} + +/** + * Process a patch, handling both data and component updates. + * Returns the updated tree. + */ +export function processPatch( + patch: JsonPatch, + currentTree: UITree, + onDataPatch?: (patch: JsonPatch) => void, +): UITree { + if (patch.dataPath && onDataPatch) { + onDataPatch(patch); + return currentTree; + } + return applyPatch(currentTree, patch); +}