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
27 changes: 27 additions & 0 deletions .changeset/feat-default-component-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"streamdown": minor
---

feat: add `fallbackComponent` prop for missing map entries / `allowedTags`

Adds a new `fallbackComponent` prop to `<Streamdown>`. When provided, it is
used as a fallback renderer for any HTML tag or allowed custom tag that does
not have an explicit entry in the `components` map:

```tsx
<Streamdown
allowedTags={{ mention: ["user_id"] }}
fallbackComponent={({ node, children, ...props }) =>
createElement(node!.tagName, props, children)
}
>
{markdown}
</Streamdown>
```

`fallbackComponent` applies to custom tags declared via `allowedTags` (with no
explicit component entry) and to standard HTML tags absent from the built-in
component set (e.g. `<span>`, `<em>`, `<div>`, `<br>`). Built-in and explicit
`components` entries always win — this is not a full unstyled mode.

Refs #543
37 changes: 37 additions & 0 deletions apps/website/content/docs/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,43 @@ You can allow multiple custom tags:
</Streamdown>
```

### Fallback for missing map entries

When you only need a shared renderer for tags that are **not** already in the
built-in map (or your `components` overrides), use `fallbackComponent` instead of
enumerating every tag:

```tsx title="app/page.tsx"
import { createElement } from "react";

<Streamdown
allowedTags={{
mention: ["user_id"],
chip: [],
}}
fallbackComponent={({ node, children, ...props }) =>
createElement(node!.tagName, props, children)
}
components={{
// Still wins over fallbackComponent for this tag
mention: ({ user_id, children }) => (
<UserMention userId={user_id as string}>{children}</UserMention>
),
}}
>
{markdown}
</Streamdown>
```

`fallbackComponent` also applies to standard HTML tags absent from both the
built-in set and `components` (for example `<span>`, `<em>`, `<div>`, `<br>`).

<Callout type="info">
This is **not** a full unstyled mode. Built-in renderers (`h1`, `p`, `code`,
…) and any explicit `components` entries always take precedence. To restyle
those tags, override them in `components`.
</Callout>

### Data Attributes

Use `data*` in the attributes array to allow all `data-*` attributes on a tag:
Expand Down
7 changes: 6 additions & 1 deletion apps/website/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,14 @@ Streamdown can be configured to suit your needs. This guide will walk you throug
description: "Custom component overrides for Markdown elements",
type: "object",
},
fallbackComponent: {
description:
"Fallback renderer for HTML tags or allowedTags entries that have no matching key in components. Built-in and explicit components entries always win — this is not a full unstyled mode. See Components.",
type: "React.ComponentType<Record<string, unknown> & ExtraProps>",
},
allowedTags: {
description:
"Custom HTML tags to allow through sanitization, with their permitted attributes. Use with 'components' to render custom tags like <ref> or <mention>. Only works with default rehype plugins.",
"Custom HTML tags to allow through sanitization, with their permitted attributes. Use with 'components' or 'fallbackComponent' to render custom tags like <ref> or <mention>. Only works with default rehype plugins.",
type: "Record<string, string[]>",
},
literalTagContent: {
Expand Down
43 changes: 43 additions & 0 deletions packages/streamdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,46 @@ export default function Chat() {
```

For more info, see the [documentation](https://streamdown.ai/docs).

## `fallbackComponent` — fallback for missing map entries

Streamdown ships built-in renderers for common markdown tags. For tags that are
**not** in that map — and not overridden via `components` — you can provide a
`fallbackComponent`. Useful for `allowedTags` custom elements and uncovered HTML
tags like `<span>`, `<em>`, `<div>`, or `<br>`.

This is **not** a full unstyled mode: built-in entries (and any explicit
`components` overrides) still take precedence. To restyle tags that already have
defaults (e.g. `h1`, `p`, `code`), pass them in `components`.

```tsx
import { createElement } from "react";
import { Streamdown } from "streamdown";

// Render missing map entries / allowedTags via a pass-through
<Streamdown
allowedTags={{ mention: ["user_id"] }}
fallbackComponent={({ node, children, ...props }) =>
createElement(node!.tagName, props, children)
}
>
{markdown}
</Streamdown>
```

Combine with explicit overrides when some tags need special treatment:

```tsx
<Streamdown
allowedTags={{ mention: ["user_id"] }}
fallbackComponent={({ node, children, ...props }) =>
createElement(node!.tagName, props, children)
}
components={{
code: MyCodeBlock,
a: MyLink,
}}
>
{markdown}
</Streamdown>
```
205 changes: 205 additions & 0 deletions packages/streamdown/__tests__/fallback-component.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { render } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { Streamdown } from "../index";
import type { ExtraProps } from "../lib/markdown";

type FallbackProps = Record<string, unknown> & ExtraProps;

/**
* A minimal pass-through renderer: renders the element using its own tag
* name with any props passed down by hast-util-to-jsx-runtime.
*/
const PassThrough = ({ node, children, ...rest }: FallbackProps) =>
createElement(
node?.tagName ?? "span",
{ ...rest, "data-fallback": "true" },
children as React.ReactNode
);

describe("fallbackComponent prop", () => {
describe("allowedTags without explicit component", () => {
it("uses fallbackComponent for an allowedTags tag with no component entry", () => {
const { container } = render(
<Streamdown
allowedTags={{ mention: [] }}
fallbackComponent={PassThrough}
mode="static"
>
{"<mention>@alice</mention>"}
</Streamdown>
);

// PassThrough renders the original tag; verify data-fallback attribute
const el = container.querySelector("mention");
expect(el).toBeTruthy();
expect(el?.getAttribute("data-fallback")).toBe("true");
expect(el?.textContent).toBe("@alice");
});

it("uses fallbackComponent for multiple allowedTags without components", () => {
const { container } = render(
<Streamdown
allowedTags={{ tag1: [], tag2: [] }}
fallbackComponent={PassThrough}
mode="static"
>
{"<tag1>first</tag1> <tag2>second</tag2>"}
</Streamdown>
);

const tag1 = container.querySelector("tag1");
const tag2 = container.querySelector("tag2");
expect(tag1?.getAttribute("data-fallback")).toBe("true");
expect(tag2?.getAttribute("data-fallback")).toBe("true");
});
});

describe("explicit components take precedence", () => {
it("explicit component wins over fallbackComponent", () => {
const ExplicitTag = ({ children }: FallbackProps) => (
<span data-explicit="true">{children as React.ReactNode}</span>
);

const { container } = render(
<Streamdown
allowedTags={{ mention: [] }}
components={{ mention: ExplicitTag }}
fallbackComponent={PassThrough}
mode="static"
>
{"<mention>@bob</mention>"}
</Streamdown>
);

// Explicit component is used, not PassThrough
const explicit = container.querySelector('[data-explicit="true"]');
expect(explicit).toBeTruthy();
expect(explicit?.textContent).toBe("@bob");

// data-fallback should NOT be present
const fallback = container.querySelector('[data-fallback="true"]');
expect(fallback).toBeNull();
});

it("explicit p component overrides fallbackComponent for paragraph", () => {
const CustomP = ({ children }: React.PropsWithChildren) => (
<p data-custom="true">{children}</p>
);

const { container } = render(
<Streamdown
components={{ p: CustomP as any }}
fallbackComponent={PassThrough}
mode="static"
>
{"Hello world"}
</Streamdown>
);

const p = container.querySelector('[data-custom="true"]');
expect(p).toBeTruthy();
// fallbackComponent must not have been used for <p>
const fallback = container.querySelector('[data-fallback="true"]');
expect(fallback).toBeNull();
});
});

describe("HTML tags not in defaultComponents", () => {
it("uses fallbackComponent for tags absent from the default map (e.g. <span>)", () => {
const { container } = render(
<Streamdown fallbackComponent={PassThrough} mode="static">
{"<span>inline span</span>"}
</Streamdown>
);

const span = container.querySelector('[data-fallback="true"]');
expect(span).toBeTruthy();
expect(span?.textContent).toContain("inline span");
});
});

describe("built-in components still win with fallbackComponent set", () => {
it("still uses the built-in h1 (Tailwind classes) when fallbackComponent is set", () => {
const { container } = render(
<Streamdown fallbackComponent={PassThrough} mode="static">
{"# Hello"}
</Streamdown>
);

const h1 = container.querySelector("h1");
expect(h1).toBeTruthy();
expect(h1?.className).toContain("font-semibold");
// Must not have been rendered via the fallback
expect(h1?.getAttribute("data-fallback")).toBeNull();
expect(container.querySelector('[data-fallback="true"]')).toBeNull();
});
});

describe("backward compatibility", () => {
it("applies built-in Tailwind classes when fallbackComponent is absent", () => {
const { container } = render(
<Streamdown mode="static">{"# Hello"}</Streamdown>
);

const h1 = container.querySelector("h1");
expect(h1).toBeTruthy();
expect(h1?.className).toContain("font-semibold");
});

it("does not add data-fallback when fallbackComponent is absent", () => {
const { container } = render(
<Streamdown mode="static">{"Hello **world**"}</Streamdown>
);

const fallback = container.querySelector('[data-fallback="true"]');
expect(fallback).toBeNull();
});
});

describe("streaming mode", () => {
it("applies fallbackComponent in streaming mode for allowedTags", () => {
const { container } = render(
<Streamdown
allowedTags={{ chip: [] }}
fallbackComponent={PassThrough}
mode="streaming"
>
{"<chip>label</chip>"}
</Streamdown>
);

const chip = container.querySelector("chip");
expect(chip).toBeTruthy();
expect(chip?.getAttribute("data-fallback")).toBe("true");
});
});

describe("node prop passthrough", () => {
it("receives node with tagName in fallbackComponent", () => {
const tagNames: string[] = [];
const Inspector = ({ node, children }: FallbackProps) => {
if (node?.tagName) {
tagNames.push(node.tagName);
}
return createElement(
node?.tagName ?? "span",
{},
children as React.ReactNode
);
};

render(
<Streamdown
allowedTags={{ badge: [] }}
fallbackComponent={Inspector}
mode="static"
>
{"<badge>x</badge>"}
</Streamdown>
);

expect(tagNames).toContain("badge");
});
});
});
Loading
Loading