Incremental layout & page reuse
A keystroke in a 7,000-page book must not re-lay out 7,000 pages. Folio's layout is incremental at three levels, and every level is verified against a full layout.
const cache = new LayoutCache();
let previous: DocumentLayout | undefined;
function layout(doc: FolioDocument) {
previous = layoutDocument(doc, { measurer, theme, cache, ...(previous ? { previous } : {}) });
return previous;
}Pass the same cache and the previous layout of the same document on every call. Nothing else is needed.
1. Block measurement is cached by identity
LayoutCache is a WeakMap from block node to its measured Flow, keyed by the content width, direction and page content height. Because nodes are immutable, an unchanged block is the same object after an edit and hits the cache; an edited block is a new object and is re-measured. Use one cache per (theme, measurer) pair.
Formatter hints are applied on top of cached flows through a memo, so hinted blocks keep their identity too.
2. Pagination resumes and converges
With previous, layout compares the old and new top-level block arrays by identity (and their hints) to find the unchanged prefix and suffix:
- Flows of prefix and suffix blocks are reused as-is.
- Leading pages whose whole break-search window ends before the first changed block are kept; pagination resumes at the first page the edit can affect.
- Pagination is memoryless given (start position, carried header, page capacity). So as soon as a new page starts inside the unchanged suffix at exactly the position where an old page started (shifted by the edit's height change), with the same carry and capacity, every later old page is valid, shifted. Pagination stops there and reuses the old tail.
Typing inside a paragraph usually changes one page; even Enter, which shifts every later block, converges after a page or two instead of repaginating the book.
3. Page objects are reused
Unchanged pages are returned as the same PageLayout objects, so renderers can skip them with a plain identity check (React.memo, a WeakMap):
- Leading untouched pages, and converged trailing pages whose block indices didn't shift, are reused directly; if only their page number or
{{total}}changed, the page is re-wrapped around the same content and only the header/footer is rebuilt. - Other pages are compared by a signature (block range, flows by identity, offsets, height, carry, number, total, theme/header/footer/variables). Equal signature means identical fragments, so the old page object is returned.
Environment inputs (theme, measurer, math, mediaSize, renderers, headerFooter, variables, firstPageNumber, totalPages, cache) are compared by identity, or by shallow equality for plain objects. Inline literals like headerFooter: { footer: {…} } still defeat reuse if their nested objects are rebuilt each call, so keep them stable (e.g. useMemo).
Lazy pages
page.fragments and page.chrome are lazy, non-enumerable getters: a page's fragments are built on first read and cached. A 7,000-page layout only materializes the pages someone draws, exports or hit-tests.
Non-enumerable matters: generic walkers (devtools prop diffs, loggers, JSON.stringify) must not force every page to build. If you need a page's fragments, read the property explicitly.
How it's verified
packages/layout/src/incremental.test.ts applies 300 random edits (edit, insert, delete, shrink, headings, forced breaks, keep-together) to an 80-block document with a seeded PRNG and asserts after each one that the incremental layout equals a fresh full layout, fragment by fragment. It also asserts the incremental paths really ran (most pages returned untouched). Any change to pagination must keep this test green.
Appending (progressive loading)
Appending blocks at the end is the cheapest edit of all: the prefix is everything, and pagination resumes near the old last page. @nextgensoftwares/folio-sync uses this to lay out a book chunk by chunk as it streams in.
What still costs O(blocks)
Per edit, Folio still does O(blocks) bookkeeping: the prefix/suffix scan, arranging offsets, and ProseMirror's flat top-level child array (resolve, replace). These are cheap integer loops (a few ms at 35,000 blocks). The planned fix is Word-like section nodes, which would also bring per-section page setup.