From 7ba56c24800f56643d406448e1551c575cae1e58 Mon Sep 17 00:00:00 2001 From: Claire-ovo <132752951+Claire-ovo@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:48:26 +0800 Subject: [PATCH] fix: isolate processor caches by plugin function identity --- .changeset/quiet-plugins-render.md | 5 ++ .../__tests__/plugin-cache.test.tsx | 89 +++++++++++++++++++ packages/streamdown/lib/markdown.ts | 32 +++---- 3 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 .changeset/quiet-plugins-render.md create mode 100644 packages/streamdown/__tests__/plugin-cache.test.tsx diff --git a/.changeset/quiet-plugins-render.md b/.changeset/quiet-plugins-render.md new file mode 100644 index 000000000..5ada25f2f --- /dev/null +++ b/.changeset/quiet-plugins-render.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +Distinguish remark and rehype plugins by function identity when caching processors, so same-name plugins with different closures do not reuse each other's behavior. diff --git a/packages/streamdown/__tests__/plugin-cache.test.tsx b/packages/streamdown/__tests__/plugin-cache.test.tsx new file mode 100644 index 000000000..ccaf70244 --- /dev/null +++ b/packages/streamdown/__tests__/plugin-cache.test.tsx @@ -0,0 +1,89 @@ +import { render } from "@testing-library/react"; +import type { Root } from "hast"; +import type { PluggableList, Plugin } from "unified"; +import { visit } from "unist-util-visit"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; +import { Markdown } from "../lib/markdown"; + +const createPlugin = (value: string): Plugin<[{ suffix?: string }?], Root> => + function replaceText(options) { + return (tree) => { + visit(tree, "text", (node) => { + node.value = value + (options?.suffix ?? ""); + }); + }; + }; + +describe("processor plugin identity", () => { + for (const stage of ["remarkPlugins", "rehypePlugins"] as const) { + for (const tuple of [false, true]) { + it(`isolates same-name ${stage} with tuple=${tuple}`, () => { + const first = createPlugin("first"); + const second = createPlugin("second"); + expect(first.name).toBe(second.name); + const plugins = (plugin: typeof first): PluggableList => + tuple ? [[plugin, { suffix: "!" }]] : [plugin]; + const { container, rerender } = render( + input + ); + expect(container.textContent).toBe(tuple ? "first!" : "first"); + rerender(input); + expect(container.textContent).toBe(tuple ? "second!" : "second"); + rerender(input); + expect(container.textContent).toBe(tuple ? "first!" : "first"); + }); + } + } + + it("reuses the same function across new plugin arrays", () => { + let setups = 0; + const plugin: Plugin = () => { + setups += 1; + }; + const { rerender } = render( + first + ); + rerender(second); + expect(setups).toBe(1); + }); + + it("keeps options distinct for the same function", () => { + const plugin = createPlugin("text"); + const { container, rerender } = render( + input + ); + expect(container.textContent).toBe("text1"); + rerender( + input + ); + expect(container.textContent).toBe("text2"); + }); + + for (const mode of ["static", "streaming"] as const) { + it(`isolates simultaneous Streamdown instances in ${mode} mode`, () => { + const first = createPlugin("left"); + const second = createPlugin("right"); + const { container } = render( + <> +
+ + input + +
+
+ + input + +
+ + ); + expect(container.querySelector('[data-testid="left"]')?.textContent).toBe( + "left" + ); + expect( + container.querySelector('[data-testid="right"]')?.textContent + ).toBe("right"); + }); + } +}); diff --git a/packages/streamdown/lib/markdown.ts b/packages/streamdown/lib/markdown.ts index a6d4a610f..f15843ce4 100644 --- a/packages/streamdown/lib/markdown.ts +++ b/packages/streamdown/lib/markdown.ts @@ -58,9 +58,22 @@ export interface Options { const EMPTY_PLUGINS: PluggableList = []; const DEFAULT_REMARK_REHYPE_OPTIONS = { allowDangerousHtml: true }; -// Plugin name cache for faster serialization +// Function names do not distinguish closures or minified plugins. // biome-ignore lint/complexity/noBannedTypes: "Need Function type for plugin caching" -const pluginNameCache = new WeakMap(); +const pluginIds = new WeakMap(); +let nextPluginId = 0; + +// biome-ignore lint/complexity/noBannedTypes: "Need Function type for plugin caching" +const getPluginId = (plugin: Function): number => { + const cached = pluginIds.get(plugin); + if (cached !== undefined) { + return cached; + } + const id = nextPluginId; + nextPluginId += 1; + pluginIds.set(plugin, id); + return id; +}; // LRU Cache for unified processors class ProcessorCache { @@ -88,7 +101,6 @@ class ProcessorCache { } // Optimize serialization for plugins - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: "Plugin serialization requires checking multiple plugin formats" const serializePlugins = (plugins: PluggableList | undefined): string => { if (!plugins || plugins.length === 0) { return ""; @@ -105,12 +117,7 @@ class ProcessorCache { // Plugin with options: [plugin, options] const [pluginFn, pluginOptions] = plugin; if (typeof pluginFn === "function") { - let name = pluginNameCache.get(pluginFn); - if (!name) { - name = pluginFn.name; - pluginNameCache.set(pluginFn, name); - } - result += name; + result += getPluginId(pluginFn); } else { result += String(pluginFn); } @@ -118,12 +125,7 @@ class ProcessorCache { result += JSON.stringify(pluginOptions); } else if (typeof plugin === "function") { // Plugin without options - let name = pluginNameCache.get(plugin); - if (!name) { - name = plugin.name; - pluginNameCache.set(plugin, name); - } - result += name; + result += getPluginId(plugin); } else { result += String(plugin); }