diff --git a/.changeset/block-code-rest-props.md b/.changeset/block-code-rest-props.md new file mode 100644 index 00000000..8f6fc2b8 --- /dev/null +++ b/.changeset/block-code-rest-props.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +Forward rest props through block code. The `code` component spread its rest props on the inline branch and dropped them on the block branch, so an attribute a consumer put on a fenced code element never reached the DOM. `CodeBlockBody` now takes part in that comparison, so a forwarded attribute updates instead of keeping the value it first rendered with. diff --git a/packages/streamdown/__tests__/block-code-rest-props.test.tsx b/packages/streamdown/__tests__/block-code-rest-props.test.tsx new file mode 100644 index 00000000..78f370cc --- /dev/null +++ b/packages/streamdown/__tests__/block-code-rest-props.test.tsx @@ -0,0 +1,119 @@ +/** + * The `code` component forwards its rest props on the inline branch and not on + * the block branch, so an attribute a consumer puts on a fenced code element — + * via a rehype plugin, the supported way to annotate rendered output — reaches + * inline code and silently disappears on code blocks. + * + * Once it is forwarded, CodeBlockBody has to compare it too, or the first value + * it renders with is the only one it ever renders. + */ + +import { render, waitFor } from "@testing-library/react"; +import type { Root } from "hast"; +import { visit } from "unist-util-visit"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; + +/** Stamps every `code` element with the source offset it was parsed from. */ +const stampSourceOffset = () => (tree: Root) => { + visit(tree, "element", (node) => { + if (node.tagName !== "code") { + return; + } + const start = node.position?.start?.offset; + node.properties = { + ...node.properties, + "data-src-start": start === undefined ? "unknown" : String(start), + }; + }); +}; + +const findStamped = (container: HTMLElement) => + Array.from(container.querySelectorAll("[data-src-start]")); + +describe("rest props on block code", () => { + it("forwards a consumer attribute to a fenced code block", async () => { + const markdown = "Some `inline` code.\n\n```ts\nconst x = 1;\n```"; + + const { container } = render( + + {markdown} + + ); + + await waitFor(() => + expect(container.textContent).toContain("const x = 1;") + ); + + // Both code elements — the inline one and the fenced one — carry the stamp. + expect(findStamped(container)).toHaveLength(2); + }); + + it("lands the attribute on the element holding the code text", async () => { + const markdown = "```ts\nconst x = 1;\n```"; + + const { container } = render( + + {markdown} + + ); + + await waitFor(() => + expect(container.textContent).toContain("const x = 1;") + ); + + const stamped = findStamped(container); + expect(stamped).toHaveLength(1); + // The header label and the copy/download controls are siblings of the body, + // so a stamp that landed correctly describes exactly the code text. + expect(stamped[0].textContent).toContain("const x = 1;"); + expect(stamped[0].textContent).not.toContain("ts\nconst"); + }); + + it("updates the attribute when the code block moves", async () => { + // A paragraph inserted above pushes the fence down the document. The code + // itself is untouched, so the highlighted tokens keep their identity and + // only the forwarded attribute differs — which is the case a comparator + // that ignores forwarded props gets wrong. + const before = "Intro\n\n```ts\nconst x = 1;\n```"; + const after = "Intro\n\nMore text\n\n```ts\nconst x = 1;\n```"; + + const { container, rerender } = render( + + {before} + + ); + await waitFor(() => + expect(container.textContent).toContain("const x = 1;") + ); + + const initial = Number( + findStamped(container)[0]?.getAttribute("data-src-start") + ); + expect(Number.isNaN(initial)).toBe(false); + + rerender( + + {after} + + ); + + await waitFor(() => { + const updated = Number( + findStamped(container)[0]?.getAttribute("data-src-start") + ); + expect(updated).toBe(initial + (after.length - before.length)); + }); + }); + + it("does not leak the internal data-block marker into the DOM", async () => { + const { container } = render( + {"```ts\nconst x = 1;\n```"} + ); + + await waitFor(() => + expect(container.textContent).toContain("const x = 1;") + ); + expect(container.querySelectorAll("[data-block]")).toHaveLength(0); + }); +}); diff --git a/packages/streamdown/lib/code-block/body.tsx b/packages/streamdown/lib/code-block/body.tsx index cc3724d7..8a51c995 100644 --- a/packages/streamdown/lib/code-block/body.tsx +++ b/packages/streamdown/lib/code-block/body.tsx @@ -202,16 +202,9 @@ export const CodeBlockBody = memo( ); - }, - (prevProps, nextProps) => { - // Custom comparison: only re-render if result tokens actually changed - return ( - prevProps.maxHeight === nextProps.maxHeight && - prevProps.result === nextProps.result && - prevProps.language === nextProps.language && - prevProps.className === nextProps.className && - prevProps.startLine === nextProps.startLine && - prevProps.lineNumbers === nextProps.lineNumbers - ); } + // No custom comparator: React's default shallow comparison already compares + // `result` by reference — the tokens are memoized upstream, so an unchanged + // code string does not re-highlight — and, unlike a hand-written list of + // props, it also covers whatever the caller forwards through CodeBlock. ); diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx index b3cf8ecb..23b9ccee 100644 --- a/packages/streamdown/lib/components.tsx +++ b/packages/streamdown/lib/components.tsx @@ -968,6 +968,10 @@ const CodeComponent = ({ const showDownload = shouldShowCodeControl(controlsConfig, "download"); const showCopy = shouldShowCodeControl(controlsConfig, "copy"); + // `data-block` is the marker the custom `pre` component sets to identify a + // fenced block. It is internal, so it is the one prop not forwarded on. + const { "data-block": _blockMarker, ...forwarded } = props; + return ( {showCodeControls ? ( <>