From d1d10f3c7a57f87d0533c579f9ddc24cf33be8ad Mon Sep 17 00:00:00 2001 From: Maxim Date: Sun, 1 Nov 2026 02:45:34 +0100 Subject: [PATCH] fix(react): render streamed repeat items before completion --- packages/react/src/chat-streaming.test.tsx | 131 +++++++++++++++++++++ packages/react/src/hooks.ts | 26 ++-- 2 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 packages/react/src/chat-streaming.test.tsx diff --git a/packages/react/src/chat-streaming.test.tsx b/packages/react/src/chat-streaming.test.tsx new file mode 100644 index 00000000..b73c9d5e --- /dev/null +++ b/packages/react/src/chat-streaming.test.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import type { JsonPatch, Spec } from "@json-render/core"; +import { useChatUI } from "./hooks"; +import { JSONUIProvider, Renderer, type ComponentRegistry } from "./renderer"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const registry: ComponentRegistry = { + List: ({ children }) => , + Item: ({ element }) =>
  • {String(element.props.label)}
  • , +}; + +describe("useChatUI streamed state", () => { + it("renders appended, edited, and removed repeat items before the stream ends", async () => { + let controller!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(value) { + controller = value; + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(stream)), + ); + + let chat!: ReturnType; + const onComplete = vi.fn(); + function App() { + chat = useChatUI({ api: "/api/chat", onComplete }); + const spec = chat.messages.at(-1)?.spec; + return ( + + + + ); + } + render(); + + let request!: Promise; + await act(async () => { + request = chat.send("Show the items"); + }); + + async function push(...patches: JsonPatch[]) { + await act(async () => { + controller.enqueue( + new TextEncoder().encode( + patches.map((patch) => JSON.stringify(patch)).join("\n") + "\n", + ), + ); + }); + } + + const labels = () => + screen.queryAllByRole("listitem").map((item) => item.textContent); + const currentSpec = () => chat.messages.at(-1)!.spec as Spec; + + try { + await push( + { op: "add", path: "/root", value: "list" }, + { + op: "add", + path: "/elements/list", + value: { + type: "List", + props: {}, + repeat: { statePath: "/items", key: "id" }, + children: ["item"], + }, + }, + { + op: "add", + path: "/elements/item", + value: { type: "Item", props: { label: { $item: "label" } } }, + }, + { op: "add", path: "/state", value: { items: [] } }, + ); + const empty = currentSpec(); + expect(labels()).toEqual([]); + + await push({ + op: "add", + path: "/state/items/0", + value: { id: "a", label: "First" }, + }); + await waitFor(() => expect(labels()).toEqual(["First"])); + const first = currentSpec(); + expect(empty.state?.items).toEqual([]); + expect(chat.isStreaming).toBe(true); + + await push({ + op: "add", + path: "/state/items/-", + value: { id: "b", label: "Second" }, + }); + await waitFor(() => expect(labels()).toEqual(["First", "Second"])); + + await push({ + op: "replace", + path: "/state/items/0/label", + value: "Updated", + }); + await waitFor(() => expect(labels()).toEqual(["Updated", "Second"])); + + await push({ op: "remove", path: "/state/items/1" }); + await waitFor(() => expect(labels()).toEqual(["Updated"])); + expect(first.state?.items).toEqual([{ id: "a", label: "First" }]); + expect(chat.isStreaming).toBe(true); + expect(onComplete).not.toHaveBeenCalled(); + } finally { + await act(async () => { + controller.close(); + await request; + }); + } + + expect(chat.error).toBeNull(); + expect(chat.isStreaming).toBe(false); + expect(onComplete).toHaveBeenCalledOnce(); + expect(onComplete.mock.calls[0]![0].spec).toEqual(currentSpec()); + }); +}); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index e222d501..a898e2b1 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -832,21 +832,17 @@ export function useChatUI({ onPatch(patch) { hasSpec = true; applySpecPatch(currentSpec, patch); + // Detach state from the mutable accumulator so later patches cannot + // mutate published arrays without notifying StateProvider. + const spec: Spec = { + root: currentSpec.root, + elements: { ...currentSpec.elements }, + ...(currentSpec.state + ? { state: structuredClone(currentSpec.state) } + : {}), + }; setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - spec: { - root: currentSpec.root, - elements: { ...currentSpec.elements }, - ...(currentSpec.state - ? { state: { ...currentSpec.state } } - : {}), - }, - } - : m, - ), + prev.map((m) => (m.id === assistantId ? { ...m, spec } : m)), ); }, onText(line) { @@ -876,7 +872,7 @@ export function useChatUI({ root: currentSpec.root, elements: { ...currentSpec.elements }, ...(currentSpec.state - ? { state: { ...currentSpec.state } } + ? { state: structuredClone(currentSpec.state) } : {}), } : null,