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/scrollable-code-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": minor
---

Add codeBlockMaxHeight and tableMaxHeight props with streaming auto-scroll
6 changes: 6 additions & 0 deletions packages/streamdown/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ beforeEach(() => {
} as unknown as typeof IntersectionObserver;
});

// Mock scrollTo for jsdom (not implemented by default)
beforeEach(() => {
// biome-ignore lint/suspicious/noEmptyBlockStatements: intentional noop for jsdom mock
window.HTMLElement.prototype.scrollTo = () => {};
});

// Cleanup after each test
afterEach(() => {
cleanup();
Expand Down
181 changes: 181 additions & 0 deletions packages/streamdown/__tests__/table-scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { StreamdownContext, type StreamdownContextType } from "../index";
import { Table } from "../lib/table";

const defaultContext: StreamdownContextType = {
codeBlockMaxHeight: 400,
controls: false,
isAnimating: false,
linkSafety: { enabled: true },
mermaid: undefined,
mode: "streaming",
shikiTheme: ["github-light", "github-dark"],
tableMaxHeight: 300,
};

function renderWithContext(
ui: ReactNode,
ctx: Partial<StreamdownContextType> = {}
) {
return render(
<StreamdownContext.Provider value={{ ...defaultContext, ...ctx }}>
{ui}
</StreamdownContext.Provider>
);
}

describe("Table scroll", () => {
it("renders inner scroll div with maxHeight style when maxHeight provided", () => {
const { container } = renderWithContext(
<Table maxHeight={300}>
<tbody>
<tr>
<td>cell</td>
</tr>
</tbody>
</Table>
);

const scrollDiv = container.querySelector(
'[data-streamdown="table-wrapper"] > div:last-child'
);
expect(scrollDiv).toBeTruthy();
expect(scrollDiv?.getAttribute("style")).toContain("max-height");
});

it("accepts string maxHeight value", () => {
const { container } = renderWithContext(
<Table maxHeight="50vh">
<tbody>
<tr>
<td>cell</td>
</tr>
</tbody>
</Table>
);

const scrollDiv = container.querySelector(
'[data-streamdown="table-wrapper"] > div:last-child'
);
expect(scrollDiv?.getAttribute("style")).toContain("50vh");
});

it("does not set maxHeight style when maxHeight is undefined", () => {
const { container } = renderWithContext(
<Table>
<tbody>
<tr>
<td>cell</td>
</tr>
</tbody>
</Table>
);

const scrollDiv = container.querySelector(
'[data-streamdown="table-wrapper"] > div:last-child'
);
expect(scrollDiv?.getAttribute("style") ?? "").not.toContain("max-height");
});

it("calls scrollTo on children update when isAnimating and pinned", () => {
const scrollToSpy = vi.fn();

const { container, rerender } = renderWithContext(
<Table maxHeight={300}>
<tbody>
<tr>
<td>row1</td>
</tr>
</tbody>
</Table>,
{ isAnimating: true }
);

const scrollDiv = container.querySelector(
'[data-streamdown="table-wrapper"] > div:last-child'
) as HTMLElement;

Object.defineProperty(scrollDiv, "scrollHeight", {
value: 1000,
configurable: true,
});
Object.defineProperty(scrollDiv, "clientHeight", {
value: 300,
configurable: true,
});
Object.defineProperty(scrollDiv, "scrollTop", {
value: 700,
configurable: true,
writable: true,
});
scrollDiv.scrollTo = scrollToSpy;

rerender(
<StreamdownContext.Provider
value={{ ...defaultContext, isAnimating: true }}
>
<Table maxHeight={300}>
<tbody>
<tr>
<td>row1</td>
</tr>
<tr>
<td>row2</td>
</tr>
</tbody>
</Table>
</StreamdownContext.Provider>
);

expect(scrollToSpy).toHaveBeenCalledWith({
top: expect.any(Number),
behavior: "instant",
});
});

it("does not call scrollTo when isAnimating is false", () => {
const scrollToSpy = vi.fn();

const { container, rerender } = renderWithContext(
<Table maxHeight={300}>
<tbody>
<tr>
<td>row1</td>
</tr>
</tbody>
</Table>,
{ isAnimating: false }
);

const scrollDiv = container.querySelector(
'[data-streamdown="table-wrapper"] > div:last-child'
) as HTMLElement;

Object.defineProperty(scrollDiv, "scrollHeight", {
value: 1000,
configurable: true,
});
scrollDiv.scrollTo = scrollToSpy;

rerender(
<StreamdownContext.Provider
value={{ ...defaultContext, isAnimating: false }}
>
<Table maxHeight={300}>
<tbody>
<tr>
<td>row1</td>
</tr>
<tr>
<td>row2</td>
</tr>
</tbody>
</Table>
</StreamdownContext.Provider>
);

expect(scrollToSpy).not.toHaveBeenCalled();
});
});
12 changes: 12 additions & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,10 @@ export type StreamdownProps = Options & {
className?: string;
shikiTheme?: [ThemeInput, ThemeInput];
mermaid?: MermaidOptions;
codeBlockMaxHeight?: number | string;
controls?: ControlsConfig;
isAnimating?: boolean;
tableMaxHeight?: number | string;
animated?: boolean | AnimateOptions;
caret?: keyof typeof carets;
plugins?: PluginConfig;
Expand Down Expand Up @@ -278,6 +280,7 @@ const carets = {

// Combined context for better performance - reduces React tree depth from 5 nested providers to 1
export interface StreamdownContextType {
codeBlockMaxHeight: number | string;
controls: ControlsConfig;
isAnimating: boolean;
/** Show line numbers in code blocks. @default true */
Expand All @@ -286,6 +289,7 @@ export interface StreamdownContextType {
mermaid?: MermaidOptions;
mode: "static" | "streaming";
shikiTheme: [ThemeInput, ThemeInput];
tableMaxHeight: number | string;
}

const defaultShikiTheme: [ThemeInput, ThemeInput] = [
Expand All @@ -298,13 +302,15 @@ const defaultLinkSafetyConfig: LinkSafetyConfig = {
};

const defaultStreamdownContext: StreamdownContextType = {
codeBlockMaxHeight: 400,
shikiTheme: defaultShikiTheme,
controls: true,
isAnimating: false,
lineNumbers: true,
mode: "streaming",
mermaid: undefined,
linkSafety: defaultLinkSafetyConfig,
tableMaxHeight: 300,
};

export const StreamdownContext = createContext<StreamdownContextType>(
Expand Down Expand Up @@ -440,8 +446,10 @@ export const Streamdown = memo(
className,
shikiTheme = defaultShikiTheme,
mermaid,
codeBlockMaxHeight = 400,
controls = true,
isAnimating = false,
tableMaxHeight = 300,
animated,
BlockComponent = Block,
parseMarkdownIntoBlocksFn = parseMarkdownIntoBlocks,
Expand Down Expand Up @@ -609,15 +617,18 @@ export const Streamdown = memo(
// Combined context value - single object reduces React tree overhead
const contextValue = useMemo<StreamdownContextType>(
() => ({
codeBlockMaxHeight,
shikiTheme: plugins?.code?.getThemes() ?? shikiTheme,
controls,
isAnimating,
lineNumbers,
mode,
mermaid,
linkSafety,
tableMaxHeight,
}),
[
codeBlockMaxHeight,
shikiTheme,
controls,
isAnimating,
Expand All @@ -626,6 +637,7 @@ export const Streamdown = memo(
mermaid,
linkSafety,
plugins?.code,
tableMaxHeight,
]
);

Expand Down
55 changes: 53 additions & 2 deletions packages/streamdown/lib/code-block/body.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { type ComponentProps, type CSSProperties, memo, useMemo } from "react";
import {
type ComponentProps,
type CSSProperties,
memo,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
import { StreamdownContext } from "../../index";
import type { HighlightResult } from "../plugin-types";
import { useCn } from "../prefix-context";
import { cn as baseCn } from "../utils";

type CodeBlockBodyProps = ComponentProps<"div"> & {
maxHeight?: number | string;
result: HighlightResult;
language: string;
startLine?: number;
Expand Down Expand Up @@ -51,11 +61,48 @@ export const CodeBlockBody = memo(
result,
language,
className,
maxHeight,
startLine,
lineNumbers = true,
...rest
}: CodeBlockBodyProps) => {
const cn = useCn();
const { isAnimating } = useContext(StreamdownContext);
const scrollRef = useRef<HTMLDivElement>(null);
const pinnedRef = useRef<boolean>(true);

let maxHeightStyle: string | undefined;
if (maxHeight !== undefined) {
maxHeightStyle =
typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight;
}

useEffect(() => {
const el = scrollRef.current;
if (!(el && maxHeightStyle)) {
return;
}
const handleScroll = () => {
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8;
pinnedRef.current = atBottom;
};
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [maxHeightStyle]);

useEffect(() => {
const el = scrollRef.current;
if (!(el && maxHeightStyle && isAnimating && pinnedRef.current)) {
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Auto-scroll useEffect in CodeBlockBody and Table components has incomplete dependency arrays, causing scroll-to-bottom to only fire once during streaming instead of on each content update.

Fix on Vercel

}
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
}, [isAnimating, maxHeightStyle]);

useEffect(() => {
if (!isAnimating) {
pinnedRef.current = true;
}
}, [isAnimating]);

// Prefix the pre-computed line number classes
const lineNumberClasses = useMemo(() => cn(LINE_NUMBER_CLASSES_BASE), [cn]);
Expand Down Expand Up @@ -85,10 +132,13 @@ export const CodeBlockBody = memo(
<div
className={cn(
className,
"overflow-x-auto rounded-md border border-border bg-background p-4 text-sm"
maxHeightStyle ? "overflow-y-auto" : "overflow-hidden",
"rounded-md border border-border bg-background p-4 text-sm"
)}
data-language={language}
data-streamdown="code-block-body"
ref={scrollRef}
style={maxHeightStyle ? { maxHeight: maxHeightStyle } : undefined}
{...rest}
>
<pre
Expand Down Expand Up @@ -180,6 +230,7 @@ export const CodeBlockBody = memo(
(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 &&
Expand Down
3 changes: 3 additions & 0 deletions packages/streamdown/lib/code-block/highlighted-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CodeBlockBody } from "./body";
type HighlightedCodeBlockBodyProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
maxHeight?: number | string;
raw: HighlightResult;
startLine?: number;
lineNumbers?: boolean;
Expand All @@ -16,6 +17,7 @@ type HighlightedCodeBlockBodyProps = HTMLAttributes<HTMLDivElement> & {
export const HighlightedCodeBlockBody = ({
code,
language,
maxHeight,
raw,
className,
startLine,
Expand Down Expand Up @@ -53,6 +55,7 @@ export const HighlightedCodeBlockBody = ({
className={className}
language={language}
lineNumbers={lineNumbers}
maxHeight={maxHeight}
result={result}
startLine={startLine}
{...rest}
Expand Down
Loading
Loading