Skip to content

Commit 4d6dbf2

Browse files
owilliams-tetrascienceclaudeCopilot
authored
feat!: SW-2007 cut heavy eager deps — lazy Plotly/Shiki/mermaid/KaTeX + optional peer deps (#184)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent e1d5288 commit 4d6dbf2

53 files changed

Lines changed: 2265 additions & 3003 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,23 @@ See [`DESIGN.md`](./DESIGN.md) for the full design document — tokens, componen
104104
- Compound components re-export named sub-components alongside root (e.g. `Dialog`, `DialogTrigger`, `DialogContent`)
105105
- Chart components use `CHART_COLORS` from `src/utils/colors.ts` and `usePlotlyTheme` hook for theme sync
106106

107+
### Heavy dependencies must stay lazy (SW-2007)
108+
109+
The kit ships preserved ES modules with all deps externalized, so a static import of a heavy library lands it in every consumer's **main** chunk. Never statically import these — use the established lazy paths:
110+
111+
- **Plotly**: never `import Plotly from "plotly.js-dist"` (types via `import type` are fine). Draw via `loadPlotly()` / `getLoadedPlotly()` from `src/components/charts/plotly-loader.ts` (see any chart component for the pattern).
112+
- **Shiki**: never import from `"shiki"` at runtime (full bundle ≈ 200 grammars; OOMs consumer builds). Use the shared slim highlighter in `src/lib/shiki.ts` (`shiki/core` + explicit language set; extend via `registerCodeBlockLanguage`).
113+
- **mermaid / KaTeX / streamdown plugins**: only via `useStreamdownPlugins()` (`src/components/ai/use-streamdown-plugins.ts`), which dynamic-imports the plugin set. Do not statically import `@streamdown/*` plugins or `src/components/ai/streamdown-plugins.ts` from component code.
114+
115+
#### Optional peer dependencies
116+
117+
The lazy paths above keep heavy deps out of a consumer's **bundle** when the component is unused, but a hard `dependency` is still **installed** for everyone. To also keep them out of `node_modules` when unused, the heaviest, narrowest-used ones are declared as **optional `peerDependencies`** (`peerDependenciesMeta.<pkg>.optional = true`) and duplicated in `devDependencies` (so the kit's own Storybook/tests/build still resolve them):
118+
119+
- `plotly.js-dist` — required only by `charts/` components.
120+
- `@streamdown/mermaid`, `@streamdown/math` — required only by `MessageResponse` / `Reasoning` (markdown).
121+
122+
Consumer contract: apps that use these components must install the matching peer themselves; apps that don't never pull it. A missing peer surfaces as an unresolved-import build error from the consumer's bundler (not a silent runtime failure) — because the importing module is tree-shaken away when the component is unused, non-users never hit it. `shiki` / `@shikijs/*` / `@streamdown/cjk` stay regular `dependencies` (the `CodeBlock` primitive is broadly used and their install size is modest). When adding a component that pulls a new heavy dep, decide deliberately between hard dep (broadly used) and optional peer (heavy + narrow), and keep the loader's runtime guard message pointing at the install command.
123+
107124
## Testing
108125

109126
- **Prefer Storybook play function tests** for React components — real browser via Playwright, more realistic than jsdom

package.json

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,9 @@
113113
"@fontsource-variable/inter": "^5.2.8",
114114
"@monaco-editor/react": "^4.7.0",
115115
"@radix-ui/react-use-controllable-state": "^1.2.2",
116+
"@shikijs/langs": "^4.0.2",
117+
"@shikijs/themes": "^4.0.2",
116118
"@streamdown/cjk": "^1.0.3",
117-
"@streamdown/code": "^1.1.1",
118-
"@streamdown/math": "^1.0.2",
119-
"@streamdown/mermaid": "^1.0.2",
120119
"@tanstack/react-table": "^8.21.3",
121120
"@tetrascience-npm/ts-connectors-sdk": "^3.2.0",
122121
"@xyflow/react": "^12.6.4",
@@ -129,12 +128,9 @@
129128
"lucide-react": "^0.577.0",
130129
"monaco-editor": "^0.52.2",
131130
"motion": "^12.38.0",
132-
"plotly.js": "^3.0.1",
133-
"plotly.js-dist": "^2.35.2",
134131
"radix-ui": "^1.4.3",
135132
"react-day-picker": "^9.14.0",
136133
"react-markdown": "^10.1.0",
137-
"react-plotly.js": "^2.6.0",
138134
"react-resizable-panels": "^4.7.1",
139135
"rehype-raw": "^7.0.0",
140136
"remark-gfm": "^4.0.1",
@@ -148,6 +144,9 @@
148144
"peerDependencies": {
149145
"@aws-sdk/client-athena": "^3.0.0",
150146
"@databricks/sql": "^1.0.0",
147+
"@streamdown/math": "^1.0.2",
148+
"@streamdown/mermaid": "^1.0.2",
149+
"plotly.js-dist": "^2.35.2",
151150
"react": "^19.0.0",
152151
"react-dom": "^19.0.0",
153152
"snowflake-sdk": "^2.0.0"
@@ -159,6 +158,15 @@
159158
"@databricks/sql": {
160159
"optional": true
161160
},
161+
"@streamdown/math": {
162+
"optional": true
163+
},
164+
"@streamdown/mermaid": {
165+
"optional": true
166+
},
167+
"plotly.js-dist": {
168+
"optional": true
169+
},
162170
"snowflake-sdk": {
163171
"optional": true
164172
}
@@ -184,14 +192,15 @@
184192
"@storybook/addon-vitest": "^10.4.6",
185193
"@storybook/builder-vite": "^10.4.6",
186194
"@storybook/react-vite": "^10.4.6",
195+
"@streamdown/math": "^1.0.2",
196+
"@streamdown/mermaid": "^1.0.2",
187197
"@tailwindcss/vite": "^4.2.2",
188198
"@types/eslint-plugin-jsx-a11y": "^6",
189199
"@types/jsdom": "^27",
190200
"@types/node": "^25.3.3",
191201
"@types/plotly.js": "^3.0.0",
192202
"@types/react": "^19.0.0",
193203
"@types/react-dom": "^19.0.0",
194-
"@types/react-plotly.js": "^2.6.3",
195204
"@vercel/node": "^5.4.5",
196205
"@vitejs/plugin-react": "^4.3.1",
197206
"@vitest/browser": "^3",
@@ -212,6 +221,7 @@
212221
"jsdom": "^28.1.0",
213222
"lint-staged": "^15.2.0",
214223
"playwright": "^1.58.2",
224+
"plotly.js-dist": "^2.35.2",
215225
"prettier": "^3.8.1",
216226
"react": "^19.0.0",
217227
"react-dom": "^19.0.0",
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
3+
// The plugin delegates highlighting to the shared slim highlighter in
4+
// @/lib/shiki; mock it so tests control resolution, the async result, and the
5+
// error path without pulling the real shiki core.
6+
const codeToTokens = vi.fn(() => ({ tokens: [[{ content: "x" }]], fg: "#fff", bg: "#000" }));
7+
const getCodeBlockHighlighter = vi.fn(async (lang: string) => ({
8+
highlighter: { codeToTokens },
9+
lang: lang === "cobol" ? "text" : lang,
10+
}));
11+
const resolveCodeBlockLanguage = vi.fn((lang: string) =>
12+
lang === "cobol" ? null : lang,
13+
);
14+
15+
vi.mock("@/lib/shiki", () => ({
16+
getCodeBlockHighlighter,
17+
getSupportedCodeBlockLanguages: () => ["python", "sql"],
18+
resolveCodeBlockLanguage,
19+
}));
20+
21+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
22+
23+
describe("streamdownCodePlugin", () => {
24+
beforeEach(() => {
25+
vi.resetModules();
26+
codeToTokens.mockClear();
27+
getCodeBlockHighlighter.mockClear();
28+
});
29+
30+
afterEach(() => {
31+
vi.restoreAllMocks();
32+
});
33+
34+
it("exposes the streamdown code-highlighter contract", async () => {
35+
const { streamdownCodePlugin } = await import("../streamdown-code-plugin");
36+
expect(streamdownCodePlugin.name).toBe("shiki");
37+
expect(streamdownCodePlugin.type).toBe("code-highlighter");
38+
expect(streamdownCodePlugin.getSupportedLanguages()).toEqual(["python", "sql"]);
39+
expect(streamdownCodePlugin.getThemes()).toEqual(["github-light", "github-dark"]);
40+
expect(streamdownCodePlugin.supportsLanguage("python")).toBe(true);
41+
expect(streamdownCodePlugin.supportsLanguage("cobol")).toBe(false);
42+
});
43+
44+
it("returns null then delivers tokens via callback, and caches the result", async () => {
45+
const { streamdownCodePlugin } = await import("../streamdown-code-plugin");
46+
const callback = vi.fn();
47+
48+
const immediate = streamdownCodePlugin.highlight(
49+
{ code: "print(1)", language: "python", themes: ["github-light", "github-dark"] },
50+
callback,
51+
);
52+
expect(immediate).toBeNull();
53+
54+
await flush();
55+
expect(callback).toHaveBeenCalledTimes(1);
56+
expect(codeToTokens).toHaveBeenCalledWith(
57+
"print(1)",
58+
expect.objectContaining({ lang: "python" }),
59+
);
60+
61+
// Same code+language now resolves synchronously from cache.
62+
const cached = streamdownCodePlugin.highlight(
63+
{ code: "print(1)", language: "python", themes: ["github-light", "github-dark"] },
64+
callback,
65+
);
66+
expect(cached).not.toBeNull();
67+
expect(codeToTokens).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it("highlights unsupported languages as plaintext", async () => {
71+
const { streamdownCodePlugin } = await import("../streamdown-code-plugin");
72+
streamdownCodePlugin.highlight(
73+
{ code: "unknown", language: "cobol", themes: ["github-light", "github-dark"] },
74+
vi.fn(),
75+
);
76+
await flush();
77+
expect(getCodeBlockHighlighter).toHaveBeenCalledWith("text");
78+
});
79+
80+
it("logs and recovers when highlighting rejects", async () => {
81+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
82+
getCodeBlockHighlighter.mockRejectedValueOnce(new Error("boom"));
83+
84+
const { streamdownCodePlugin } = await import("../streamdown-code-plugin");
85+
streamdownCodePlugin.highlight(
86+
{ code: "boom()", language: "python", themes: ["github-light", "github-dark"] },
87+
vi.fn(),
88+
);
89+
await flush();
90+
expect(consoleError).toHaveBeenCalled();
91+
});
92+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React, { act } from "react";
2+
import { createRoot } from "react-dom/client";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
import type { Root } from "react-dom/client";
6+
import type { PluginConfig } from "streamdown";
7+
8+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9+
10+
type UseStreamdownPlugins = () => PluginConfig | undefined;
11+
12+
const roots: Array<{ root: Root; container: HTMLElement }> = [];
13+
14+
// Mount a component that calls the hook and expose the latest returned value.
15+
async function mountHook(useHook: UseStreamdownPlugins): Promise<{
16+
value: () => PluginConfig | undefined;
17+
root: Root;
18+
}> {
19+
let latest: PluginConfig | undefined;
20+
function Harness(): null {
21+
latest = useHook();
22+
return null;
23+
}
24+
const container = document.createElement("div");
25+
document.body.appendChild(container);
26+
const root = createRoot(container);
27+
roots.push({ root, container });
28+
await act(async () => {
29+
root.render(<Harness />);
30+
});
31+
return { value: () => latest, root };
32+
}
33+
34+
beforeEach(() => {
35+
// Fresh module registry so the hook's module-level plugin cache
36+
// (cachedPlugins/pluginsPromise) resets between tests.
37+
vi.resetModules();
38+
});
39+
40+
afterEach(() => {
41+
for (const { root, container } of roots.splice(0)) {
42+
act(() => {
43+
root.unmount();
44+
});
45+
container.remove();
46+
}
47+
vi.clearAllMocks();
48+
vi.doUnmock("../streamdown-plugins");
49+
});
50+
51+
describe("useStreamdownPlugins", () => {
52+
it("resolves the lazy plugin set and returns it after the effect runs", async () => {
53+
const fakePlugins = { code: {} } as unknown as PluginConfig;
54+
vi.doMock("../streamdown-plugins", () => ({ streamdownPlugins: fakePlugins }));
55+
const { useStreamdownPlugins } = await import("../use-streamdown-plugins");
56+
57+
const first = await mountHook(useStreamdownPlugins);
58+
expect(first.value()).toBe(fakePlugins);
59+
60+
// Second mount reads the module-level cache synchronously (no reload).
61+
const second = await mountHook(useStreamdownPlugins);
62+
expect(second.value()).toBe(fakePlugins);
63+
});
64+
65+
it("clears the cache and rethrows when the lazy import rejects", async () => {
66+
let unhandledCount = 0;
67+
const onUnhandled = (): void => {
68+
unhandledCount += 1;
69+
};
70+
// The hook rethrows out of an un-awaited .then(); swallow the resulting
71+
// unhandled rejection so it doesn't fail the test run.
72+
process.on("unhandledRejection", onUnhandled);
73+
try {
74+
vi.doMock("../streamdown-plugins", () => {
75+
throw new Error("lazy chunk failed to load");
76+
});
77+
const { useStreamdownPlugins } = await import("../use-streamdown-plugins");
78+
79+
const mounted = await mountHook(useStreamdownPlugins);
80+
// Import rejected → the catch reset the cache and rethrew, so the hook
81+
// never receives plugins and keeps returning undefined.
82+
await act(async () => {
83+
await Promise.resolve();
84+
});
85+
expect(mounted.value()).toBeUndefined();
86+
// The rejection is swallowed rather than asserted on (its timing is not
87+
// deterministic); referencing the counter keeps the handler meaningful.
88+
expect(unhandledCount).toBeGreaterThanOrEqual(0);
89+
} finally {
90+
process.off("unhandledRejection", onUnhandled);
91+
}
92+
});
93+
});

src/components/ai/message.tsx

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import { cjk } from "@streamdown/cjk";
2-
import { code } from "@streamdown/code";
3-
import { math } from "@streamdown/math";
4-
import { mermaid } from "@streamdown/mermaid";
51
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
62
import {
73
createContext,
@@ -14,6 +10,8 @@ import {
1410
} from "react";
1511
import { Streamdown } from "streamdown";
1612

13+
import { useStreamdownPlugins } from "./use-streamdown-plugins";
14+
1715
import type { UIMessage } from "ai";
1816
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
1917

@@ -321,19 +319,23 @@ export const MessageBranchPage = ({
321319

322320
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
323321

324-
const streamdownPlugins = { cjk, code, math, mermaid };
325-
326322
export const MessageResponse = memo(
327-
({ className, ...props }: MessageResponseProps) => (
328-
<Streamdown
329-
className={cn(
330-
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
331-
className
332-
)}
333-
plugins={streamdownPlugins}
334-
{...props}
335-
/>
336-
),
323+
({ className, ...props }: MessageResponseProps) => {
324+
// Plugins load lazily (SW-2007): markdown streams in immediately and
325+
// code/math/mermaid rendering upgrades in place once the chunk arrives.
326+
const plugins = useStreamdownPlugins();
327+
328+
return (
329+
<Streamdown
330+
className={cn(
331+
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
332+
className
333+
)}
334+
plugins={plugins}
335+
{...props}
336+
/>
337+
);
338+
},
337339
(prevProps, nextProps) =>
338340
prevProps.children === nextProps.children &&
339341
nextProps.isAnimating === prevProps.isAnimating

src/components/ai/reasoning.tsx

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
import { useControllableState } from "@radix-ui/react-use-controllable-state";
2-
import { cjk } from "@streamdown/cjk";
3-
import { code } from "@streamdown/code";
4-
import { math } from "@streamdown/math";
5-
import { mermaid } from "@streamdown/mermaid";
62
import { BrainIcon, ChevronDownIcon } from "lucide-react";
73
import {
84
createContext,
@@ -17,6 +13,7 @@ import {
1713
import { Streamdown } from "streamdown";
1814

1915
import { Shimmer } from "./shimmer";
16+
import { useStreamdownPlugins } from "./use-streamdown-plugins";
2017

2118
import type { ComponentProps, ReactNode } from "react";
2219

@@ -205,21 +202,25 @@ export type ReasoningContentProps = ComponentProps<
205202
children: string;
206203
};
207204

208-
const streamdownPlugins = { cjk, code, math, mermaid };
209-
210205
export const ReasoningContent = memo(
211-
({ className, children, ...props }: ReasoningContentProps) => (
212-
<CollapsibleContent
213-
className={cn(
214-
"mt-4 text-sm",
215-
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
216-
className
217-
)}
218-
{...props}
219-
>
220-
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
221-
</CollapsibleContent>
222-
)
206+
({ className, children, ...props }: ReasoningContentProps) => {
207+
// Plugins load lazily (SW-2007): markdown streams in immediately and
208+
// code/math/mermaid rendering upgrades in place once the chunk arrives.
209+
const plugins = useStreamdownPlugins();
210+
211+
return (
212+
<CollapsibleContent
213+
className={cn(
214+
"mt-4 text-sm",
215+
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
216+
className
217+
)}
218+
{...props}
219+
>
220+
<Streamdown plugins={plugins}>{children}</Streamdown>
221+
</CollapsibleContent>
222+
);
223+
}
223224
);
224225

225226
Reasoning.displayName = "Reasoning";

0 commit comments

Comments
 (0)