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

feat: add `defaultComponent` fallback prop for unstyled/custom tag rendering

Adds a new `defaultComponent` 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. This enables unstyled /
passthrough rendering without enumerating every tag:

```tsx
<Streamdown
defaultComponent={({ node, children, ...props }) =>
createElement(node!.tagName, props, children)
}
>
{markdown}
</Streamdown>
```

`defaultComponent` applies to custom tags declared via `allowedTags` (no
explicit component entry) and to standard HTML tags absent from the built-in
Tailwind component set. Explicit `components` entries always win.

Refs #543
46 changes: 46 additions & 0 deletions packages/streamdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,49 @@ export default function Chat() {
```

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

## `defaultComponent` — unstyled / custom fallback rendering

By default, Streamdown renders most HTML elements with Tailwind utility classes.
If you use a different design system (or simply want unstyled output), you can
provide a `defaultComponent` prop. It acts as a **fallback renderer** for any
tag that does not have an explicit entry in the `components` map.

`defaultComponent` applies to:

- Custom tags declared via `allowedTags` that have no matching key in `components`.
- Standard HTML tags not covered by the built-in Tailwind component set (e.g.
`<span>`, `<div>`, `<section>`).

Explicit entries in `components` always take precedence over `defaultComponent`.

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

// Pass-through: renders every unhandled tag as plain HTML
<Streamdown
defaultComponent={({ node, children, ...props }) =>
createElement(node!.tagName, props, children)
}
>
{markdown}
</Streamdown>
```

You can combine `defaultComponent` with explicit overrides for the tags that
need special treatment:

```tsx
<Streamdown
defaultComponent={({ node, children, ...props }) =>
createElement(node!.tagName, props, children)
}
components={{
code: MyCodeBlock,
a: MyLink,
}}
>
{markdown}
</Streamdown>
```
194 changes: 194 additions & 0 deletions packages/streamdown/__tests__/default-component.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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("defaultComponent prop", () => {
describe("allowedTags without explicit component", () => {
it("uses defaultComponent for an allowedTags tag with no component entry", () => {
const { container } = render(
<Streamdown
allowedTags={{ mention: [] }}
defaultComponent={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 defaultComponent for multiple allowedTags without components", () => {
const { container } = render(
<Streamdown
allowedTags={{ tag1: [], tag2: [] }}
defaultComponent={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 defaultComponent", () => {
const ExplicitTag = ({ children }: FallbackProps) => (
<span data-explicit="true">{children as React.ReactNode}</span>
);

const { container } = render(
<Streamdown
allowedTags={{ mention: [] }}
components={{ mention: ExplicitTag }}
defaultComponent={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 defaultComponent for paragraph", () => {
const CustomP = ({ children }: React.PropsWithChildren) => (
<p data-custom="true">{children}</p>
);

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

const p = container.querySelector('[data-custom="true"]');
expect(p).toBeTruthy();
// defaultComponent 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 defaultComponent for tags absent from the default map (e.g. <span>)", () => {
const { container } = render(
<Streamdown defaultComponent={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("backward compatibility", () => {
it("behaves identically when defaultComponent is not provided", () => {
const { container: withProp } = render(
<Streamdown mode="static">{"# Hello"}</Streamdown>
);
const { container: withoutProp } = render(
<Streamdown mode="static">{"# Hello"}</Streamdown>
);

// Both should produce equivalent output
expect(withProp.innerHTML).toBe(withoutProp.innerHTML);
// defaultComponents Tailwind classes should still be applied
const h1 = withProp.querySelector("h1");
expect(h1).toBeTruthy();
expect(h1?.className).toContain("font-semibold");
});

it("does not add data-fallback when defaultComponent 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 defaultComponent in streaming mode for allowedTags", () => {
const { container } = render(
<Streamdown
allowedTags={{ chip: [] }}
defaultComponent={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 defaultComponent", () => {
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: [] }}
defaultComponent={Inspector}
mode="static"
>
{"<badge>x</badge>"}
</Streamdown>
);

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