diff --git a/.changeset/prefix-raw-html-ids.md b/.changeset/prefix-raw-html-ids.md
new file mode 100644
index 00000000..280f051d
--- /dev/null
+++ b/.changeset/prefix-raw-html-ids.md
@@ -0,0 +1,5 @@
+---
+"streamdown": patch
+---
+
+fix: restore `user-content-` clobberPrefix on sanitize so raw HTML ids cannot clobber `window`, and strip the doubled prefix on footnote ids that remark-rehype already prefixed.
diff --git a/packages/streamdown/__tests__/allowed-tags.test.tsx b/packages/streamdown/__tests__/allowed-tags.test.tsx
index a941971a..ad33dade 100644
--- a/packages/streamdown/__tests__/allowed-tags.test.tsx
+++ b/packages/streamdown/__tests__/allowed-tags.test.tsx
@@ -193,10 +193,12 @@ Content for snippet 2
);
- // Sanitizer's clobberPrefix is disabled to keep footnote backref hrefs in
- // sync with their targets, so user-provided ids are preserved as-is.
- const snippet1 = container.querySelector('[data-testid="snippet-1"]');
- const snippet2 = container.querySelector('[data-testid="snippet-2"]');
+ const snippet1 = container.querySelector(
+ '[data-testid="snippet-user-content-1"]'
+ );
+ const snippet2 = container.querySelector(
+ '[data-testid="snippet-user-content-2"]'
+ );
expect(snippet1).toBeTruthy();
expect(snippet2).toBeTruthy();
// Ensure snippet 2's content isn't absorbed into snippet 1
diff --git a/packages/streamdown/__tests__/anchor-links.test.tsx b/packages/streamdown/__tests__/anchor-links.test.tsx
new file mode 100644
index 00000000..dcd3c3c7
--- /dev/null
+++ b/packages/streamdown/__tests__/anchor-links.test.tsx
@@ -0,0 +1,67 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Streamdown } from "../index";
+
+describe("Sanitized fragment links", () => {
+ it("rewrites raw HTML fragments but leaves prefixed and external links alone", () => {
+ const { container } = render(
+
+ {
+ 'Details
\n\nRaw\n\n[Prefixed](#user-content-details)\n\n[External](https://example.com/#details)'
+ }
+
+ );
+
+ expect(container.querySelector("[id]")?.id).toBe("user-content-details");
+ expect(
+ Array.from(container.querySelectorAll("a[href]"), (link) =>
+ link.getAttribute("href")
+ )
+ ).toEqual([
+ "#user-content-details",
+ "#user-content-details",
+ "https://example.com/#details",
+ ]);
+ });
+
+ it("keeps footnote references and backrefs aligned", () => {
+ const { container } = render(
+
+ {"Note[^1].\n\n[^1]: Details."}
+
+ );
+
+ const links = container.querySelectorAll('a[href^="#"]');
+ expect(links.length).toBe(2);
+ for (const link of links) {
+ expect(
+ container.querySelector(link.getAttribute("href") ?? "")
+ ).toBeTruthy();
+ expect(link.getAttribute("href")).not.toContain(
+ "user-content-user-content-"
+ );
+ }
+ });
+
+ for (const mode of ["static", "streaming"] as const) {
+ for (const allowedTags of [undefined, { snippet: ["id"] }]) {
+ it(`keeps raw HTML targets reachable in ${mode} mode (${allowedTags ? "extended" : "default"} schema)`, () => {
+ const { container } = render(
+
+ {'Details
\n\n[Go](#details)'}
+
+ );
+
+ const target = container.querySelector("[id]");
+ const link = container.querySelector("a");
+
+ expect(target?.id).toBe("user-content-details");
+ expect(link?.getAttribute("href")).toBe(`#${target?.id}`);
+ });
+ }
+ }
+});
diff --git a/packages/streamdown/__tests__/custom-tag-markdown.test.tsx b/packages/streamdown/__tests__/custom-tag-markdown.test.tsx
index 85661f1a..5d36acf2 100644
--- a/packages/streamdown/__tests__/custom-tag-markdown.test.tsx
+++ b/packages/streamdown/__tests__/custom-tag-markdown.test.tsx
@@ -125,8 +125,12 @@ Content for snippet 2
);
- const snippet1 = container.querySelector('[data-testid="snippet-1"]');
- const snippet2 = container.querySelector('[data-testid="snippet-2"]');
+ const snippet1 = container.querySelector(
+ '[data-testid="snippet-user-content-1"]'
+ );
+ const snippet2 = container.querySelector(
+ '[data-testid="snippet-user-content-2"]'
+ );
expect(snippet1).toBeTruthy();
expect(snippet2).toBeTruthy();
expect(snippet1?.textContent).not.toContain("Snippet 2");
diff --git a/packages/streamdown/__tests__/footnotes.test.tsx b/packages/streamdown/__tests__/footnotes.test.tsx
index f8ede71c..3c95a27d 100644
--- a/packages/streamdown/__tests__/footnotes.test.tsx
+++ b/packages/streamdown/__tests__/footnotes.test.tsx
@@ -178,6 +178,15 @@ GFM extends standard Markdown with powerful features[^1]. Here's a comprehensive
}
});
+ it("prefixes raw HTML ids so they cannot clobber window", () => {
+ const markdown = '
raw
';
+ const { container } = render({markdown});
+
+ expect(container.querySelector("#user-content-clobbered")).toBeTruthy();
+ expect(container.querySelector("#clobbered")).toBeNull();
+ expect((globalThis as { clobbered?: unknown }).clobbered).toBeUndefined();
+ });
+
it("should show footnotes once content arrives", () => {
// Simulate streaming where definition has partial content
const markdown = `Text with footnote[^1].
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index e25fe043..a0ba7788 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -391,14 +391,52 @@ export type StreamdownProps = Options & {
| undefined;
} & AriaAttributes;
+const CLOBBER_PREFIX = "user-content-";
+
+interface HastNode {
+ children?: HastNode[];
+ properties?: { id?: unknown; href?: unknown };
+ type?: string;
+}
+
+/** Normalize sanitized ids and keep fragment links aligned with their targets. */
+function rehypeUndoubleClobberPrefix() {
+ const doubled = `${CLOBBER_PREFIX}${CLOBBER_PREFIX}`;
+ const visit = (node: HastNode) => {
+ if (
+ node.type === "element" &&
+ typeof node.properties?.id === "string" &&
+ node.properties.id.startsWith(doubled)
+ ) {
+ node.properties.id = node.properties.id.slice(CLOBBER_PREFIX.length);
+ }
+ // Fragment targets are sanitized too. Rewrite even when the target is in
+ // another streaming block, but leave already-prefixed footnote links alone.
+ if (
+ node.type === "element" &&
+ typeof node.properties?.href === "string" &&
+ node.properties.href.startsWith("#") &&
+ node.properties.href.length > 1 &&
+ !node.properties.href.startsWith(`#${CLOBBER_PREFIX}`)
+ ) {
+ node.properties.href = `#${CLOBBER_PREFIX}${node.properties.href.slice(1)}`;
+ }
+ if (Array.isArray(node.children)) {
+ for (const child of node.children) {
+ visit(child);
+ }
+ }
+ };
+ return (tree: HastNode) => {
+ visit(tree);
+ };
+}
+
const defaultSanitizeSchema = {
...defaultSchema,
- // remark-rehype already prefixes footnote ids and backref hrefs with
- // `user-content-` (its default `clobberPrefix`). hast-util-sanitize's default
- // `clobberPrefix` is also `user-content-`, which would double-prefix ids like
- // `user-content-user-content-fn-1` while leaving the (already-prefixed) href
- // pointing at the un-doubled anchor. Disable it here to avoid the mismatch.
- clobberPrefix: "",
+ // Keep the default `user-content-` clobberPrefix so raw HTML ids (from
+ // rehype-raw) cannot clobber `window`. remark-rehype already prefixes
+ // footnote ids; `rehypeUndoubleClobberPrefix` strips the doubled prefix.
protocols: {
...defaultSchema.protocols,
// `streamdown:` is the remend sentinel scheme for incomplete links/images
@@ -415,6 +453,7 @@ const defaultSanitizeSchema = {
export const defaultRehypePlugins: Record = {
raw: rehypeRaw,
sanitize: [rehypeSanitize, defaultSanitizeSchema],
+ undoubleClobberPrefix: rehypeUndoubleClobberPrefix,
harden: [
harden,
{
@@ -1028,6 +1067,7 @@ export const Streamdown = memo(
result = [
defaultRehypePlugins.raw,
[rehypeSanitize, extendedSchema],
+ defaultRehypePlugins.undoubleClobberPrefix,
defaultRehypePlugins.harden,
];
}