1import { cleanup, render, screen } from "@testing-library/react";
2import projectReadme from "../readme.md?raw";
3import type { Components } from "rehype-react";
4import type { ComponentPropsWithoutRef, JSX } from "react";
5import { useRef } from "react";
6import remarkGfm from "remark-gfm";
7import remarkParse from "remark-parse";
8import { unified } from "unified";
9import { afterEach, expect, it, vi } from "vitest";
10import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts";
11import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts";
12
13type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & {
14 node?: unknown;
15};
16
17const renderOptions = { reactStrictMode: false } as const;
18const strictRenderOptions = { reactStrictMode: true } as const;
19const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor;
20
21afterEach(() => {
22 cleanup();
23 vi.restoreAllMocks();
24});
25
26function omitNode<Props extends { node?: unknown }>(props: Props): Omit<Props, "node"> {
27 const { node, ...rest } = props;
28 void node;
29 return rest;
30}
31
32it("skips Markdown rerenders for semantically equal props, then wakes back up when content changes", () => {
33 const updateSpy = vi.spyOn(Memoizer.prototype, "update");
34 const Paragraph = vi.fn(function Paragraph(props: MarkdownProps<"p">) {
35 return <p data-testid="paragraph" {...omitNode(props)} />;
36 });
37 const components = { p: Paragraph } satisfies Partial<Components>;
38
39 const { rerender } = render(<Markdown content="hello" components={components} />, renderOptions);
40
41 expect(updateSpy).toHaveBeenCalledTimes(1);
42 expect(Paragraph).toHaveBeenCalledTimes(1);
43
44 rerender(<Markdown content="hello" components={components} />);
45 rerender(<Markdown content="hello" components={{ p: Paragraph }} />);
46
47 expect(updateSpy).toHaveBeenCalledTimes(1);
48 expect(Paragraph).toHaveBeenCalledTimes(1);
49 expect(screen.getByTestId("paragraph").textContent).toBe("hello");
50
51 rerender(<Markdown content="hello!" components={{ p: Paragraph }} />);
52
53 expect(updateSpy).toHaveBeenCalledTimes(2);
54 expect(Paragraph).toHaveBeenCalledTimes(2);
55 expect(screen.getByTestId("paragraph").textContent).toBe("hello!");
56});
57
58it("keeps an unchanged inline custom renderer asleep when only another block changes", () => {
59 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
60 return <a data-testid="link" {...omitNode(props)} />;
61 });
62 const components = { a: Link } satisfies Partial<Components>;
63
64 const { rerender } = render(
65 <Markdown content={"first block\n\n[stable](https://example.com)"} components={components} />,
66 renderOptions,
67 );
68
69 const link = screen.getByTestId("link");
70 expect(Link).toHaveBeenCalledTimes(1);
71
72 rerender(
73 <Markdown
74 content={"first block changed\n\n[stable](https://example.com)"}
75 components={components}
76 />,
77 );
78 rerender(
79 <Markdown
80 content={"first block changed again\n\n[stable](https://example.com)"}
81 components={components}
82 />,
83 );
84
85 expect(Link).toHaveBeenCalledTimes(1);
86 expect(screen.getByTestId("link")).toBe(link);
87 expect(screen.getByTestId("link").textContent).toBe("stable");
88});
89
90it("does not rerender an unchanged inline custom renderer when only adjacent text changes", () => {
91 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
92 return <a data-testid="link" {...omitNode(props)} />;
93 });
94 const components = { a: Link } satisfies Partial<Components>;
95
96 const { rerender } = render(
97 <Markdown content={"alpha [stable](https://example.com) omega"} components={components} />,
98 renderOptions,
99 );
100
101 const link = screen.getByTestId("link");
102 expect(Link).toHaveBeenCalledTimes(1);
103
104 rerender(
105 <Markdown content={"alpha! [stable](https://example.com) omega"} components={components} />,
106 );
107 expect(screen.getByTestId("link")).toBe(link);
108 expect(Link).toHaveBeenCalledTimes(1);
109
110 rerender(
111 <Markdown content={"alpha! [stable](https://example.com) omega?"} components={components} />,
112 );
113 expect(screen.getByTestId("link")).toBe(link);
114 expect(Link).toHaveBeenCalledTimes(1);
115});
116
117it("does not rerender an unchanged inline custom renderer with formatted children when only adjacent text changes", () => {
118 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
119 return <a data-testid="rich-link" {...omitNode(props)} />;
120 });
121 const components = { a: Link } satisfies Partial<Components>;
122
123 const { rerender } = render(
124 <Markdown content={"alpha [**stable**](https://example.com) omega"} components={components} />,
125 renderOptions,
126 );
127
128 const link = screen.getByTestId("rich-link");
129 expect(Link).toHaveBeenCalledTimes(1);
130
131 rerender(
132 <Markdown content={"alpha! [**stable**](https://example.com) omega"} components={components} />,
133 );
134
135 expect(screen.getByTestId("rich-link")).toBe(link);
136 expect(Link).toHaveBeenCalledTimes(1);
137});
138
139it("keeps an unchanged strong renderer asleep when surrounding paragraph text changes", () => {
140 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
141 return <strong data-testid="strong" {...omitNode(props)} />;
142 });
143 const components = { strong: Strong } satisfies Partial<Components>;
144
145 const { rerender } = render(
146 <Markdown content={"alpha **stable** omega"} components={components} />,
147 renderOptions,
148 );
149
150 const strong = screen.getByTestId("strong");
151 expect(Strong).toHaveBeenCalledTimes(1);
152
153 rerender(<Markdown content={"alpha! **stable** omega"} components={components} />);
154 rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />);
155
156 expect(screen.getByTestId("strong")).toBe(strong);
157 expect(Strong).toHaveBeenCalledTimes(1);
158});
159
160it("keeps an unchanged strong renderer asleep under Strict Mode when surrounding text changes", () => {
161 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
162 return <strong data-testid="strict-strong" {...omitNode(props)} />;
163 });
164 const components = { strong: Strong } satisfies Partial<Components>;
165
166 const { rerender } = render(
167 <Markdown content={"alpha **stable** omega"} components={components} />,
168 strictRenderOptions,
169 );
170
171 const strong = screen.getByTestId("strict-strong");
172 const initialCalls = Strong.mock.calls.length;
173
174 rerender(<Markdown content={"alpha! **stable** omega"} components={components} />);
175 rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />);
176
177 expect(screen.getByTestId("strict-strong")).toBe(strong);
178 expect(Strong.mock.calls.length).toBe(initialCalls);
179});
180
181it("keeps an unchanged list item renderer asleep when a sibling item changes", () => {
182 const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) {
183 return <li data-testid="list-item" {...omitNode(props)} />;
184 });
185 const components = { li: ListItem } satisfies Partial<Components>;
186
187 const { rerender } = render(
188 <Markdown content={"- first\n- stable\n- third"} components={components} />,
189 renderOptions,
190 );
191
192 const stableItem = screen.getByText("stable").closest("li");
193 expect(stableItem).not.toBeNull();
194 expect(ListItem).toHaveBeenCalledTimes(3);
195
196 rerender(<Markdown content={"- first changed\n- stable\n- third"} components={components} />);
197
198 expect(screen.getByText("stable").closest("li")).toBe(stableItem);
199 expect(ListItem).toHaveBeenCalledTimes(4);
200});
201
202it("keeps an unchanged table cell renderer asleep when another cell changes", () => {
203 const TableCell = vi.fn(function TableCell(props: MarkdownProps<"td">) {
204 return <td data-testid="table-cell" {...omitNode(props)} />;
205 });
206 const components = { td: TableCell } satisfies Partial<Components>;
207
208 const { rerender } = render(
209 <Markdown
210 components={components}
211 content={"| left | stable |\n| --- | --- |\n| one | keep |"}
212 processor={gfmProcessor}
213 />,
214 renderOptions,
215 );
216
217 const stableCell = screen.getByText("keep").closest("td");
218 expect(stableCell).not.toBeNull();
219 expect(TableCell).toHaveBeenCalledTimes(2);
220
221 rerender(
222 <Markdown
223 components={components}
224 content={"| left | stable |\n| --- | --- |\n| one! | keep |"}
225 processor={gfmProcessor}
226 />,
227 );
228
229 expect(screen.getByText("keep").closest("td")).toBe(stableCell);
230 expect(TableCell).toHaveBeenCalledTimes(3);
231});
232
233it("does not rerender Markdown through the provider when the effective components stay the same", () => {
234 const updateSpy = vi.spyOn(Memoizer.prototype, "update");
235 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
236 return <a data-testid="provider-link" {...omitNode(props)} />;
237 });
238
239 function WrappedMarkdown({ components }: { components: Partial<Components> }) {
240 return (
241 <MarkdownOptionsProvider components={components}>
242 <Markdown content="[stable](https://example.com)" />
243 </MarkdownOptionsProvider>
244 );
245 }
246
247 const { rerender } = render(<WrappedMarkdown components={{ a: Link }} />, renderOptions);
248
249 expect(updateSpy).toHaveBeenCalledTimes(1);
250 expect(Link).toHaveBeenCalledTimes(1);
251
252 rerender(<WrappedMarkdown components={{ a: Link }} />);
253
254 expect(updateSpy).toHaveBeenCalledTimes(1);
255 expect(Link).toHaveBeenCalledTimes(1);
256 expect(screen.getByTestId("provider-link").textContent).toBe("stable");
257});
258
259it("reprocesses the document when prediction changes", () => {
260 const { container, rerender } = render(
261 <Markdown content="hello *world" predict={false} />,
262 renderOptions,
263 );
264
265 expect(container.querySelector("em")).toBeNull();
266 expect(container.textContent).toBe("hello *world");
267
268 rerender(<Markdown content="hello *world" predict />);
269
270 expect(container.querySelector("em")?.textContent).toBe("world");
271 expect(container.textContent).toBe("hello world");
272});
273
274it.each([
275 {
276 content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"],
277 name: "paragraph merging edits",
278 predict: false,
279 processor: undefined,
280 },
281 {
282 content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]"],
283 name: "reference link definition edits",
284 predict: false,
285 processor: undefined,
286 },
287 {
288 content: [
289 "| A | B |\n| --- | --- |\n| one | two |",
290 "| A | B |\n| --- | --- |\n| one! | two |",
291 "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |",
292 ],
293 name: "gfm table edits",
294 predict: false,
295 processor: gfmProcessor,
296 },
297 {
298 content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"],
299 name: "prediction edits",
300 predict: true,
301 processor: undefined,
302 },
303])("matches a fresh render across $name", ({ content, predict, processor }) => {
304 const first = content[0]!;
305 const { container, rerender } = render(
306 <Markdown content={first} predict={predict} processor={processor} />,
307 renderOptions,
308 );
309
310 expect(container.innerHTML).toBe(renderFreshHtml(first, predict, processor));
311
312 for (const step of content.slice(1)) {
313 rerender(<Markdown content={step} predict={predict} processor={processor} />);
314 expect(container.innerHTML).toBe(renderFreshHtml(step, predict, processor));
315 }
316});
317
318it("keeps a stable block tied to its own DOM node when blocks are inserted or removed above it", () => {
319 function Link(props: MarkdownProps<"a">) {
320 const instanceRef = useRef(Symbol("instance"));
321 return <a data-instance={String(instanceRef.current)} {...omitNode(props)} />;
322 }
323
324 const components = { a: Link } satisfies Partial<Components>;
325 const { rerender } = render(
326 <Markdown content={"before\n\n[stable](https://example.com)"} components={components} />,
327 renderOptions,
328 );
329
330 const stableLink = screen.getByRole("link");
331
332 rerender(
333 <Markdown
334 content={"intro\n\nbefore\n\n[stable](https://example.com)"}
335 components={components}
336 />,
337 );
338 expect(screen.getByRole("link")).toBe(stableLink);
339
340 rerender(<Markdown content={"intro\n\n[stable](https://example.com)"} components={components} />);
341 expect(screen.getByRole("link")).toBe(stableLink);
342});
343
344it("keeps an unchanged list item tied to its DOM node when a sibling item is inserted above it", () => {
345 function ListItem(props: MarkdownProps<"li">) {
346 const instanceRef = useRef(Symbol("instance"));
347 return <li data-instance={String(instanceRef.current)} {...omitNode(props)} />;
348 }
349
350 const components = { li: ListItem } satisfies Partial<Components>;
351 const { rerender } = render(
352 <Markdown content={"- stable\n- tail"} components={components} />,
353 renderOptions,
354 );
355
356 const stableItem = screen.getByText("stable").closest("li");
357 expect(stableItem).not.toBeNull();
358
359 rerender(<Markdown content={"- inserted\n- stable\n- tail"} components={components} />);
360
361 expect(screen.getByText("stable").closest("li")).toBe(stableItem);
362});
363
364it("keeps demo README strong renderers asleep when editing a different block", () => {
365 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
366 return <strong data-testid="readme-strong" {...omitNode(props)} />;
367 });
368 const components = { strong: Strong } satisfies Partial<Components>;
369
370 const original = projectReadme;
371 const edited = projectReadme.replace(
372 "This package exports a React component to render Markdown using the [unified]",
373 "This package exports a React component to render memoized Markdown using the [unified]",
374 );
375
376 const { rerender } = render(
377 <Markdown components={components} content={original} processor={gfmProcessor} />,
378 renderOptions,
379 );
380
381 const stableStrong = screen.getByText("Bring your Existing Pipeline").closest("strong");
382 expect(stableStrong).not.toBeNull();
383
384 const strongCalls = Strong.mock.calls.length;
385
386 rerender(<Markdown components={components} content={edited} processor={gfmProcessor} />);
387
388 expect(screen.getByText("Bring your Existing Pipeline").closest("strong")).toBe(stableStrong);
389 expect(Strong.mock.calls.length).toBe(strongCalls);
390});
391
392function renderFreshHtml(
393 content: string,
394 predict: boolean | undefined,
395 processor: BaseProcessor | undefined,
396) {
397 const { container, unmount } = render(
398 <Markdown content={content} predict={predict} processor={processor} />,
399 renderOptions,
400 );
401 const html = container.innerHTML;
402 unmount();
403 return html;
404}