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:
1 parent
3af5a00fed
commit
da857fcbb8
16 files changed
+478
-230
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user