From ccedc4b73dcf5565ba5eac11269428ef95395c84 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Fri, 31 Jul 2026 15:06:17 +0000 Subject: [PATCH] fix(elements): keep last good jsx preview when streamed chunk fails to parse --- .../elements/__tests__/jsx-preview.test.tsx | 66 +++++++++++++++++++ packages/elements/src/jsx-preview.tsx | 18 +++-- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/elements/__tests__/jsx-preview.test.tsx b/packages/elements/__tests__/jsx-preview.test.tsx index 2b87d51d..8c1dd219 100644 --- a/packages/elements/__tests__/jsx-preview.test.tsx +++ b/packages/elements/__tests__/jsx-preview.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from "@testing-library/react"; +import { StrictMode } from "react"; import type { JSXPreviewProps } from "../src/jsx-preview"; + import { JSXPreview, JSXPreviewContent, @@ -277,6 +279,70 @@ describe("jSXPreview streaming mode", () => { ); expect(screen.getByText("Text")).toBeInTheDocument(); }); + + it("keeps the last good preview when a streamed chunk fails to parse", () => { + // Parser reports errors synchronously during render; the resulting React + // "update while rendering" dev warning is expected here + const consoleError = vi.spyOn(console, "error").mockImplementation(vi.fn()); + const onError = vi.fn(); + + const { rerender } = render( + + + + ); + expect(screen.getByText("Good")).toBeInTheDocument(); + + rerender( + + + + ); + expect(screen.getByText("Good")).toBeInTheDocument(); + expect(onError).not.toHaveBeenCalled(); + + rerender( + + + + ); + expect(screen.getByText("Recovered")).toBeInTheDocument(); + + consoleError.mockRestore(); + }); + + it("keeps the last good preview across a bad chunk in Strict Mode", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(vi.fn()); + + const { rerender } = render( + + + + + + ); + expect(screen.getByText("Good")).toBeInTheDocument(); + + rerender( + + + + + + ); + expect(screen.getByText("Good")).toBeInTheDocument(); + + rerender( + + + + + + ); + expect(screen.getByText("Recovered")).toBeInTheDocument(); + + consoleError.mockRestore(); + }); }); describe("jSXPreview integration", () => { diff --git a/packages/elements/src/jsx-preview.tsx b/packages/elements/src/jsx-preview.tsx index 22709929..6446b2cc 100644 --- a/packages/elements/src/jsx-preview.tsx +++ b/packages/elements/src/jsx-preview.tsx @@ -1,8 +1,10 @@ "use client"; +import type { ComponentProps, ReactNode } from "react"; +import type { TProps as JsxParserProps } from "react-jsx-parser"; + import { cn } from "@repo/shadcn-ui/lib/utils"; import { AlertCircle } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; import { createContext, memo, @@ -13,7 +15,6 @@ import { useRef, useState, } from "react"; -import type { TProps as JsxParserProps } from "react-jsx-parser"; import JsxParser from "react-jsx-parser"; interface JSXPreviewContextValue { @@ -209,12 +210,17 @@ export const JSXPreviewContent = memo( const errorReportedRef = useRef(null); const lastGoodJsxRef = useRef(""); const [hadError, setHadError] = useState(false); - - // Reset error tracking when jsx changes - useEffect(() => { + const [prevProcessedJsx, setPrevProcessedJsx] = useState(processedJsx); + + // Reset error tracking before the parser renders the new string + // (react-jsx-parser reports parse errors synchronously during render, + // so a post-render effect would reset them too late and the tracking + // effect below would record the broken string as "last good") + if (processedJsx !== prevProcessedJsx) { + setPrevProcessedJsx(processedJsx); errorReportedRef.current = null; setHadError(false); - }, [processedJsx]); + } const handleError = useCallback( (err: Error) => {