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 ? (
<>