From 9e7ccc85bf6590937fbb8a11c5cce59978054250 Mon Sep 17 00:00:00 2001
From: PathGao <42336971+PathGao@users.noreply.github.com>
Date: Sat, 24 Oct 2026 04:18:06 +0800
Subject: [PATCH] perf(preview): batch a first render and regroup only the
sections an edit touched
A first render sanitized every block with a call of its own, and each
DOMPurify call builds a document of its own: a 5MB file opened about 30%
slower than with the whole-document pipeline. Blocks are now processed
and sanitized in one pass, each inside a carrying a nonce, and
taken back out of it; markup that closes or forges one cannot know the
nonce, so that batch falls back to one block at a time.
Adding, removing or re-levelling a top-level heading moved every block
in the document back to the top and wrapped them all again. Only the
sections between the nearest heading of the edited level or higher
before the edit and the next one after it are regrouped now; the folds
outside keep their wrappers, and a kept heading's wrapper takes on the
state the reader left it in.
---
scripts/previewPatch.spec.ts | 52 +++++++++++
scripts/previewPatchParity.spec.ts | 3 +
scripts/previewSanitize.test.ts | 27 ++++--
src/lib/utils/previewPatch.ts | 142 +++++++++++++++++++++++++----
4 files changed, 197 insertions(+), 27 deletions(-)
diff --git a/scripts/previewPatch.spec.ts b/scripts/previewPatch.spec.ts
index 3ad3f8d0..c44f4294 100644
--- a/scripts/previewPatch.spec.ts
+++ b/scripts/previewPatch.spec.ts
@@ -48,6 +48,19 @@ describe('the first render', () => {
});
});
+describe('raw HTML that breaks out of the batch a first render parses blocks in', () => {
+ test('is rendered block by block instead, with nothing attributed to the wrong block', () => {
+ const forged = 'a
b
';
+ const result = patch(split(p(1, 'alpha'), forged, p(5, 'beta')));
+
+ expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alpha', 'a', 'b', 'beta']);
+ expect(result.inserted.length).toBe(4);
+
+ patch(split(p(1, 'alphaX'), forged, p(5, 'beta')));
+ expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alphaX', 'a', 'b', 'beta']);
+ });
+});
+
describe('typing one character in the middle of a document', () => {
const before = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'), p(7, 'gamma'));
const after = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'betaX'), p(7, 'gamma'));
@@ -152,6 +165,45 @@ describe('headings', () => {
expect(sections[1].contains(beta)).toBe(true);
});
+ test('a new subsection leaves the sections around it where they were', () => {
+ patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Three'), p(11, 'gamma')));
+ const [one, twoBody, three] = Array.from(host.querySelectorAll('.foldable-content-wrapper'));
+ const two = host.querySelector('#two')!;
+ two.classList.add('is-collapsed');
+ two.nextElementSibling!.classList.add('is-collapsed');
+
+ const result = patch(
+ split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(3, 9, 'Sub'), p(11, 'x'), h(2, 13, 'Three'), p(15, 'gamma')),
+ );
+
+ const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper'));
+ expect(wrappers[0]).toBe(one);
+ expect(wrappers[1]).toBe(twoBody);
+ expect(wrappers[wrappers.length - 1]).toBe(three);
+ // Only the new subsection is wrapped: the section around it ends where it did.
+ expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['sub']);
+ // The section the reader closed is still closed, and now holds the new one.
+ expect(two.nextElementSibling!.classList.contains('is-collapsed')).toBe(true);
+ expect(two.nextElementSibling!.querySelector('#sub')).not.toBeNull();
+ });
+
+ test('a new section of the same level re-wraps only the section it splits', () => {
+ patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), p(9, 'b2'), h(2, 11, 'Three'), p(13, 'gamma')));
+ const [one, , three] = Array.from(host.querySelectorAll('.foldable-content-wrapper'));
+ const kept = bodies();
+
+ const result = patch(
+ split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Mid'), p(11, 'b2'), h(2, 13, 'Three'), p(15, 'gamma')),
+ );
+
+ expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['two', 'mid']);
+ const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper'));
+ expect(wrappers[0]).toBe(one);
+ expect(wrappers[3]).toBe(three);
+ expect(bodies().every((body, index) => body === kept[index])).toBe(true);
+ expect(wrappers[2].textContent).toBe('b2');
+ });
+
test('a new paragraph right under a heading goes into that heading\'s section, first', () => {
patch(split(h(2, 1, 'One'), p(3, 'alpha')));
patch(split(h(2, 1, 'One'), p(3, 'new'), p(5, 'alpha')));
diff --git a/scripts/previewPatchParity.spec.ts b/scripts/previewPatchParity.spec.ts
index 94292e2a..50bea4c8 100644
--- a/scripts/previewPatchParity.spec.ts
+++ b/scripts/previewPatchParity.spec.ts
@@ -61,6 +61,9 @@ const edits: Record string> = {
'a new paragraph': (split) => at(split, middleParagraph, (b) => `${b}\0\nnew
\n`),
'a new section': (split) => at(split, middleParagraph, (b) => `${b}\0\nAdded
\n`),
'a deleted heading': (split) => at(split, firstH2, () => ''),
+ 'a new subsection': (split) => at(split, middleParagraph, (b) => `${b}\0\nSub
\n`),
+ 'a new top-level heading': (split) => at(split, firstH2, (b) => `Top
\n\0${b}`),
+ 'a heading changed level': (split) => at(split, firstH2, (b) => b.replace(/^(\n?)/, '')),
'an edited diagram': (split) => at(split, mermaid, (b) => b.replace('', 'C --> D\n')),
'an edited raw HTML block': (split) => at(split, details, (b) => b.replace('', ' edited')),
};
diff --git a/scripts/previewSanitize.test.ts b/scripts/previewSanitize.test.ts
index ba329b14..bf3ab7cb 100644
--- a/scripts/previewSanitize.test.ts
+++ b/scripts/previewSanitize.test.ts
@@ -100,11 +100,20 @@ test('the preview sanitizes through the shared policy, not a local config', () =
const parses = [...previewPatchSource.matchAll(/(\w+)\.(?:innerHTML|outerHTML|insertAdjacentHTML)\s*=\s*([^;]+);/g)].map(
(m) => `${m[1]} = ${m[2].trim()}`,
);
- assert.deepEqual(parses, ['box = raw'], 'previewPatch.ts parses only a raw block, before the filter');
+ assert.deepEqual(parses, ['box = markup'], 'previewPatch.ts parses only the renderer output, before the filter');
assert.match(previewPatchSource, /const box = scratch\.createElement\('div'\);/, 'into the detached scratch document');
- const renderBlock = sliceBetween(previewPatchSource, 'function renderBlock(', '\n}\n');
- assert.match(renderBlock, /const clean = sanitizeMarkdownFragment\(box\.innerHTML\);\n\tconst top = Array\.from\(clean\.children\)/);
- assert.match(renderBlock, /return top;$/, 'a block is inserted as the filter returned it');
+ const processAndSanitize = sliceBetween(previewPatchSource, 'function processAndSanitize(', '\n}\n');
+ assert.match(processAndSanitize, /return sanitizeMarkdownFragment\(box\.innerHTML\);$/, 'and the filter has the last word');
+ // What a block is inserted as is always the filter's own nodes.
+ const renderBlocks = sliceBetween(previewPatchSource, 'function renderBlocks(', '\n}\n');
+ assert.deepEqual(
+ [...renderBlocks.matchAll(/Array\.from\((\w+)\.children\)/g)].map((m) => m[1]),
+ ['clean', 'lone', 'mark'],
+ );
+ assert.match(renderBlocks, /const clean = processAndSanitize\(/);
+ assert.match(renderBlocks, /const lone = processAndSanitize\(/);
+ assert.match(renderBlocks, /const marks = Array\.from\(clean\.children\);/);
+ assert.match(renderBlocks, /return marks\.map\(\(mark\) =>/);
assert.doesNotMatch(previewPatchSource, /insertAdjacentHTML|outerHTML|createContextualFragment/);
// Why the footnote sink is allowed, pinned as a direction rather than as a
@@ -218,17 +227,17 @@ test('the two paths keep their opposite orders on purpose', () => {
// argument list `(html, filePath, collapsedHeaders)` and the trailing
// semicolon — which pinned three private parameter names and a formatting
// choice as the price of checking which call happens where.
- // The preview processes each block it inserts in `renderBlock`, and
+ // The preview processes the blocks it inserts in `processAndSanitize`, and
// nowhere else; the viewer hands the renderer's output straight to the patch.
assert.deepEqual(callSiteOffsets(viewerSource, 'processMarkdownHtml'), [], 'the viewer processes nothing itself');
assert.deepEqual(
callSiteOffsets(previewPatchSource, 'processBlockMarkup').map((offset) => enclosingFunctionName(previewPatchSource, offset)),
- ['renderBlock'],
- 'renderBlock must be the one place that processes the renderer output',
+ ['processAndSanitize'],
+ 'processAndSanitize must be the one place that processes the renderer output',
);
- const renderBlock = sliceBetween(previewPatchSource, 'function renderBlock(', '\n}\n');
+ const processAndSanitize = sliceBetween(previewPatchSource, 'function processAndSanitize(', '\n}\n');
assert.ok(
- renderBlock.indexOf('processBlockMarkup(') < renderBlock.indexOf('sanitizeMarkdownFragment('),
+ processAndSanitize.indexOf('processBlockMarkup(') < processAndSanitize.indexOf('sanitizeMarkdownFragment('),
'the preview must not move the filter ahead of processing without revisiting the note above',
);
// The sink half — that what `{@html}` injects is the sanitizer's output — is
diff --git a/src/lib/utils/previewPatch.ts b/src/lib/utils/previewPatch.ts
index 6677a747..6ad9b4d6 100644
--- a/src/lib/utils/previewPatch.ts
+++ b/src/lib/utils/previewPatch.ts
@@ -42,7 +42,7 @@ import { sanitizeMarkdownFragment } from './sanitize.js';
* has seen it. A block is inserted after the one before it, or at the top of
* that heading's wrapper; only an edit that adds, removes or re-levels a
* top-level heading changes which wrapper a block belongs to, and that one
- * regroups.
+ * regroups the sections it touched (`regroupSections`).
*/
const SEPARATOR = '\0';
@@ -79,21 +79,60 @@ export interface PreviewPatch {
let scratch: Document | null = null;
-function renderBlock(raw: string, filePath: string, ids: FoldIds): HTMLElement[] {
+/** Processes and sanitizes `markup` in one pass, answering with the filter's own nodes. */
+function processAndSanitize(markup: string, filePath: string): DocumentFragment {
// An inert document: nothing in it runs or loads.
scratch ??= document.implementation.createHTMLDocument('');
const box = scratch.createElement('div');
- box.innerHTML = raw;
+ box.innerHTML = markup;
processBlockMarkup(box, filePath);
- const clean = sanitizeMarkdownFragment(box.innerHTML);
- const top = Array.from(clean.children) as HTMLElement[];
- // Headings inside a block (a callout, a list item) fold among their own
- // siblings. The block's top-level heading is the patch's to wrap.
+ return sanitizeMarkdownFragment(box.innerHTML);
+}
+
+/** Headings inside a block (a callout, a list item) fold among their own siblings. */
+function wrapNestedHeadings(block: ParentNode, ids: FoldIds): void {
+ wrapHeadings(
+ Array.from(block.querySelectorAll('.foldable-header')).filter((h) => h.parentNode !== block),
+ ids,
+ );
+}
+
+const BLOCK_MARK = 'data-preview-block';
+
+/**
+ * The elements each of `raws` becomes, in one parse and one DOMPurify pass
+ * however many blocks there are: a call per block costs a document of
+ * DOMPurify's own each time, which made a first render of 50,000 blocks a
+ * second slower than the whole-document pipeline.
+ *
+ * Each block goes in inside a `` carrying a nonce, and comes back
+ * out of it. Markup that closed one early or forged one cannot know the
+ * nonce, so the count or a nonce is wrong and the blocks are done one at a
+ * time instead.
+ */
+function renderBlocks(raws: string[], filePath: string, ids: FoldIds): HTMLElement[][] {
+ if (raws.length === 0) return [];
+ const nonce = Math.random().toString(36).slice(2);
+ const clean = processAndSanitize(
+ raws.map((raw) => ``).join(''),
+ filePath,
+ );
+ const marks = Array.from(clean.children);
+ const intact =
+ marks.length === raws.length &&
+ marks.every((mark) => mark.tagName === 'SECTION' && mark.getAttribute(BLOCK_MARK) === nonce);
+ if (!intact) {
+ if (raws.length > 1) return raws.map((raw) => renderBlocks([raw], filePath, ids)[0]);
+ const lone = processAndSanitize(raws[0], filePath);
+ wrapNestedHeadings(lone, ids);
+ return [Array.from(lone.children) as HTMLElement[]];
+ }
+ // One query for the whole batch, not one per block.
wrapHeadings(
- Array.from(clean.querySelectorAll('.foldable-header')).filter((h) => h.parentNode !== clean),
+ Array.from(clean.querySelectorAll('.foldable-header')).filter((h) => h.parentNode?.parentNode !== clean),
ids,
);
- return top;
+ return marks.map((mark) => Array.from(mark.children) as HTMLElement[]);
}
function live(element: Element): Element {
@@ -151,7 +190,7 @@ export function patchPreview(
if (!old) {
const ids: FoldIds = { next: 0 };
- const els = raws.map((raw) => renderBlock(raw, filePath, ids));
+ const els = renderBlocks(raws, filePath, ids);
const flat = els.flat() as HTMLElement[];
host.replaceChildren(...flat);
patch.wrappers = wrapHeadings(flat.filter(isTopHeading), ids);
@@ -193,7 +232,11 @@ export function patchPreview(
for (let index = head; index < oldTo; index += 1) {
if (!(pairwise && old.keys[index] === keys[index])) removed.push(...old.els[index].map(live));
}
- for (const index of render) els[index] = renderBlock(raws[index], filePath, old.ids);
+ renderBlocks(
+ render.map((index) => raws[index]),
+ filePath,
+ old.ids,
+ ).forEach((elements, at) => (els[render[at]] = elements));
const added = render.flatMap((index) => els[index]) as HTMLElement[];
// A renamed heading of the same level keeps its wrapper. Any other change
@@ -216,13 +259,12 @@ export function patchPreview(
added.some((e) => e.matches(FOLD_HEADS) || e.querySelector(FOLD_HEADS));
if (regroup) {
- // Regroup: every block back to the top level, then wrapped again. The
- // blocks are moved, not rebuilt.
- for (const element of removed) element.remove();
- const flat = els.flat() as HTMLElement[];
- host.replaceChildren(...flat);
- patch.wrappers = wrapHeadings(flat.filter(isTopHeading), old.ids);
- applyFolds(host, foldOverrides);
+ patch.wrappers = regroupSections(host, els, head, newTo, removed, added, old.ids);
+ applyFolds(host, foldOverrides, true);
+ // A kept heading keeps its state, which its new wrapper takes on.
+ for (const wrapper of patch.wrappers) {
+ wrapper.classList.toggle('is-collapsed', wrapper.previousElementSibling!.classList.contains('is-collapsed'));
+ }
} else {
for (const index of render) {
const before = pairwise ? old.els[index].map(live) : [];
@@ -245,6 +287,70 @@ export function patchPreview(
return patch;
}
+const levelOf = (heading: Element) => Number(heading.tagName[1]);
+const topHeadingOf = (elements: Element[] | undefined) => elements?.find(isTopHeading);
+
+/**
+ * Re-wraps the part of the document whose sections an edit to its top-level
+ * headings changed, and nothing else. With `L` the highest level the edit
+ * added or removed, that part runs from the nearest heading of level `L` or
+ * higher before the edit to the next one after it: a section of a higher
+ * level contains it unchanged, and one of the same level ends where it did.
+ * The blocks are moved, not rebuilt. Returns the new wrappers.
+ */
+function regroupSections(
+ host: HTMLElement,
+ els: Element[][],
+ from: number,
+ to: number,
+ removed: Element[],
+ added: Element[],
+ ids: FoldIds,
+): HTMLElement[] {
+ const level = Math.min(...[...removed, ...added].filter(isTopHeading).map(levelOf));
+ const wrapperOf = (heading: Element) => {
+ const next = heading.nextElementSibling;
+ return next?.classList.contains('foldable-content-wrapper') ? next : null;
+ };
+
+ // Where the regrouped run goes: a placeholder, set before anything moves.
+ const marker = host.ownerDocument.createComment('');
+ let start = 0;
+ for (let index = from - 1; index >= 0; index -= 1) {
+ const heading = topHeadingOf(els[index]);
+ if (!heading || levelOf(heading) > level) continue;
+ if (levelOf(heading) === level) {
+ start = index;
+ heading.before(marker);
+ } else {
+ start = index + 1;
+ const inner = Array.from(wrapperOf(heading)?.children ?? []).find((child) =>
+ child.classList.contains('content-inner'),
+ );
+ (inner ?? host).prepend(marker);
+ }
+ break;
+ }
+ if (!marker.parentNode) host.prepend(marker);
+ let end = els.length;
+ for (let index = to; index < els.length; index += 1) {
+ const heading = topHeadingOf(els[index]);
+ if (heading && levelOf(heading) <= level) {
+ end = index;
+ break;
+ }
+ }
+
+ const stale: Element[] = [];
+ for (const element of removed) if (isTopHeading(element)) stale.push(...[wrapperOf(element)].filter((w) => w !== null));
+ const run = els.slice(start, end).flat() as HTMLElement[];
+ for (const element of run) if (isTopHeading(element)) stale.push(...[wrapperOf(element)].filter((w) => w !== null));
+ for (const element of removed) element.remove();
+ marker.replaceWith(...run);
+ for (const wrapper of stale) wrapper.remove();
+ return wrapHeadings(run.filter(isTopHeading), ids);
+}
+
/** Puts `nodes` where block `index` belongs: after the previous block's last element. */
function insertBlock(host: HTMLElement, els: Element[][], index: number, nodes: HTMLElement[]) {
for (let before = index - 1; before >= 0; before -= 1) {