authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 00:25:59-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 01:17:10-07:00
log3d54937be677300a48ef3538513c4dcbfac920b3
treef5f5f1343dd9e191351baf7abe96103080d5467d
parent7cbbaedd32edafc10c7c0b128461dc123771c5e0
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

fix: layout shift stuff


5 files changed, 449 insertions(+), 21 deletions(-)

package.json+4-4
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1{1{
2 "name": "@clo/react-markdown",2 "name": "@clo/react-markdown",
3 "type": "module",3 "type": "module",
4 "exports": {
5 ".": "./src/Markdown.ts",
6 "./Predict": "./src/Predict.ts"
7 },
4 "scripts": {8 "scripts": {
5 "demo": "vite dev",9 "demo": "vite dev",
6 "all": "pnpm --parallel fmt && pnpm --stream '/check|test$/'",10 "all": "pnpm --parallel fmt && pnpm --stream '/check|test$/'",
...@@ -9,10 +13,6 @@...@@ -9,10 +13,6 @@
9 "check:publish": "deno publish --dry-run --allow-dirty",13 "check:publish": "deno publish --dry-run --allow-dirty",
10 "fmt": "oxfmt --write ."14 "fmt": "oxfmt --write ."
11 },15 },
12 "exports": {
13 ".": "./src/Markdown.ts",
14 "./Predict": "./src/Predict.ts"
15 },
16 "dependencies": {16 "dependencies": {
17 "@clo/lib": "npm:@jsr/clo__lib@3.0.0",17 "@clo/lib": "npm:@jsr/clo__lib@3.0.0",
18 "@types/react": "^19",18 "@types/react": "^19",
src/Memoizer.ts+75-12
...@@ -4,9 +4,9 @@ import { type Components } from "rehype-react";...@@ -4,9 +4,9 @@ import { type Components } from "rehype-react";
4import remarkRehype from "remark-rehype";4import remarkRehype from "remark-rehype";
5import { Fragment, jsx } from "react/jsx-runtime";5import { Fragment, jsx } from "react/jsx-runtime";
6import { type Processor, unified } from "unified";6import { type Processor, unified } from "unified";
7import type { Root as HastRoot } from "hast";7import type { Root as HastRoot, Root } from "hast";
8import type { Literal, Node, Parent } from "unist";8import type { Literal, Node, Parent } from "unist";
9import { memoizedHastToReact, type RenderState } from "./hast.ts";9import { memoizedHastToReact, nodeDeepEquals, type RenderState } from "./hast.ts";
10import remarkParse from "remark-parse";10import remarkParse from "remark-parse";
11import { Predict } from "./Predict.ts";11import { Predict } from "./Predict.ts";
1212
...@@ -38,6 +38,9 @@ export class Memoizer {...@@ -38,6 +38,9 @@ export class Memoizer {
38 #content: string = "";38 #content: string = "";
39 #positions: number[] = [];39 #positions: number[] = [];
40 #parsed: Node[][] = [];40 #parsed: Node[][] = [];
41 // React block identity must survive index shifts when blocks are inserted above.
42 #keys: string[] = [];
43 #nextKey = 0;
41 #renderStates: (RenderState | null)[] = [];44 #renderStates: (RenderState | null)[] = [];
42 #reactNodes: readonly ReactNode[] = [];45 #reactNodes: readonly ReactNode[] = [];
4346
...@@ -67,6 +70,8 @@ export class Memoizer {...@@ -67,6 +70,8 @@ export class Memoizer {
67 this.#content = "";70 this.#content = "";
68 this.#positions = [];71 this.#positions = [];
69 this.#parsed = [];72 this.#parsed = [];
73 this.#keys = [];
74 this.#nextKey = 0;
70 this.#renderStates = [];75 this.#renderStates = [];
71 this.#reactNodes = [];76 this.#reactNodes = [];
72 this.#predict = predict ? new Predict() : null;77 this.#predict = predict ? new Predict() : null;
...@@ -126,15 +131,75 @@ export class Memoizer {...@@ -126,15 +131,75 @@ export class Memoizer {
126 const newTransformed = extractBlocks(processor.runSync(parsedTree), newPositions, parseOffset);131 const newTransformed = extractBlocks(processor.runSync(parsedTree), newPositions, parseOffset);
127132
128 // React133 // React
134 const keys = this.#keys;
135 const renderStates = this.#renderStates;
136 const previousReactNodes = this.#reactNodes;
137 const previousLength = renderStates.length;
129 let reactNodes: ReactNode[] | null = null;138 let reactNodes: ReactNode[] | null = null;
130 if (this.#reactNodes.length !== positions.length) {139
131 // If items are removed, the array must be resliced.140 // Insertions and removals above an unchanged tail shift indices without
132 reactNodes = this.#reactNodes.slice(0, positions.length);141 // changing block content. Matching the shared suffix is enough to keep the
133 this.#renderStates.length = positions.length;142 // unaffected tail bound to its previous keys and render states.
143 let suffixLength = 0;
144 for (; suffixLength < newTransformed.length; suffixLength += 1) {
145 const nextIndex = positions.length - 1 - suffixLength;
146 const previousIndex = previousLength - 1 - suffixLength;
147 if (nextIndex < blockStart || previousIndex < blockStart) break;
148
149 const next = UNWRAP(newTransformed[nextIndex - blockStart]);
150 const state = renderStates[previousIndex];
151 const prev = state?.node.type === "root" ? (state.node as Root) : null;
152 if (!prev || !nodeDeepEquals(next, prev.children)) break;
153 }
154
155 // (If realignment is going to occurs, the array must be cloned)
156 const previousMiddleEnd = previousLength - suffixLength;
157 const nextMiddleEnd = positions.length - suffixLength;
158 const realign = previousLength !== positions.length || previousMiddleEnd !== nextMiddleEnd;
159 if (realign) reactNodes = previousReactNodes.slice(0, positions.length);
160
161 if (positions.length > previousLength) {
162 keys.length = renderStates.length = positions.length;
163 }
164 // Realignment reads from the previous layout and writes the next layout
165 // into the same backing arrays. Traversal direction prevents later reads
166 // from observing values that were already overwritten earlier in the pass.
167 for (
168 let direction = positions.length < previousLength ? 1 : -1,
169 i = direction === 1 ? 0 : positions.length - 1,
170 end = direction === 1 ? positions.length : -1;
171 i !== end;
172 i += direction
173 ) {
174 let previousIndex: number = -1;
175 if (i < blockStart) {
176 previousIndex = i;
177 } else if (i >= nextMiddleEnd) {
178 previousIndex = previousLength - (positions.length - i);
179 } else if (i < previousMiddleEnd) {
180 previousIndex = i;
181 }
182 if (previousIndex < 0 || previousIndex >= previousLength) {
183 keys[i] = String(this.#nextKey);
184 this.#nextKey += 1;
185 renderStates[i] = null;
186 if (reactNodes) reactNodes[i] = undefined as ReactNode | undefined;
187 continue;
188 }
189
190 keys[i] = keys[previousIndex] ?? String("m" + this.#nextKey++);
191 renderStates[i] = renderStates[previousIndex] ?? null;
192 if (reactNodes) reactNodes[i] = previousReactNodes[previousIndex];
193 }
194
195 // Create and splice the React elements
196 if (positions.length < previousLength) {
197 keys.length = renderStates.length = positions.length;
134 }198 }
135 for (let i = blockStart, len = positions.length; i < len; i += 1) {199 for (let i = blockStart, len = positions.length; i < len; i += 1) {
136 const ast = UNWRAP(newTransformed[i - blockStart]);200 const ast = UNWRAP(newTransformed[i - blockStart]);
137 const previousState = this.#renderStates[i] ?? null;201 const previousState = renderStates[i] ?? null;
202
138 // `memoizedHastToReact` handles diffing and incrementally updating the AST.203 // `memoizedHastToReact` handles diffing and incrementally updating the AST.
139 let { react: result, state } = memoizedHastToReact(204 let { react: result, state } = memoizedHastToReact(
140 { type: "root", children: ast } as HastRoot,205 { type: "root", children: ast } as HastRoot,
...@@ -142,10 +207,10 @@ export class Memoizer {...@@ -142,10 +207,10 @@ export class Memoizer {
142 this.#components,207 this.#components,
143 );208 );
144 if (previousState === state) continue;209 if (previousState === state) continue;
145 this.#renderStates[i] = state;210 renderStates[i] = state;
146211
147 // Do a little trolling and unwrap the fragment to clean up the Virtual DOM212 // Do a little trolling and unwrap the fragment to clean up the Virtual DOM
148 const key = String("m" + i);213 const key = keys[i]!;
149 const rendered = result as JSX.Element;214 const rendered = result as JSX.Element;
150 const children = rendered.props.children;215 const children = rendered.props.children;
151 if (rendered.type === Fragment && children?.type) {216 if (rendered.type === Fragment && children?.type) {
...@@ -153,9 +218,7 @@ export class Memoizer {...@@ -153,9 +218,7 @@ export class Memoizer {
153 } else {218 } else {
154 result = setReactKey(rendered, key);219 result = setReactKey(rendered, key);
155 }220 }
156221 reactNodes ??= previousReactNodes.slice(0, positions.length);
157 // If anything in the array changes, the array must be sliced.
158 reactNodes ??= this.#reactNodes.slice();
159 reactNodes[i] = result;222 reactNodes[i] = result;
160 }223 }
161224
tests/Markdown.memoization.test.tsx+163-1
...@@ -1,15 +1,20 @@...@@ -1,15 +1,20 @@
1import { cleanup, render, screen } from "@testing-library/react";1import { cleanup, render, screen } from "@testing-library/react";
2import type { Components } from "rehype-react";2import type { Components } from "rehype-react";
3import type { ComponentPropsWithoutRef, JSX } from "react";3import type { ComponentPropsWithoutRef, JSX } from "react";
4import { useRef } from "react";
5import remarkGfm from "remark-gfm";
6import remarkParse from "remark-parse";
7import { unified } from "unified";
4import { afterEach, expect, it, vi } from "vitest";8import { afterEach, expect, it, vi } from "vitest";
5import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts";9import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts";
6import { Memoizer } from "../src/Memoizer.ts";10import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts";
711
8type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & {12type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & {
9 node?: unknown;13 node?: unknown;
10};14};
1115
12const renderOptions = { reactStrictMode: false } as const;16const renderOptions = { reactStrictMode: false } as const;
17const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor;
1318
14afterEach(() => {19afterEach(() => {
15 cleanup();20 cleanup();
...@@ -129,6 +134,79 @@ it("does not rerender an unchanged inline custom renderer with formatted childre...@@ -129,6 +134,79 @@ it("does not rerender an unchanged inline custom renderer with formatted childre
129 expect(Link).toHaveBeenCalledTimes(1);134 expect(Link).toHaveBeenCalledTimes(1);
130});135});
131136
137it("keeps an unchanged strong renderer asleep when surrounding paragraph text changes", () => {
138 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
139 return <strong data-testid="strong" {...omitNode(props)} />;
140 });
141 const components = { strong: Strong } satisfies Partial<Components>;
142
143 const { rerender } = render(
144 <Markdown content={"alpha **stable** omega"} components={components} />,
145 renderOptions,
146 );
147
148 const strong = screen.getByTestId("strong");
149 expect(Strong).toHaveBeenCalledTimes(1);
150
151 rerender(<Markdown content={"alpha! **stable** omega"} components={components} />);
152 rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />);
153
154 expect(screen.getByTestId("strong")).toBe(strong);
155 expect(Strong).toHaveBeenCalledTimes(1);
156});
157
158it("keeps an unchanged list item renderer asleep when a sibling item changes", () => {
159 const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) {
160 return <li data-testid="list-item" {...omitNode(props)} />;
161 });
162 const components = { li: ListItem } satisfies Partial<Components>;
163
164 const { rerender } = render(
165 <Markdown content={"- first\n- stable\n- third"} components={components} />,
166 renderOptions,
167 );
168
169 const stableItem = screen.getByText("stable").closest("li");
170 expect(stableItem).not.toBeNull();
171 expect(ListItem).toHaveBeenCalledTimes(3);
172
173 rerender(<Markdown content={"- first changed\n- stable\n- third"} components={components} />);
174
175 expect(screen.getByText("stable").closest("li")).toBe(stableItem);
176 expect(ListItem).toHaveBeenCalledTimes(4);
177});
178
179it("keeps an unchanged table cell renderer asleep when another cell changes", () => {
180 const TableCell = vi.fn(function TableCell(props: MarkdownProps<"td">) {
181 return <td data-testid="table-cell" {...omitNode(props)} />;
182 });
183 const components = { td: TableCell } satisfies Partial<Components>;
184
185 const { rerender } = render(
186 <Markdown
187 components={components}
188 content={"| left | stable |\n| --- | --- |\n| one | keep |"}
189 processor={gfmProcessor}
190 />,
191 renderOptions,
192 );
193
194 const stableCell = screen.getByText("keep").closest("td");
195 expect(stableCell).not.toBeNull();
196 expect(TableCell).toHaveBeenCalledTimes(2);
197
198 rerender(
199 <Markdown
200 components={components}
201 content={"| left | stable |\n| --- | --- |\n| one! | keep |"}
202 processor={gfmProcessor}
203 />,
204 );
205
206 expect(screen.getByText("keep").closest("td")).toBe(stableCell);
207 expect(TableCell).toHaveBeenCalledTimes(3);
208});
209
132it("does not rerender Markdown through the provider when the effective components stay the same", () => {210it("does not rerender Markdown through the provider when the effective components stay the same", () => {
133 const updateSpy = vi.spyOn(Memoizer.prototype, "update");211 const updateSpy = vi.spyOn(Memoizer.prototype, "update");
134 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {212 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
...@@ -169,3 +247,87 @@ it("reprocesses the document when prediction changes", () => {...@@ -169,3 +247,87 @@ it("reprocesses the document when prediction changes", () => {
169 expect(container.querySelector("em")?.textContent).toBe("world");247 expect(container.querySelector("em")?.textContent).toBe("world");
170 expect(container.textContent).toBe("hello world");248 expect(container.textContent).toBe("hello world");
171});249});
250
251it.each([
252 {
253 content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"],
254 name: "paragraph merging edits",
255 predict: false,
256 processor: undefined,
257 },
258 {
259 content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]"],
260 name: "reference link definition edits",
261 predict: false,
262 processor: undefined,
263 },
264 {
265 content: [
266 "| A | B |\n| --- | --- |\n| one | two |",
267 "| A | B |\n| --- | --- |\n| one! | two |",
268 "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |",
269 ],
270 name: "gfm table edits",
271 predict: false,
272 processor: gfmProcessor,
273 },
274 {
275 content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"],
276 name: "prediction edits",
277 predict: true,
278 processor: undefined,
279 },
280])("matches a fresh render across $name", ({ content, predict, processor }) => {
281 const first = content[0]!;
282 const { container, rerender } = render(
283 <Markdown content={first} predict={predict} processor={processor} />,
284 renderOptions,
285 );
286
287 expect(container.innerHTML).toBe(renderFreshHtml(first, predict, processor));
288
289 for (const step of content.slice(1)) {
290 rerender(<Markdown content={step} predict={predict} processor={processor} />);
291 expect(container.innerHTML).toBe(renderFreshHtml(step, predict, processor));
292 }
293});
294
295it("keeps a stable block tied to its own DOM node when blocks are inserted or removed above it", () => {
296 function Link(props: MarkdownProps<"a">) {
297 const instanceRef = useRef(Symbol("instance"));
298 return <a data-instance={String(instanceRef.current)} {...omitNode(props)} />;
299 }
300
301 const components = { a: Link } satisfies Partial<Components>;
302 const { rerender } = render(
303 <Markdown content={"before\n\n[stable](https://example.com)"} components={components} />,
304 renderOptions,
305 );
306
307 const stableLink = screen.getByRole("link");
308
309 rerender(
310 <Markdown
311 content={"intro\n\nbefore\n\n[stable](https://example.com)"}
312 components={components}
313 />,
314 );
315 expect(screen.getByRole("link")).toBe(stableLink);
316
317 rerender(<Markdown content={"intro\n\n[stable](https://example.com)"} components={components} />);
318 expect(screen.getByRole("link")).toBe(stableLink);
319});
320
321function renderFreshHtml(
322 content: string,
323 predict: boolean | undefined,
324 processor: BaseProcessor | undefined,
325) {
326 const { container, unmount } = render(
327 <Markdown content={content} predict={predict} processor={processor} />,
328 renderOptions,
329 );
330 const html = container.innerHTML;
331 unmount();
332 return html;
333}
tests/Memoizer.test.tsx created+206
...@@ -0,0 +1,206 @@
1import type { ReactNode } from "react";
2import { Fragment } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import remarkGfm from "remark-gfm";
5import remarkParse from "remark-parse";
6import { unified } from "unified";
7import type { Literal, Node, Parent } from "unist";
8import { describe, expect, it } from "vitest";
9import {
10 componentsAreEqual,
11 defaultProcessor,
12 extractBlocks,
13 indexOfDiff,
14 Memoizer,
15 nodeAffectsDocument,
16} from "../src/Memoizer.ts";
17
18const gfmProcessor = unified().use(remarkParse).use(remarkGfm);
19
20describe("Memoizer utilities", () => {
21 it("finds the first differing index for edits in the middle, start, and end", () => {
22 expect(indexOfDiff("alpha", "alpha!")).toBe(5);
23 expect(indexOfDiff("!alpha", "alpha")).toBe(0);
24 expect(indexOfDiff("alXha", "alpha")).toBe(2);
25 });
26
27 it("treats semantically equal component maps as equal", () => {
28 const Link = () => null;
29 const Paragraph = () => null;
30
31 expect(componentsAreEqual({ a: Link, p: Paragraph }, { p: Paragraph, a: Link })).toBe(true);
32 expect(componentsAreEqual({ a: Link }, { a: Link, p: Paragraph })).toBe(false);
33 expect(componentsAreEqual({ a: Link }, { a: Paragraph })).toBe(false);
34 });
35
36 it("detects document-wide nodes even when nested", () => {
37 expect(
38 nodeAffectsDocument({
39 children: [
40 {
41 children: [
42 {
43 children: [],
44 type: "paragraph",
45 } as Parent,
46 {
47 identifier: "ref",
48 label: "ref",
49 title: null,
50 type: "definition",
51 url: "https://example.com",
52 } as Node,
53 ],
54 type: "container",
55 } as Parent,
56 ],
57 type: "root",
58 } as Parent),
59 ).toBe(true);
60
61 expect(
62 nodeAffectsDocument({
63 children: [
64 {
65 children: [],
66 type: "paragraph",
67 } as Parent,
68 ],
69 type: "root",
70 } as Parent),
71 ).toBe(false);
72 });
73
74 it("groups transformed nodes into block buckets and ignores pure whitespace text", () => {
75 const blocks = extractBlocks(
76 {
77 children: [
78 {
79 children: [],
80 position: {
81 end: { column: 6, line: 1, offset: 5 },
82 start: { column: 1, line: 1, offset: 0 },
83 },
84 type: "paragraph",
85 } as Node,
86 {
87 type: "text",
88 value: "\n\n",
89 } as Literal,
90 {
91 children: [],
92 position: {
93 end: { column: 5, line: 3, offset: 14 },
94 start: { column: 1, line: 3, offset: 10 },
95 },
96 type: "paragraph",
97 } as Node,
98 ],
99 type: "root",
100 } as Parent,
101 [0, 10],
102 0,
103 );
104
105 expect(blocks).toHaveLength(2);
106 expect(blocks[0]).toHaveLength(1);
107 expect(blocks[1]).toHaveLength(1);
108 });
109});
110
111describe("Memoizer incremental rendering", () => {
112 it("returns the same array reference when content does not change", () => {
113 const memoizer = createMemoizer();
114
115 const first = memoizer.update("hello");
116 const second = memoizer.update("hello");
117
118 expect(second).toBe(first);
119 });
120
121 it("reuses unchanged block react nodes when editing a different block", () => {
122 const memoizer = createMemoizer();
123 const first = memoizer.update("alpha\n\nbeta");
124 const second = memoizer.update("alpha!\n\nbeta");
125 const third = memoizer.update("alpha!\n\nbeta!");
126
127 expect(second[1]).toBe(first[1]);
128 expect(third[0]).toBe(second[0]);
129 expect(third[1]).not.toBe(second[1]);
130 });
131
132 it("reprocesses reference-link consumers when a later definition changes", () => {
133 const memoizer = createMemoizer();
134
135 const before = renderNodes(memoizer.update("[ref][id]\n\n[id]: /one"));
136 const after = renderNodes(memoizer.update("[ref][id]\n\n[id]: /two"));
137
138 expect(before).toContain('href="/one"');
139 expect(after).toContain('href="/two"');
140 });
141
142 it.each([
143 {
144 content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"],
145 name: "paragraphs merging and splitting",
146 predict: false,
147 processor: defaultProcessor,
148 },
149 {
150 content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]\n\n[id]: /three"],
151 name: "reference link definitions",
152 predict: false,
153 processor: defaultProcessor,
154 },
155 {
156 content: [
157 "| A | B |\n| --- | --- |\n| one | two |",
158 "| A | B |\n| --- | --- |\n| one! | two |",
159 "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |",
160 ],
161 name: "gfm tables",
162 predict: false,
163 processor: gfmProcessor,
164 },
165 {
166 content: ["- [ ] open\n- [x] done", "- [x] open\n- [x] done", "- [x] open\n- [ ] done"],
167 name: "gfm task lists",
168 predict: false,
169 processor: gfmProcessor,
170 },
171 {
172 content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"],
173 name: "prediction across mid-document edits",
174 predict: true,
175 processor: defaultProcessor,
176 },
177 ])("matches a fresh memoizer for $name", ({ content, predict, processor }) => {
178 const incremental = createMemoizer(processor, predict);
179
180 for (const step of content) {
181 const fresh = createMemoizer(processor, predict);
182
183 expect(renderNodes(incremental.update(step))).toBe(renderNodes(fresh.update(step)));
184 }
185 });
186
187 it("keeps a stable block tied to its own react node when blocks are inserted or removed above it", () => {
188 const memoizer = createMemoizer();
189 const first = memoizer.update("before\n\n[stable](https://example.com)");
190 const second = memoizer.update("intro\n\nbefore\n\n[stable](https://example.com)");
191 const third = memoizer.update("intro\n\n[stable](https://example.com)");
192
193 expect(second[2]).toBe(first[1]);
194 expect(third[1]).toBe(second[2]);
195 });
196});
197
198function createMemoizer(processor = defaultProcessor, predict = false) {
199 const memoizer = new Memoizer();
200 memoizer.reconfigure(processor, predict, {});
201 return memoizer;
202}
203
204function renderNodes(nodes: readonly ReactNode[]) {
205 return renderToStaticMarkup(<Fragment>{nodes}</Fragment>);
206}
tests/prediction.test.ts+1-4
...@@ -207,10 +207,7 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => {...@@ -207,10 +207,7 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => {
207 ["hello **world ", "hello **world** "],207 ["hello **world ", "hello **world** "],
208 ["hello _world ", "hello _world_ "],208 ["hello _world ", "hello _world_ "],
209 ["hello __world ", "hello __world__ "],209 ["hello __world ", "hello __world__ "],
210 ])(210 ])("closes before trailing whitespace for %j", (input, expected) => check(input, expected));
211 "closes before trailing whitespace for %j",
212 (input, expected) => check(input, expected),
213 );
214 });211 });
215212
216 describe("soft line breaks", () => {213 describe("soft line breaks", () => {