Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/block-code-rest-props.md
Original file line number Diff line number Diff line change
@@ -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.
119 changes: 119 additions & 0 deletions packages/streamdown/__tests__/block-code-rest-props.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Streamdown mode="static" rehypePlugins={[stampSourceOffset]}>
{markdown}
</Streamdown>
);

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(
<Streamdown mode="static" rehypePlugins={[stampSourceOffset]}>
{markdown}
</Streamdown>
);

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(
<Streamdown mode="static" rehypePlugins={[stampSourceOffset]}>
{before}
</Streamdown>
);
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(
<Streamdown mode="static" rehypePlugins={[stampSourceOffset]}>
{after}
</Streamdown>
);

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(
<Streamdown mode="static">{"```ts\nconst x = 1;\n```"}</Streamdown>
);

await waitFor(() =>
expect(container.textContent).toContain("const x = 1;")
);
expect(container.querySelectorAll("[data-block]")).toHaveLength(0);
});
});
15 changes: 4 additions & 11 deletions packages/streamdown/lib/code-block/body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,16 +202,9 @@ export const CodeBlockBody = memo(
</pre>
</div>
);
},
(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.
);
5 changes: 5 additions & 0 deletions packages/streamdown/lib/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<CodeBlock
className={className}
Expand All @@ -976,6 +980,7 @@ const CodeComponent = ({
language={language}
lineNumbers={showLineNumbers}
startLine={startLine}
{...forwarded}
>
{showCodeControls ? (
<>
Expand Down
Loading