perf: the load passes batch their field work and flow a section whole

Cross-references resolve in one batch per pass, the index reads every leader gap
before filling one, and a zone's tabs lay out with the other zones' — and only
where a field asks for the page count or the chapter map.
List marker and list style decorations are rebuilt only for a transaction
reaching into a list; the unsaved dot compares documents structurally, and the
autosave serializes when it writes rather than per keystroke.
The columns flow applies every op it can judge in a pass, so a section pages in
one pass. Two long files that took 33 s and 20 s now settle in 9 s and 2 s.
This commit is contained in:
Steffen Becker committed 2026-09-08 22:31:01 +02:00
1 parent 3af5a00fed
commit da857fcbb8
16 files changed
+478 -230

No files matched your search

+21
View File
@@ -258,6 +258,27 @@ try {
(p) => `${p.getAttribute('data-wrap-band') ?? '-'}/${getComputedStyle(p).clear}`).join(' '));
check(bands === '-/none true/none -/both anchored/none -/none true/none true/none -/both',
`loaded from the autosave, the block after a band frame clears it, after an anchored one it does not (${bands})`);
// A two-column section over several pages pages in one pass: a continuation is judged
// with a full page wherever it renders, and the split counts the blocks' margins as the
// overflow test does — else one block moves down per pass, a pass per block.
const lines = [];
for (let i = 0; i < 300; i++) {
lines.push({ type: 'paragraph', attrs: { spaceAfter: 6 }, content: [words(`Line ${i + 1}: ${LOREM.slice(0, 5 + (i % 5)).join(' ')}`)] });
}
await page.evaluate((d) => localStorage.setItem('edentext-doc', JSON.stringify(d)), { type: 'doc', content: [
block(words('before the section')), { type: 'columns', attrs: { count: 2 }, content: lines }, block(words('after the section')),
] });
await page.addInitScript(() => {
window.__passes = 0;
document.addEventListener('pm-pagecount', () => { window.__passes++; });
});
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
await settle(page, true);
const flow = await page.evaluate(() => ({ passes: window.__passes, fragments: document.querySelectorAll('.tiptap > .columns-node').length }));
check(flow.fragments >= 3 && flow.passes <= 8,
`a two-column section over ${flow.fragments} pages settles in ${flow.passes} passes`);
} catch (err) {
check(false, `dom run threw: ${err.message ?? err}`);
} finally {
+3 -3
View File
@@ -43,7 +43,7 @@ describe('autosave crash recovery', () => {
await loadDocument();
expect(await loadDocument()).toBeNull(); // recovered: boot flag cleared, doc parked
vi.useFakeTimers();
saveDocument({ type: 'doc' });
saveDocument(() => ({ type: 'doc' }));
await vi.runAllTimersAsync();
vi.useRealTimers();
expect(await loadDocument()).toEqual({ type: 'doc' });
@@ -97,7 +97,7 @@ describe('autosave flush on pagehide', () => {
it('writes the pending document when the page is hidden', () => {
vi.useFakeTimers();
saveDocument({ type: 'doc', content: [{ type: 'paragraph' }] });
saveDocument(() => ({ type: 'doc', content: [{ type: 'paragraph' }] }));
expect(localStorage.getItem('edentext-doc')).toBeNull();
window.dispatchEvent(new Event('pagehide'));
expect(JSON.parse(localStorage.getItem('edentext-doc')!)).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] });
@@ -106,7 +106,7 @@ describe('autosave flush on pagehide', () => {
it('writes nothing when nothing is pending', async () => {
vi.useFakeTimers();
saveDocument({ type: 'doc' });
saveDocument(() => ({ type: 'doc' }));
await vi.runAllTimersAsync();
localStorage.removeItem('edentext-doc');
window.dispatchEvent(new Event('pagehide'));
+61
View File
@@ -0,0 +1,61 @@
// Which transactions the list-marker decorations are rebuilt for: a marker takes its
// format from the item's first text portion, so an edit inside an item counts, and a
// mark step counts by its own range — its position map is empty.
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import { extensions } from '../../src/lib/editor/extensions';
import { touchesList } from '../../src/lib/editor/extensions/listMarker';
const text = (t: string) => ({ type: 'text', text: t });
const para = (t: string) => ({ type: 'paragraph', content: [text(t)] });
const item = (t: string) => ({ type: 'listItem', content: [para(t)] });
// "outside" fills 1..8; the list starts at 9, its first item's text at 12.
const content = {
type: 'doc',
content: [para('outside'), { type: 'bulletList', content: [item('alpha'), item('beta')] }, para('tail')],
};
function makeEditor() {
const el = document.createElement('div');
document.body.appendChild(el);
return new Editor({ element: el, extensions, content });
}
describe('touchesList', () => {
it('sees an edit in a list item and ignores one outside every list', () => {
const editor = makeEditor();
const { doc } = editor.state;
const at = doc.resolve(1).after(1); // after the first paragraph = the list's position
const inItem = at + 3; // inside the first item's paragraph
expect(touchesList(editor.state.tr.insertText('x', inItem))).toBe(true);
expect(touchesList(editor.state.tr.insertText('x', 2))).toBe(false);
editor.destroy();
});
it('sees a mark applied inside an item, whose position map is empty', () => {
const editor = makeEditor();
const bold = editor.state.schema.marks.bold;
const at = editor.state.doc.resolve(1).after(1);
const inItem = at + 3;
const marked = editor.state.tr.addMark(inItem, inItem + 4, bold.create());
expect(marked.steps[0].getMap().ranges.length).toBe(0);
expect(touchesList(marked)).toBe(true);
expect(touchesList(editor.state.tr.addMark(2, 6, bold.create()))).toBe(false);
editor.destroy();
});
it('sees the list node itself change, and a list inserted where there was none', () => {
const editor = makeEditor();
const at = editor.state.doc.resolve(1).after(1);
expect(touchesList(editor.state.tr.setNodeAttribute(at, 'markerAlign', 'right'))).toBe(true);
const list = editor.state.schema.nodeFromJSON({ type: 'bulletList', content: [item('new')] });
expect(touchesList(editor.state.tr.insert(1, list))).toBe(true);
editor.destroy();
});
});