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(
+ <>
+
+
+ >
+ );
+ 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);
}