From 71e7ebaadd493c07d240e163c6bed3852d1d1ee7 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Wed, 19 Aug 2026 01:52:34 +0300 Subject: [PATCH] fix: update streamed code block content --- .changeset/fresh-code-blocks-update.md | 5 ++ .../__tests__/code-block-memo.test.tsx | 24 +++++++++ packages/streamdown/lib/components.tsx | 51 ++++++++++++++----- 3 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 .changeset/fresh-code-blocks-update.md diff --git a/.changeset/fresh-code-blocks-update.md b/.changeset/fresh-code-blocks-update.md new file mode 100644 index 00000000..8842c8c6 --- /dev/null +++ b/.changeset/fresh-code-blocks-update.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +Fix streamed code blocks remaining stale when their content changes without moving in the document. diff --git a/packages/streamdown/__tests__/code-block-memo.test.tsx b/packages/streamdown/__tests__/code-block-memo.test.tsx index 4c8675df..3bb020e3 100644 --- a/packages/streamdown/__tests__/code-block-memo.test.tsx +++ b/packages/streamdown/__tests__/code-block-memo.test.tsx @@ -18,6 +18,30 @@ vi.mock("../lib/code-block", () => ({ })); describe("MemoCode in streaming mode", () => { + it("should re-render code blocks when their content changes in place", async () => { + codeBlockRenderCount = 0; + + const initial = "```js\nfirst\n```"; + const updated = "```js\nsecond\n```"; + + const { container, rerender } = render({initial}); + + await waitFor(() => { + expect(container.textContent).toContain("first"); + }); + + const initialRenderCount = codeBlockRenderCount; + + rerender({updated}); + + await waitFor(() => { + expect(container.textContent).toContain("second"); + }); + + expect(container.textContent).not.toContain("first"); + expect(codeBlockRenderCount).toBeGreaterThan(initialRenderCount); + }); + it("should not re-render unchanged code blocks on streaming updates", async () => { codeBlockRenderCount = 0; diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx index cb6eb67d..396c919d 100644 --- a/packages/streamdown/lib/components.tsx +++ b/packages/streamdown/lib/components.tsx @@ -89,6 +89,38 @@ function sameClassAndNode( ); } +function getCodeContent(children: React.ReactNode): string | undefined { + if (typeof children === "string") { + return children; + } + + if ( + isValidElement(children) && + children.props && + typeof children.props === "object" && + "children" in children.props && + typeof children.props.children === "string" + ) { + return children.props.children; + } + + return undefined; +} + +function sameCodeContent( + prev: React.ReactNode, + next: React.ReactNode +): boolean { + const prevContent = getCodeContent(prev); + const nextContent = getCodeContent(next); + + if (prevContent === undefined && nextContent === undefined) { + return prev === next; + } + + return prevContent === nextContent; +} + const shouldShowControls = ( config: ControlsConfig, type: "table" | "code" | "mermaid" | "image" @@ -852,19 +884,7 @@ const CodeComponent = ({ : false; const showLineNumbers = !metaNoLineNumbers && contextLineNumbers !== false; - // Extract code content from children safely - let code = ""; - if ( - isValidElement(children) && - children.props && - typeof children.props === "object" && - "children" in children.props && - typeof children.props.children === "string" - ) { - code = children.props.children; - } else if (typeof children === "string") { - code = children; - } + const code = getCodeContent(children) ?? ""; if (customRenderer) { const CustomComponent = customRenderer.component; @@ -981,7 +1001,10 @@ const MemoCode = memo< DetailedHTMLProps, HTMLElement> & ExtraProps >( CodeComponent, - (p, n) => p.className === n.className && sameNodePosition(p.node, n.node) + (p, n) => + p.className === n.className && + sameNodePosition(p.node, n.node) && + sameCodeContent(p.children, n.children) ); MemoCode.displayName = "MarkdownCode";