perf: the document's fields answer a pass in one round

Every field resolving against the settled layout — each index's page numbers, every
cross-reference — registers with scheduleFieldRound instead of taking a frame of its
own. A round measures for all of them before any of them writes, since a write between
two measurements lays the whole document out again, and their cached results ride one
transaction: a dispatch of its own costs a view update over the document, 55 ms per
index on a 460-page file. That file settles about 220 ms earlier, with every spacer,
page number and reference exactly where it was on five documents.
This commit is contained in:
Steffen Becker committed 2026-09-09 00:51:54 +02:00
1 parent 50c7ec5236
commit 7e687b6ccb
5 files changed
+82 -44

No files matched your search

+12 -20
View File
@@ -3,7 +3,7 @@ import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import type { EditorView } from '@tiptap/pm/view';
import { bookmarks, findBookmark, type BookmarkRef } from './bookmark';
import { readVerticalMargins, pageOfElement, type PageGrid } from './pageBreaks';
import { readVerticalMargins, pageOfElement, scheduleFieldRound, type FieldWrite, type PageGrid } from './pageBreaks';
// A cross-reference: an inline atom showing either the text of a bookmark or the page it
// sits on, kept live by the node view the way the TOC keeps its page numbers. Round-trips
@@ -100,11 +100,10 @@ function resolveText(view: EditorView, node: PMNode, target: BookmarkRef | undef
}
// Every reference of one editor resolves in one go, on each pagination settle
// (pm-pagecount) and on a change to any of them: one bookmark scan, every page read
// before any text is written, and one transaction for the texts that changed.
// (pm-pagecount) and on a change to any of them: one bookmark scan, and every page read
// in the field round's measuring phase, before any text of the round is written.
class CrossRefBatch {
private views = new Set<CrossRefView>();
private scheduled = false;
private listening = false;
constructor(private editor: Editor) {}
@@ -119,15 +118,11 @@ class CrossRefBatch {
}
schedule(): void {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
this.scheduled = false;
this.run();
});
if (this.editor.isDestroyed) return;
scheduleFieldRound(this.editor.view, this, () => this.measure());
}
private run(): void {
private measure(): FieldWrite | void {
const { editor } = this;
if (editor.isDestroyed) return;
const view = editor.view;
@@ -146,15 +141,12 @@ class CrossRefBatch {
if (pos === null || !node || node.type.name !== 'crossRef' || !ref.dom.isConnected) continue;
jobs.push({ ref, node, pos, text: resolveText(view, node, targets.get(String(node.attrs.name ?? '')), gridOf) });
}
for (const j of jobs) j.ref.paint(j.node, j.text);
const tr = editor.state.tr.setMeta('addToHistory', false);
let changed = false;
for (const j of jobs) {
if (j.text === j.node.attrs.text) continue;
tr.setNodeAttribute(j.pos, 'text', j.text);
changed = true;
}
if (changed) view.dispatch(tr);
return (tr) => {
for (const j of jobs) j.ref.paint(j.node, j.text);
for (const j of jobs) {
if (j.text !== j.node.attrs.text) tr.setNodeAttribute(j.pos, 'text', j.text);
}
};
}
}
+35
View File
@@ -329,6 +329,41 @@ export const pageBreakKey = new PluginKey<PageBreakState>('pageBreaks');
// pagination recompute even though the document content is unchanged.
export const FORCE_PAGE_RECALC = 'forcePageBreakRecalc';
// A field that reads the settled layout — an index's page numbers, a cross-reference's.
// It measures, and what it returns writes: its own DOM, and its cached result on the
// round's transaction. A write may return one more phase, run after every other write.
export type FieldWrite = (tr: Transaction) => FieldWrite | void;
export type FieldRead = () => FieldWrite | void;
type FieldRound = { jobs: Map<object, FieldRead>; raf: number | null };
const fieldRounds = new WeakMap<EditorView, FieldRound>();
// Every field of one editor answers a pagination pass together: all measure before any
// writes, or each write lays the whole document out again for the next reader — and one
// transaction carries the results, a dispatch costing a view update over the document.
export function scheduleFieldRound(view: EditorView, owner: object, read: FieldRead): void {
let round = fieldRounds.get(view);
if (!round) fieldRounds.set(view, (round = { jobs: new Map(), raf: null }));
round.jobs.set(owner, read);
if (round.raf === null) round.raf = requestAnimationFrame(() => runFieldRound(view));
}
function runFieldRound(view: EditorView): void {
const round = fieldRounds.get(view);
if (!round) return;
round.raf = null;
let phase = Array.from(round.jobs.values(), (read) => read());
round.jobs.clear();
const tr = view.state.tr.setMeta('addToHistory', false);
// Bounded: measure, write, and the one read-back a write can ask for.
for (let i = 0; i < 2 && phase.some(Boolean); i++) {
phase = phase.map((write) => (write ? write(tr) : undefined));
}
// A field that only moved its own rows carries no document change, and still needs the
// pass to measure the height it just changed.
if (tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC)) view.dispatch(tr);
}
// A split view's second pane (Editor.svelte marks its host). Both panes are the same
// width, so it renders the decorations the first pane's pass produced; a layout pass of
// its own would measure the same DOM twice and fight over the shared result.
+24 -23
View File
@@ -9,7 +9,8 @@ import { seqCategoryOf, sequenceFieldText, type SeqCategory } from './caption';
import { indexEntries, indexRows } from './indexEntry';
import { bibliographyEntries, bibliographyRows } from './bibliographyEntry';
import { isCitationStyle, type CitationStyle } from '../../utils/citationStyle';
import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC, type PageGrid } from './pageBreaks';
import type { Transaction } from '@tiptap/pm/state';
import { readVerticalMargins, pageOfElement, topInEditor, scheduleFieldRound, FORCE_PAGE_RECALC, type FieldWrite, type PageGrid } from './pageBreaks';
// A generated index: a block atom listing every source with its live page number — the
// headings for a table of contents, the captions of one category for a list of figures
@@ -199,13 +200,13 @@ function inCellOrItem(doc: PMNode, pos: number): boolean {
}
// Node view: renders the title + one clickable row per heading. Recomputes on each
// pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change,
// writing entries back to the node attr — guarded by a serialized key against a loop.
// pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change, in
// the field round: it measures with the other fields, and writes its entries back on the
// round's transaction — guarded by a serialized key against a loop.
class TocView {
dom: HTMLElement;
private editor: Editor;
private getPos: () => number;
private scheduled = false;
private lastKey = '';
private lastLook = '';
private wasPaginated = false;
@@ -225,17 +226,13 @@ class TocView {
requestAnimationFrame(() => {
this.paper = this.dom.closest('.paper') as HTMLElement | null;
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
this.render();
this.schedule();
});
}
private schedule(): void {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
this.scheduled = false;
this.render();
});
if (this.editor.isDestroyed) return;
scheduleFieldRound(this.editor.view, this, () => this.measure());
}
// What the index lists, in document order: the headings down to its own level, or —
@@ -308,7 +305,7 @@ class TocView {
return pageOfElement(this.editor.view, el, grid);
}
private render(): void {
private measure(): FieldWrite | void {
if (this.editor.isDestroyed || !this.dom.isConnected) return;
const grid = this.grid();
let heads = this.sources();
@@ -329,18 +326,23 @@ class TocView {
const { entries: _cached, ...look } = this.node()?.attrs ?? {};
this.lastLook = JSON.stringify(look);
const key = JSON.stringify([entries, look]);
if (key !== this.lastKey) {
this.lastKey = key;
this.paint(entries, heads);
this.syncAttr(entries);
}
this.paginate();
const stale = key !== this.lastKey;
this.lastKey = key;
return (tr) => {
if (stale) {
this.paint(entries, heads);
this.syncAttr(entries, tr);
}
// The rows carry their page numbers now, so where they fall is read once every
// field of the round has written.
return (last) => this.paginate(last);
};
}
// The index is a block atom: it has no inner document positions for pagination to put
// a spacer at, so one longer than a page breaks itself — the row that would cross the
// boundary takes the gap to the next page's content top as its margin.
private paginate(): void {
private paginate(tr: Transaction): void {
const rows = Array.from(this.dom.querySelectorAll<HTMLElement>('.toc-entry'));
if (!rows.length) return;
const view = this.editor.view;
@@ -364,7 +366,7 @@ class TocView {
// The index just changed height, and pagination measured the old one.
if (moved !== this.wasPaginated) {
this.wasPaginated = moved;
view.dispatch(view.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true));
tr.setMeta(FORCE_PAGE_RECALC, true);
}
}
@@ -476,14 +478,13 @@ class TocView {
return typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null;
}
private syncAttr(entries: TocEntry[]): void {
private syncAttr(entries: TocEntry[], tr: Transaction): void {
const pos = this.getPos();
if (typeof pos !== 'number') return;
const node = this.editor.state.doc.nodeAt(pos);
if (!node || node.type.name !== 'tableOfContents') return;
if (JSON.stringify(node.attrs.entries ?? []) === JSON.stringify(entries)) return;
const tr = this.editor.state.tr.setNodeAttribute(pos, 'entries', entries).setMeta('addToHistory', false);
this.editor.view.dispatch(tr);
tr.setNodeAttribute(pos, 'entries', entries);
}
update(node: PMNode): boolean {