Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ export type PatchOp = "add" | "remove" | "replace" | "set";
*/
export interface JsonPatch {
op: PatchOp;
path: string;
path?: string;
dataPath?: string;
value?: unknown;
}

Expand Down
94 changes: 15 additions & 79 deletions packages/react/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
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
Expand All @@ -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;
}

/**
Expand All @@ -112,6 +41,7 @@ export function useUIStream({
api,
onComplete,
onError,
onDataPatch,
}: UseUIStreamOptions): UseUIStreamReturn {
const [tree, setTree] = useState<UITree | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
Expand Down Expand Up @@ -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 });
}
}
}
}
Expand All @@ -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 });
}
}
}

Expand All @@ -200,7 +136,7 @@ export function useUIStream({
setIsStreaming(false);
}
},
[api, onComplete, onError],
[api, onComplete, onError, onDataPatch],
);

// Cleanup on unmount
Expand Down
83 changes: 83 additions & 0 deletions packages/react/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
95 changes: 95 additions & 0 deletions packages/react/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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);
}