Skip to content

First paint / time-to-ready is gated on full-document measure+layout and scales linearly with page count, despite page virtualization #3869

Description

@ZacharyHampton

What happened?

Time-to-first-paint (and editor-ready) scales linearly with document length, because the layout pipeline measures every block and paginates the entire document — up to several convergence passes — before a single page is painted, even though the DOM painter then virtualizes and only mounts a handful of pages.

Production observation (superdoc@2.3.0, v2 engine integration, collaborative open, app-level telemetry only): on real legal documents with comments and footnotes we measure ~75 ms/page of pre-paint work — a 55-page DOCX reaches ready in ~4.1 s, a 247-page document in ~18 s — with everything else warm (module imports 0 ms, document bytes already in memory, collaboration-ready wait <5 ms after layout finishes). The viewport shows one page; ~2–4 .superdoc-page nodes are materialized.

Reproduction in this repo's own dev harness (v1 path, AGPL, pnpm dev): a synthetic ~120-page/913-block DOCX (generator script below) with SD_DEBUG_LAYOUT timers enabled shows the same architecture at smaller magnitude on plain content:

[Perf] toFlowBlocks: 37.70ms (blocks=914)
[Perf] 4.1 Measure all blocks: 716.70ms (913 measured, 0 cached, 0 reused)
[Perf] 4.2 Layout document (pagination): 152.50ms
[Perf] 4.3 Page token resolution converged after 0 iterations
[Perf] incrementalLayout: 872.60ms
[Perf] painter.paint: 17.00ms   <- first pixels, after everything above

Measure-all is 82% of the run and linear in block count; nothing paints until it and full pagination complete. On complex documents the convergence passes multiply this: page-token resolution can re-run layoutDocument up to 3× (layout-bridge/src/incrementalLayout.ts:1558-1655) and footnote reserves up to 4 more passes (:2686, MAX_FOOTNOTE_LAYOUT_PASSES at :462) — worst case the whole document is paginated up to ~8× before first paint.

Where the gate lives (AGPL source, current main):

  • Single monolithic pass: measure loop over all blocks at packages/layout-engine/layout-bridge/src/incrementalLayout.ts:1136; whole-document layoutDocument at :1539 (packages/layout-engine/layout-engine/src/index.ts:759, block loop at :2200).
  • resolveLayout then eagerly converts all pages on every paint (packages/layout-engine/layout-resolved/src/resolveLayout.ts:658) even though the painter mounts ~4.
  • First paint happens only after all of the above: packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts:7736 (#rerender() at :7262).
  • Meanwhile the painter already virtualizes beautifully (packages/layout-engine/painters/dom/src/renderer.ts:1600, default { window: 5, overscan: 1 } from packages/superdoc/src/core/SuperDoc.ts:593-600) — the system already knows only ~4 pages matter, but layout, resolve, and the ready gate still process all of them.
  • IncrementalLayoutResult is a single terminal value — no partial results, no progress callback, and LayoutOptions has no maxPages/deadline/abort surface.

Notably, packages/layout-engine/tests/src/performance.bench.ts:24-29 budgets 500 ms time-to-first-paint for a 50-page document, and :191-193 notes the benchmark simulates layout, so it cannot catch this. Our production numbers are ~7.5× over that budget at 50 pages, growing linearly.

Feature requests (in order of value to us)

  1. Prefix paint / first-page ready: lay out and paint the first N pages, resolve ready, and complete pagination in the background (provisional PAGE/NUMPAGES tokens repainted on completion — the footnote warm-start seed at incrementalLayout.ts:88 is precedent for provisional-then-revalidated results).
  2. Layout progress events (blocks measured / pages so far) surfaced on the public event bus alongside layoutUpdated — small and additive; today there is no signal between "boot started" and "everything done".
  3. Demand-driven resolveLayout (page range or lazy) — independent win; it is a whole-document pass per paint for ~4 mounted pages.
  4. A question: does the v2 engine integration share this design, and does v2's ready explicitly await complete layout? (In the v1 source, ready fires from a 0 ms timer — Editor.ts:1699 — and only appears layout-gated because pagination runs synchronously.)

We would be glad to contribute the progress-events change and discuss the prefix-paint design as PRs (CLA review permitting — same team as #3862/#3863).

Steps to reproduce

  1. git clone https://github.com/superdoc/docx-editor && cd docx-editor && pnpm install
  2. Surface the existing phase timers (they are keyed on process.env.SD_DEBUG_LAYOUT, which is dead in the browser dev server): add 'process.env.SD_DEBUG_LAYOUT': JSON.stringify('1') to the define block in packages/superdoc/vite.config.js.
  3. pnpm dev, open the harness (layout engine is on by default), upload a large DOCX, and read the [Perf] lines: nothing paints until 4.1 Measure all blocks and 4.2 Layout document complete over the whole document.
  4. Generator for a synthetic ~120-page test document (pure synthetic content):
# pip install python-docx
from docx import Document
from docx.shared import Pt
import random
random.seed(42)
doc = Document()
words = ("the company shall deliver notice of any material adverse change in the ordinary course "
         "of business consistent with past practice including without limitation any amendment "
         "modification or waiver of any provision under the agreement").split()
doc.add_heading('Synthetic Layout Benchmark Document', 0)
for section in range(1, 61):
    doc.add_heading(f'Section {section}.0 - Synthetic Provisions', level=1)
    for para in range(14):
        text = ' '.join(random.choice(words) for _ in range(random.randint(40, 90)))
        p = doc.add_paragraph(f'{section}.{para+1}  ' + text.capitalize() + '.')
        p.paragraph_format.space_after = Pt(8)
    if section % 5 == 0:
        t = doc.add_table(rows=6, cols=3)
        t.style = 'Table Grid'
        for r in range(6):
            for c in range(3):
                t.cell(r, c).text = f'Item {section}-{r}-{c}: ' + ' '.join(random.choice(words) for _ in range(8))
doc.save('synthetic-120p.docx')

Scale range(1, 61) up and add comments/footnotes to approach production magnitudes.

SuperDoc version

2.3.0 (v2 engine integration) in production; repro analysis against this repo's main (2a9df40)

Browser

Chrome 143 (macOS, M-series)

Additional context

Our interim mitigation host-side: prefetching document bytes, warming module chunks, and keeping booted editors alive across route changes (reparenting the mount node into a hidden size-constrained host — virtualization survives as long as the constrained overflow:auto container is preserved). A supported detach()/attach(container) API — or a statement that reparenting is supported behavior — would let us rely on contract rather than incident. Happy to provide traces or test branches.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions