Skip to content

Documents: instant typing and faster opening in long documents - #139

Merged
jelveh merged 7 commits into
mainfrom
docs-latency/a-docx
Oct 28, 2026
Merged

jelveh merged 7 commits into
mainfrom
docs-latency/a-docx

Conversation

@jelveh

@jelveh jelveh commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Typing in a long document with many lists, a header and footer, and hundreds of hyperlinks lagged on every keystroke and froze shortly after each pause; opening it took nearly two seconds. This fixes the underlying causes. Each one was found by profiling a real 28-page, 1,500-paragraph document (176 lists, 299 hyperlinks, 16 pictures). The document is not included here.

Results (Chrome, production build, same machine)

main this PR
Open → editable 1.75–1.80 s 1.30 s
Keystroke, input to paint (avg / worst, 40-key burst) 72 / 168 ms 26 / 72 ms
Typing quickly: keys over 50 ms every key (~92 ms each) none
Autosave stall after a pause in typing 398 ms 98 ms
Toolbar refresh after each keystroke 8.7 ms 3.6 ms

Causes and fixes

  • List definitions in every header and footer key. Each keystroke lays out every page's header and footer from the measured-story cache. That cache's key held the whole list definitions table (here ~518 KB as JSON), serialized and then hashed for every page: about 45 ms per keystroke. A layout now serializes the styles and lists once, shared by the body and its page stories, and keys name them by the cache's short ids. A header or footer without page fields no longer takes the page number into its key, so it is measured once instead of once per page.
  • Autosave on the main thread. Each save re-parsed the whole body XML to get its skeleton, parsed each unchanged paragraph's source XML with its own DOMParser (1,500 parsers), and deflated the package on the page's thread. Now:
    • The skeleton comes from the parse made at opening.
    • Each paragraph's source XML is parsed once, ahead of the first save, a little at a time while the page is idle (DocxPackage.prepareSave).
    • Autosave zips in a module worker (package-zip.js), falling back to the page's thread where workers are unavailable.
    • Downloads and other explicit exports are unchanged (export() stays synchronous; autosave uses exportAsync()).
  • Controls rereading the document. After every keystroke the controls listed tracked changes seven times (each pass reads every run), parsed the 60 KB relationships part to find the theme and the bibliography, and parsed the theme. The change list is now kept per model revision, and the relationships and theme colors per version of their part bytes.
  • Design tab at opening. It parsed the whole body XML twice (plus every header) to find a watermark and page fill. Those are now read only when their menus ask.
  • Paragraph measuring at opening (~40% faster). Line building did a lot of per-character overhead around canvas measureText. Now:
    • The fitting pass's builder is reused for a paragraph's last line.
    • Characters share one style object per font slot.
    • Shaping keys are computed once per style.
    • The measurer's results are read without copying.
    • Plain ASCII skips the grapheme segmenter.
  • Unchanged paragraphs keep their cache key strings across layouts, so lookups no longer hash the whole document's text again.

Verification

  • Saving: byte-for-byte identical before and after for all 2,257 fixture and corpus documents. Each was opened, edited and saved twice in Chrome, so the cached path is covered too.
  • Layout: body and page-story layouts identical for all 2,257 documents. On 207 of them it was also checked after inserting characters, reusing the measurer each time.
  • Unit suite: 10,564 tests pass. The new and touched tests also pass on Node 22.
  • Build and licenses: npm run build and npm run check:licenses pass.
  • Browser specs: the related specs pass (typing and undo, review tools, design and themes, headers, lists, watermarks, autosave, save dialog, cloud lifecycle). paragraph-end-typing fails the same way on main.
  • New tests:
    • page-story-cache-keys.test.js: stories without fields are measured once; a list definition changed in place still relays out.
    • revision-list-cache.test.js: the change list follows edits and undo; rewritten relationships and theme parts are read again.
    • save-preparation.test.js: repeated saves after edits equal a freshly opened package's; exportAsync produces the same parts.
    • line-measure-reuse.test.js.

Not in this PR

  • A faster .docx reader (about 0.5 s of opening) is in progress as a follow-up PR.
  • When keys arrive faster than the browser goes idle, a keystroke can still pay for Undo's full-document copy (~18 ms). The model shares objects between paragraphs and styles, so that copy must stay a single structured clone; a cheaper exact copy measured no faster.

🤖 Generated with Claude Code

…r or footer

Every keystroke laid out each page's header and footer again from the
measured-story cache, whose key held the document's whole list
definitions. A document with 176 lists serialized half a megabyte of
them into the key of every page's header and footer, then hashed each
key to look it up: about 45 ms of each keystroke in a 28-page document.

A layout now serializes the list definitions once, like the styles, and
the keys name them by the cache's short id. A header or footer without
page fields no longer takes the page number into its key either, so it
is measured once rather than once a page, at opening and after.
…d them

Opening a document read its watermark and page background for the Design
tab at once, parsing the whole body XML twice (and every header): about
140 ms of opening a 670 KB document. Each piece of the package's design
is now read the first time something asks for it.
…he document

After each keystroke the controls listed the document's tracked changes
seven times, each a pass over every run, parsed the main document's
relationships (a document with 300 hyperlinks has 60 KB of them) to find
the theme and the bibliography, and parsed the theme. The changes are
now listed once per document revision, and the relationships and theme
colors once per version of their parts: 8.7 ms -> 3.6 ms per keystroke.
…nged paragraphs' keys

The body and its headers and footers each serialized the styles and list
definitions for their cache keys on every keystroke; one layout now
serializes each once for both. An unchanged paragraph also keeps the key
strings of the last layout, so looking every paragraph up no longer
hashes the whole document's text again: about 1 ms of each keystroke in
a 1,500-paragraph document.
An autosave a moment after typing stopped held the page for about
380 ms in a 1,500-paragraph document, so keys typed then waited. The
save parsed the whole body XML again just to keep its root and final
section properties, parsed each unchanged paragraph's source XML with
a parser of its own, and deflated the package on the page's thread.

The save's starting skeleton is now taken from the parse made when the
document opened, each paragraph's source XML is parsed once (ahead of
the first save, a little at a time while the page is idle), and the
autosave zips the parts in a worker, falling back to the page's thread
where workers are unavailable: 380 ms -> 96 ms of the page's time.
Saved files are byte for byte the same as before for all 2,257 fixture
and corpus documents, edited and saved twice each.
Opening a document lays every paragraph out once, measuring the text of
each line prefix by prefix. A 28-page document of 1,500 paragraphs spent
about 255 ms there; profiling showed the canvas measureText calls were
only about 50 ms of it. The rest was repeated bookkeeping around them:

- Every line was built twice: once to find where it breaks and again to
  build the result, repeating each measurement. A paragraph's last line
  is exactly what the first pass built when it ran out of characters, so
  it is now used as is. Most paragraphs are a single line.
- Each character copied its run's whole format (40 properties), and each
  line's text groups were new style objects, so the measurer serialized
  thousands of them to key its memory. A run's characters now share one
  style per font slot, and a group measures as the style it was copied
  from (it differs only in layout's own output fields).
- Characters were built with conditional object spreads, the shaping key
  string was rebuilt for every character, and color, underline and
  background decisions were recomputed per character; they are now
  built plainly, computed once per style object, and skipped when the
  style object has not changed.
- The geometry layer copied each remembered measurement before copying
  it again, built the bundled decoration defaults it never used for
  canvas measurements, and segmented ASCII text into graphemes.

Layout output is unchanged: every line, fragment, caret stop and metric
of the 2,256 test and public fixtures and a large real document is
identical before and after, including after typing into its longest
paragraph with the same measurer.

Non-minified production build, opening the 28-page document in Chrome:
validateCanvasLayout 293 -> 195 ms, paragraph measuring 254 -> 151 ms. A
generated document of 120 paragraphs of 1,200 characters: first layout
464 -> 298 ms, relayout after a keystroke 14.8 -> 12.7 ms.
@jelveh
jelveh merged commit b63d24f into main Oct 28, 2026
6 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant