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
5 changes: 5 additions & 0 deletions .changeset/hot-dryers-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": minor
---

Add new option `extraIncompleteHandles` that allows users to configure how unterminated Markdown blocks are handled.
7 changes: 7 additions & 0 deletions apps/website/app/components/props.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ const props = [
description:
"Array of remark plugins to use for processing markdown. Includes GitHub Flavored Markdown and math support by default. You can import defaultRemarkPlugins to access individual default plugins when overriding.",
},
{
name: "extraIncompleteHandles",
type: "array",
default: "[handleIncompleteStrikethrough, handleIncompleteBlockKatex]",
description:
"The unterminated Markdown blocks handles based on the syntax supported by the remarkPlugins.",
},
{
name: "shikiTheme",
type: "[BundledTheme, BundledTheme] (from Shiki)",
Expand Down
1 change: 1 addition & 0 deletions packages/streamdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ Streamdown accepts all the same props as react-markdown, plus additional streami
| `components` | `object` | - | Custom component overrides |
| `rehypePlugins` | `array` | `[[harden, { allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], defaultOrigin: undefined }], rehypeRaw, [rehypeKatex, { errorColor: "var(--color-muted-foreground)" }]]` | Rehype plugins to use. Includes rehype-harden for security, rehype-raw for HTML support, and rehype-katex for math rendering by default |
| `remarkPlugins` | `array` | `[[remarkGfm, {}], [remarkMath, { singleDollarTextMath: false }]]` | Remark plugins to use. Includes GitHub Flavored Markdown and math support by default |
| `extraIncompleteHandles` | `array` | `[handleIncompleteStrikethrough, handleIncompleteBlockKatex]` | The unterminated Markdown blocks handles based on the syntax supported by the remarkPlugins. |
| `shikiTheme` | `[BundledTheme, BundledTheme]` | `['github-light', 'github-dark']` | The light and dark themes to use for code blocks |
| `mermaidConfig` | `MermaidConfig` | - | Custom configuration for Mermaid diagrams (theme, colors, etc.) |
| `controls` | `boolean \| { table?: boolean, code?: boolean, mermaid?: boolean }` | `true` | Control visibility of copy/download buttons |
Expand Down
33 changes: 32 additions & 1 deletion packages/streamdown/__tests__/dollar-sign.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Streamdown } from "../index";
import remarkMath from "remark-math";
import type { Pluggable } from "unified";
import {
Streamdown,
defaultRemarkPlugins,
} from "../index";
import {
handleIncompleteStrikethrough,
handleIncompleteInlineKatex,
handleIncompleteBlockKatex,
} from "../lib/parse-incomplete-markdown";

describe("Dollar sign handling", () => {
it("should not render dollar amounts as math", () => {
Expand Down Expand Up @@ -88,4 +98,25 @@ describe("Dollar sign handling", () => {
const text = container.textContent;
expect(text).toContain("$99.99");
});

it("should handle escaped dollar signs and normal single dollar inline math with extra handleIncompleteInlineKatex", () => {
const content = "The price is \\$50 and math is $E = mc^2";
const extraIncompleteHandles = [
handleIncompleteStrikethrough,
handleIncompleteInlineKatex,
handleIncompleteBlockKatex,
];
const remarkPlugins: Pluggable[] = [
defaultRemarkPlugins.gfm,
[remarkMath, { singleDollarTextMath: true }],
];
const { container } = render(<Streamdown remarkPlugins={remarkPlugins} extraIncompleteHandles={extraIncompleteHandles}>{content}</Streamdown>);

const katexElements = container.querySelectorAll(".katex");
expect(katexElements.length).toBe(1);

// Check that dollar amounts are preserved
const text = container.textContent;
expect(text).toContain("$50");
});
});
29 changes: 28 additions & 1 deletion packages/streamdown/__tests__/parse-incomplete-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
import { parseIncompleteMarkdown } from "../lib/parse-incomplete-markdown";
import {
type IncompleteHandle,
parseIncompleteMarkdown as parseIncompleteMarkdownWithoutDefaultExtraIncompleteHandles,
handleIncompleteStrikethrough,
handleIncompleteInlineKatex,
handleIncompleteBlockKatex,
} from "../lib/parse-incomplete-markdown";

const parseIncompleteMarkdown = (text: string, extraIncompleteHandles: IncompleteHandle[] = []): string => {
return parseIncompleteMarkdownWithoutDefaultExtraIncompleteHandles(text, [handleIncompleteStrikethrough, handleIncompleteBlockKatex, ...extraIncompleteHandles]);
}

describe("parseIncompleteMarkdown", () => {
describe("basic input handling", () => {
Expand Down Expand Up @@ -355,15 +365,24 @@ describe("parseIncompleteMarkdown", () => {
expect(parseIncompleteMarkdown("$incomplete")).toBe("$incomplete");
});

it("should complete single dollar signs with extra handleIncompleteInlineKatex", () => {
expect(parseIncompleteMarkdown("Text with $formula", [handleIncompleteInlineKatex])).toBe(
"Text with $formula$"
);
expect(parseIncompleteMarkdown("$incomplete", [handleIncompleteInlineKatex])).toBe("$incomplete$");
});

it("should keep text with paired dollar signs unchanged", () => {
// Even paired dollar signs are preserved but not treated as math
const text = "Text with $x^2 + y^2 = z^2$";
expect(parseIncompleteMarkdown(text)).toBe(text);
expect(parseIncompleteMarkdown(text, [handleIncompleteInlineKatex])).toBe(text);
});

it("should handle multiple inline KaTeX sections", () => {
const text = "$a = 1$ and $b = 2$";
expect(parseIncompleteMarkdown(text)).toBe(text);
expect(parseIncompleteMarkdown(text, [handleIncompleteInlineKatex])).toBe(text);
});

it("should NOT complete odd number of dollar signs", () => {
Expand All @@ -385,11 +404,19 @@ describe("parseIncompleteMarkdown", () => {
expect(parseIncompleteMarkdown("$x + y = z")).toBe("$x + y = z");
});

it("should complete dollar sign at start of text with extra handleIncompleteInlineKatex", () => {
expect(parseIncompleteMarkdown("$x + y = z", [handleIncompleteInlineKatex])).toBe("$x + y = z$");
});

it("should handle escaped dollar signs", () => {
const text = "Price is \\$100";
expect(parseIncompleteMarkdown(text)).toBe(text);
});

it("should NOT complete escaped single dollar signs with extra handleIncompleteInlineKatex", () => {
expect(parseIncompleteMarkdown("\\$3 dollar", [handleIncompleteInlineKatex])).toBe("\\$3 dollar");
});

it("should handle multiple consecutive dollar signs correctly", () => {
expect(parseIncompleteMarkdown("$$$")).toBe("$$$$$");
expect(parseIncompleteMarkdown("$$$$")).toBe("$$$$");
Expand Down
22 changes: 18 additions & 4 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { harden } from "rehype-harden";
import type { Pluggable } from "unified";
import { components as defaultComponents } from "./lib/components";
import { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
import { parseIncompleteMarkdown } from "./lib/parse-incomplete-markdown";
import {
type IncompleteHandle,
parseIncompleteMarkdown,
handleIncompleteStrikethrough,
handleIncompleteBlockKatex,
} from "./lib/parse-incomplete-markdown";
import { cn } from "./lib/utils";

export type { MermaidConfig } from "mermaid";
Expand All @@ -30,6 +35,7 @@ export type StreamdownProps = Options & {
parseIncompleteMarkdown?: boolean;
className?: string;
shikiTheme?: [BundledTheme, BundledTheme];
extraIncompleteHandles?: IncompleteHandle[];
mermaidConfig?: MermaidConfig;
controls?: ControlsConfig;
isAnimating?: boolean;
Expand All @@ -54,6 +60,11 @@ export const defaultRemarkPlugins: Record<string, Pluggable> = {
math: [remarkMath, { singleDollarTextMath: false }],
} as const;

const defaultExtraIncompleteHandles = [
handleIncompleteStrikethrough,
handleIncompleteBlockKatex,
];

export const ShikiThemeContext = createContext<[BundledTheme, BundledTheme]>([
"github-light" as BundledTheme,
"github-dark" as BundledTheme,
Expand All @@ -76,17 +87,18 @@ export const StreamdownRuntimeContext =

type BlockProps = Options & {
content: string;
extraIncompleteHandles: IncompleteHandle[];
shouldParseIncompleteMarkdown: boolean;
};

const Block = memo(
({ content, shouldParseIncompleteMarkdown, ...props }: BlockProps) => {
({ content, shouldParseIncompleteMarkdown, extraIncompleteHandles, ...props }: BlockProps) => {
const parsedContent = useMemo(
() =>
typeof content === "string" && shouldParseIncompleteMarkdown
? parseIncompleteMarkdown(content.trim())
? parseIncompleteMarkdown(content.trim(), extraIncompleteHandles)
: content,
[content, shouldParseIncompleteMarkdown]
[content, shouldParseIncompleteMarkdown, extraIncompleteHandles]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Block component memo comparison only checks content but ignores extraIncompleteHandles, causing stale renders when extraIncompleteHandles changes.

View Details
📝 Patch Details
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index 8921f60..a7636b6 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -103,7 +103,10 @@ const Block = memo(
 
     return <ReactMarkdown {...props}>{parsedContent}</ReactMarkdown>;
   },
-  (prevProps, nextProps) => prevProps.content === nextProps.content
+  (prevProps, nextProps) => 
+    prevProps.content === nextProps.content && 
+    prevProps.shouldParseIncompleteMarkdown === nextProps.shouldParseIncompleteMarkdown &&
+    prevProps.extraIncompleteHandles === nextProps.extraIncompleteHandles
 );
 
 Block.displayName = "Block";
@@ -165,6 +168,7 @@ export const Streamdown = memo(
   (prevProps, nextProps) =>
     prevProps.children === nextProps.children &&
     prevProps.shikiTheme === nextProps.shikiTheme &&
-    prevProps.isAnimating === nextProps.isAnimating
+    prevProps.isAnimating === nextProps.isAnimating &&
+    prevProps.extraIncompleteHandles === nextProps.extraIncompleteHandles
 );
 Streamdown.displayName = "Streamdown";

Analysis

Block component memo comparison ignores extraIncompleteHandles causing stale renders

What fails: The Block component's React.memo comparison function only checks content prop, ignoring extraIncompleteHandles and shouldParseIncompleteMarkdown dependencies used in the component's useMemo hook.

How to reproduce:

  1. Render <Streamdown extraIncompleteHandles={[handler1]}>content</Streamdown>
  2. Re-render with different handlers: <Streamdown extraIncompleteHandles={[handler2]}>content</Streamdown>
  3. Same content, different handlers - component should re-render but doesn't

Result: Block component shows stale parsed content when extraIncompleteHandles changes, because memo comparison returns true (no re-render needed) even though the useMemo dependencies have changed.

Expected: Component should re-render when any useMemo dependency changes, producing different parsed output when handlers change.

Root cause: Both Streamdown and Block memo functions were incomplete:

  • Block memo only checked content but ignored extraIncompleteHandles and shouldParseIncompleteMarkdown
  • Streamdown memo only checked children, shikiTheme, and isAnimating but ignored extraIncompleteHandles

);

return <ReactMarkdown {...props}>{parsedContent}</ReactMarkdown>;
Expand All @@ -103,6 +115,7 @@ export const Streamdown = memo(
components,
rehypePlugins = Object.values(defaultRehypePlugins),
remarkPlugins = Object.values(defaultRemarkPlugins),
extraIncompleteHandles = defaultExtraIncompleteHandles,
className,
shikiTheme = ["github-light", "github-dark"],
mermaidConfig,
Expand Down Expand Up @@ -138,6 +151,7 @@ export const Streamdown = memo(
shouldParseIncompleteMarkdown={
shouldParseIncompleteMarkdown
}
extraIncompleteHandles={extraIncompleteHandles}
{...props}
/>
))}
Expand Down
36 changes: 28 additions & 8 deletions packages/streamdown/lib/parse-incomplete-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const singleAsteriskPattern = /(\*)([^*]*?)$/;
const singleUnderscorePattern = /(_)([^_]*?)$/;
const inlineCodePattern = /(`)([^`]*?)$/;
const strikethroughPattern = /(~~)([^~]*?)$/;
const inlineKatexPattern = /(\$)([^$]*?)$/;

export type IncompleteHandle = (text: string) => string;

// Helper function to check if we have a complete code block
const hasCompleteCodeBlock = (text: string): boolean => {
Expand Down Expand Up @@ -450,7 +453,7 @@ const handleIncompleteInlineCode = (text: string): string => {
};

// Completes incomplete strikethrough formatting (~~)
const handleIncompleteStrikethrough = (text: string): string => {
export const handleIncompleteStrikethrough = (text: string): string => {
const strikethroughMatch = text.match(strikethroughPattern);

if (strikethroughMatch) {
Expand All @@ -472,7 +475,7 @@ const handleIncompleteStrikethrough = (text: string): string => {
};

// Counts single dollar signs that are not part of double dollar signs and not escaped
const _countSingleDollarSigns = (text: string): number => {
const countSingleDollarSigns = (text: string): number => {
return text.split("").reduce((acc, char, index) => {
if (char === "$") {
const prevChar = text[index - 1];
Expand All @@ -489,8 +492,26 @@ const _countSingleDollarSigns = (text: string): number => {
}, 0);
};

export const handleIncompleteInlineKatex = (text: string): string => {
// Don't process if inside a complete code block
if (hasCompleteCodeBlock(text)) {
return text;
}

const inlineKatexMatch = text.match(inlineKatexPattern);

if (inlineKatexMatch) {
const singleDollars = countSingleDollarSigns(text);
if (singleDollars % 2 === 1) {
return `${text}$`;
}
}

return text;
};

// Completes incomplete block KaTeX formatting ($$)
const handleIncompleteBlockKatex = (text: string): string => {
export const handleIncompleteBlockKatex = (text: string): string => {
// Count all $$ pairs in the text
const dollarPairs = (text.match(/\$\$/g) || []).length;

Expand Down Expand Up @@ -565,7 +586,7 @@ const handleIncompleteBoldItalic = (text: string): string => {
};

// Parses markdown text and removes incomplete tokens to prevent partial rendering
export const parseIncompleteMarkdown = (text: string): string => {
export const parseIncompleteMarkdown = (text: string, extraIncompleteHandles: IncompleteHandle[] = []): string => {
if (!text || typeof text !== "string") {
return text;
}
Expand All @@ -591,11 +612,10 @@ export const parseIncompleteMarkdown = (text: string): string => {
result = handleIncompleteSingleAsteriskItalic(result);
result = handleIncompleteSingleUnderscoreItalic(result);
result = handleIncompleteInlineCode(result);
result = handleIncompleteStrikethrough(result);

// Handle KaTeX formatting (only block math with $$)
result = handleIncompleteBlockKatex(result);
// Note: We don't handle inline KaTeX with single $ as they're likely currency symbols
extraIncompleteHandles.forEach(handle => {
result = handle(result);
})

return result;
};