From a7b8629248e96352e5fd51a91892d9330515be99 Mon Sep 17 00:00:00 2001 From: Daniel Lantz Date: Tue, 20 Jan 2026 22:58:07 -0500 Subject: [PATCH 1/2] feat(react): add useUITree hook for external patch sources Adds useUITree hook for managing UI tree state when patches come from external sources (e.g., AI SDK, WebSocket) rather than a dedicated endpoint. Benefits: - initialTree option enables iteration on existing trees within a conversation - Transport-agnostic: patches can come from any source, not just fetch --- packages/react/src/hooks.test.ts | 37 +++++++++++++++++++++++++++- packages/react/src/hooks.ts | 41 ++++++++++++++++++++++++++++++++ packages/react/src/index.ts | 3 +++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/react/src/hooks.test.ts b/packages/react/src/hooks.test.ts index c86b92e2..33e98d32 100644 --- a/packages/react/src/hooks.test.ts +++ b/packages/react/src/hooks.test.ts @@ -1,5 +1,40 @@ import { describe, it, expect } from "vitest"; -import { flatToTree } from "./hooks"; +import { renderHook, act } from "@testing-library/react"; +import { flatToTree, useUITree } from "./hooks"; +import type { UITree } from "@json-render/core"; + +describe("useUITree", () => { + it("starts with empty tree by default", () => { + const { result } = renderHook(() => useUITree()); + expect(result.current.tree).toEqual({ root: "", elements: {} }); + }); + + it("applies patches", () => { + const { result } = renderHook(() => useUITree()); + act(() => { + result.current.applyPatch({ op: "set", path: "/root", value: "main" }); + result.current.applyPatch({ + op: "add", + path: "/elements/main", + value: { key: "main", type: "Stack", props: {} }, + }); + }); + expect(result.current.tree.root).toBe("main"); + expect(result.current.tree.elements["main"]).toBeDefined(); + }); + + it("clears tree", () => { + const initialTree: UITree = { + root: "main", + elements: { main: { key: "main", type: "Stack", props: {} } }, + }; + const { result } = renderHook(() => useUITree({ initialTree })); + act(() => { + result.current.clear(); + }); + expect(result.current.tree).toEqual({ root: "", elements: {} }); + }); +}); describe("flatToTree", () => { it("converts array of elements to tree structure", () => { diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index deb71f28..d4e0551e 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -77,6 +77,47 @@ function applyPatch(tree: UITree, patch: JsonPatch): UITree { return newTree; } +/** + * Options for useUITree + */ +export interface UseUITreeOptions { + /** Initial tree state */ + initialTree?: UITree; +} + +/** + * Return type for useUITree + */ +export interface UseUITreeReturn { + /** Current UI tree */ + tree: UITree; + /** Apply a patch to the tree */ + applyPatch: (patch: JsonPatch) => void; + /** Replace the entire tree */ + setTree: (tree: UITree) => void; + /** Reset to empty tree */ + clear: () => void; +} + +/** + * Hook for managing a UI tree from external patch sources. + */ +export function useUITree(options?: UseUITreeOptions): UseUITreeReturn { + const [tree, setTree] = useState( + options?.initialTree ?? { root: "", elements: {} }, + ); + + const applyPatchFn = useCallback((patch: JsonPatch) => { + setTree((current) => ({ ...applyPatch(current, patch) })); + }, []); + + const clear = useCallback(() => { + setTree({ root: "", elements: {} }); + }, []); + + return { tree, applyPatch: applyPatchFn, setTree, clear }; +} + /** * Options for useUIStream */ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8fd5b76d..9dc66fde 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -51,7 +51,10 @@ export { // Hooks export { useUIStream, + useUITree, flatToTree, type UseUIStreamOptions, type UseUIStreamReturn, + type UseUITreeOptions, + type UseUITreeReturn, } from "./hooks"; From 01f52d0f1c969c3b12f49db4752452e29339c8c8 Mon Sep 17 00:00:00 2001 From: Daniel Lantz Date: Tue, 20 Jan 2026 23:47:10 -0500 Subject: [PATCH 2/2] fix: remove redundant spread in useUITree applyPatchFn applyPatch already returns a new object, so the spread was creating an unnecessary extra allocation. --- packages/react/src/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index d4e0551e..5b3c5f52 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -108,7 +108,7 @@ export function useUITree(options?: UseUITreeOptions): UseUITreeReturn { ); const applyPatchFn = useCallback((patch: JsonPatch) => { - setTree((current) => ({ ...applyPatch(current, patch) })); + setTree((current) => applyPatch(current, patch)); }, []); const clear = useCallback(() => {