perf: a keystroke keeps its spacers, and typing waits for a pause

The page-break decorations are mapped through every transaction and each
spacer widget carries a key, so a pass that lands the same spacers keeps
their DOM instead of rebuilding all of them. An edit within 150 ms of the
last pass waits for a pause in the typing. Measured at the top of a
124-page document: 1.5 s a keystroke before, 40 ms after. The dom run
holds the budget.
This commit is contained in:
Steffen Becker committed 2026-09-08 16:48:41 +02:00
1 parent b2dcfa3aaf
commit 50aeb4a570
3 files changed
+74 -3

No files matched your search

+9
View File
@@ -110,6 +110,15 @@ width. Every **widget** decoration therefore builds its DOM in a `toDOM` functio
passed directly can only live in one of the two documents, and each view keeps taking it
back from the other.
**A pass keeps its spacers, and typing gets a pause.** The decoration set is mapped through
every transaction (`apply`) and each spacer widget carries a `key` naming what it draws, so
a pass that lands the same spacers keeps their DOM. Unmapped, an edit above them left every
widget one position off and the view rebuilt all of them — measured at the top of a
124-page document: 1.5 s a keystroke, 144 ms with the mapping. An edit within
`EDIT_IDLE_MS` of the last pass waits for a pause in the typing (the first after a pause
runs at once); a forced recalc or a layout-only write never waits. The dom run
(`tests/dom/run.mjs`) holds the budget.
**Layout constants** (must stay in sync between `pageBreaks.ts`, `Editor.svelte`, and `editor.css`):
- `PAGE_HEIGHT = 1123px` (A4 portrait), `PAGE_GAP = 20px`, `CYCLE = PAGE_HEIGHT + PAGE_GAP = 1143px`.
- Page height/width and margins are read **live** from CSS custom properties (`--user-page-height`, `--user-page-width`, `--user-margin-*`) so orientation/margin changes don't require new constants. `getCycle()` in `Editor.svelte` reads `--user-page-height` at runtime.
+25 -2
View File
@@ -315,6 +315,13 @@ export function isSplitPane(view: EditorView): boolean {
// foot of the gap, which is the top of the continuation.
type RowSpacer = { columns: number; close: string; open: string; header: { html: string; height: number } | null };
// What a spacer's DOM shows, as the widget key: without one a fresh toDOM is a fresh
// widget, and an edit near the top moves — and so rebuilds — every spacer below it.
function spacerKey(p: { height: number; row?: RowSpacer | null }): string {
if (!p.row) return `s${p.height}`;
return `r${p.height}|${p.row.columns}|${p.row.close}|${p.row.open}|${p.row.header?.html ?? ''}`;
}
type Leaf = {
el: HTMLElement;
kind: 'atomic' | 'splittable';
@@ -380,6 +387,11 @@ export const PageBreaks = Extension.create({
let decorations = DecorationSet.empty;
let isUpdating = false;
let rafId: number | null = null;
// Keys can come faster than a long document paginates: an edit within EDIT_IDLE_MS of
// the last pass waits for a pause in the typing; the first after a pause runs at once.
const EDIT_IDLE_MS = 150;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let lastPassAt = 0;
let lastPlacementsKey = '';
// The layout before the current one: two layouts can each imply the other (a block
// below a float's overhang moves further than the model's spacer), so seeing the older
@@ -398,6 +410,9 @@ export const PageBreaks = Extension.create({
state: {
init: () => ({ recalc: 0, edit: 0 }),
apply(tr, value) {
// The spacers follow the text until the next pass: left unmapped, an edit above
// them puts every widget one position off and the view tears them all down.
if (tr.docChanged) decorations = decorations.map(tr.mapping, tr.doc);
const recalc = value.recalc + (tr.getMeta(FORCE_PAGE_RECALC) ? 1 : 0);
const edit = value.edit
+ (tr.docChanged && tr.getMeta('addToHistory') !== false ? 1 : 0);
@@ -1780,7 +1795,7 @@ export const PageBreaks = Extension.create({
spacerEl.style.userSelect = 'none';
spacerEl.setAttribute('contenteditable', 'false');
return spacerEl;
}, { side: -1 }));
}, { side: -1, key: spacerKey(p) }));
if (collapsedTrailing) {
decoArray.push(Decoration.node(collapsedTrailing.from, collapsedTrailing.to, {
style: 'height:0;min-height:0;margin:0;overflow:hidden',
@@ -1857,6 +1872,7 @@ export const PageBreaks = Extension.create({
};
isUpdating = false;
lastPassAt = performance.now();
// A per-page restart counts within the page each anchor landed on, which only
// this pass knows (notes.ts). Renumbering can rewrap, so it takes a pass of its
@@ -1876,9 +1892,15 @@ export const PageBreaks = Extension.create({
}
function schedule() {
if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null; }
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(calculate);
}
function scheduleEdit() {
if (performance.now() - lastPassAt > EDIT_IDLE_MS) { schedule(); return; }
if (idleTimer !== null) clearTimeout(idleTimer);
idleTimer = setTimeout(schedule, EDIT_IDLE_MS);
}
// Initial calculation
schedule();
@@ -1895,10 +1917,11 @@ export const PageBreaks = Extension.create({
// pm-pagecount — answer the last pass, so forgetting the ping-pong
// memory on them would leave the guard with nothing to catch.
if (forced || isEditTr) prevPlacementsKey = null;
schedule();
if (isEditTr && !forced) scheduleEdit(); else schedule();
}
},
destroy() {
if (idleTimer !== null) clearTimeout(idleTimer);
if (rafId !== null) cancelAnimationFrame(rafId);
debugAccessors.delete(editorView);
},
+40 -1
View File
@@ -4,7 +4,7 @@
// uncaught page error.
import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import { ROOT, MOD, checker, previewServer, openApp } from '../browser.mjs';
import { ROOT, MOD, checker, previewServer, openApp, settle } from '../browser.mjs';
const PORT = +(process.env.DOM_PORT ?? 4185);
const { check, failures } = checker();
@@ -181,6 +181,45 @@ try {
}));
check(hf.first === 'true' && !hf.running && hf.firstPage.includes('Titelseite') && /"header":1.8/.test(hf.dist),
`the ribbon's header/footer switches reach the document (first page: ${hf.first}, running zone: ${hf.running}, distances: ${hf.dist})`);
// Typing latency in a long document. A keystroke at the top moves every page break
// below it, so this is where a whole-document pass costs the most: blocked main-thread
// time per keystroke in a burst, then the pass that follows the burst.
const LOREM = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'.split(' ');
const long = { type: 'doc', content: [] };
for (let i = 0; i < 1600; i++) {
let s = '';
for (let k = i; s.length < 300; k++) s += LOREM[k % LOREM.length] + ' ';
long.content.push({ type: 'paragraph', content: [{ type: 'text', text: s.trim() }] });
}
await page.evaluate((d) => localStorage.setItem('edentext-doc', JSON.stringify(d)), long);
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
await settle(page, true);
const longPages = await pageCount();
// How long the page holds the thread: a zero timeout resolves once it is free again.
const blocked = async () => {
const t = performance.now();
await page.evaluate(() => new Promise((r) => setTimeout(r, 0)));
return performance.now() - t;
};
await caretTo(1);
const keys = [];
for (const ch of 'The quick brown fox jumps') {
const t = performance.now();
await page.keyboard.type(ch);
await blocked();
keys.push(performance.now() - t);
}
let pass = 0;
for (const until = Date.now() + 4000; Date.now() < until;) pass = Math.max(pass, await blocked());
keys.sort((a, b) => a - b);
const keyMedian = Math.round(keys[keys.length >> 1]);
const keyMax = Math.round(keys[keys.length - 1]);
// The keystroke is the budget (a runner is slower than a laptop, so with room); the pass
// is what a whole-document layout costs, recorded for the day it goes incremental.
check(longPages > 100 && keyMedian < 300,
`typing at the top of a ${longPages}-page document: ${keyMedian} ms per keystroke (max ${keyMax}), ${Math.round(pass)} ms pass after the burst`);
} catch (err) {
check(false, `dom run threw: ${err.message ?? err}`);
} finally {