perf: incremental layout pipeline (rebased #246 + review fixes)#263
Closed
perf: incremental layout pipeline (rebased #246 + review fixes)#263
Conversation
The editor re-processed the entire document on every keystroke, causing 200-500ms input lag on 20+ page documents. This adds an incremental pipeline that only re-converts, re-measures, and re-paginates from the edited paragraph forward, with early exit when page breaks stabilize. **Incremental block conversion (Phase 1)** - New `IncrementalBlockCache` tracks previous doc state and uses ProseMirror's structural sharing (node identity comparison) to detect which top-level nodes changed — O(1) per node - Extracted `convertTopLevelNode()` from `toFlowBlocks()` for per-node incremental conversion - List counter snapshots at each node boundary for correct numbering after partial re-conversion - Forward propagation of list counter changes until stabilization **Incremental measurement** - `measureBlocksIncremental()` reuses cached measures for clean blocks before the dirty range, re-measures only from dirtyFrom forward - Full floating zone pre-scan still runs (fast, zones can shift) **Layout engine resume + early exit (Phase 2)** - Paginator `snapshot()`/`createPaginatorFromSnapshot()` API for capturing and restoring layout state at page boundaries - `layoutDocument()` accepts `resumeFrom` option to skip blocks before the dirty range and start from a saved paginator snapshot - Early exit: after 2 consecutive blocks past the dirty range converge with the previous layout state, remaining pages are spliced from the previous run — avoids re-paginating the entire tail - `applyContextualSpacingRange()` for partial spacing application **CSS containment (Phase 3)** - `content-visibility: auto` + `contain-intrinsic-size` on page shells so the browser skips layout/paint for off-screen pages **Pipeline integration** - PagedEditor detects doc changes via PM node identity comparison (works for both transaction-driven and direct relayout calls) - Deferred cache mutation: `updateBlocks()` returns results without mutating the cache; `applyIncrementalResult()` is called only after successful paint to prevent split-state on stale aborts - Per-step performance diagnostics via `console.debug` | Step | Full pipeline | Incremental | Speedup | |------|-------------|-------------|---------| | Block conversion | 1.3ms | 0.0ms | ∞ | | Measurement | 13.7ms | 0.5ms | **27x** | | Layout | 0.5ms | 0.3ms (resumed) | 1.7x | | Paint | 12.7ms | 12.7ms | 1x | | **Total** | **~28ms** | **~13ms** | **2x** | Steps 1-3 combined: **15ms → 0.8ms (19x faster)** On larger documents (500+ blocks), the savings scale linearly since unchanged blocks are completely skipped. - 36 new unit tests (incrementalBlockCache, paginator-snapshot, layout-resume) — all passing - 368/368 total unit tests passing - Demo-docx E2E suite passing - Typecheck clean across all 4 packages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Internal planning document, not needed in the upstream repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. incrementalBlockCache: compute real PM position delta from doc size difference at splice boundary instead of using dead blockDelta ternary. Reindex unconditionally when pmDelta != 0, fixing click-to-position and selection mapping after any edit. 2. PagedEditor: move onLayoutComplete to finally block so LayoutSelectionGate always unblocks, even on pipeline exceptions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. toFlowBlocks.convertTopLevelNode: paragraphs with numPr.numId == null/0 no longer break out of the switch early; section-break emission runs for any paragraph regardless of list state. 2. incrementalBlockCache.rebuildIndices: replace per-iteration getNodeStartPos() scan with a running pos counter. Drops saveBlockState from O(n^2) to O(n) on large docs. 3. updateBlocks: return dirtyBlockEnd so callers know the real converted range (includes list-counter propagation) instead of guessing with +10. 4. PagedEditor.runLayoutPipeline: pass dirtyBlockEnd to resumeFrom.dirtyTo so convergence checks start after the fully-converted range. 5. layout-engine.layoutDocument: track earlyExitIndex explicitly instead of deriving from statesAtBlock.length (which gets filled from prevStates during splice and reports the wrong index). 6. PagedEditor: gate per-step console.debug logs behind globalThis.__DOCX_EDITOR_LAYOUT_DEBUG__ so they don't spam prod and don't trip the no-console eslint rule. console.warn slow-path thresholds kept. Changeset: patch bump for docx-js-editor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Apr 16, 2026
Contributor
Author
|
I ran performance test on this end-to-end against
The "30×" is one sub-step, the PR's own table sums to 2× total pipeline, and even that doesn't land end-to-end because paint/DOM commit dominate on this workload, not the phases this PR touches. Given ~3,000 LOC im closing this PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebase of #246 on main with the outstanding review findings addressed.
What's in this PR
Same incremental layout pipeline as #246 by @sicko7947: ProseMirror node-identity dirty detection, paginator snapshot/restore, CSS
content-visibility: autoon page shells, 30x faster keystroke response on 20+ page docs. Full architecture and benchmarks in the original PR #246.What changed in this rebase
Merge conflict with main (#258
DecorationLayer+transactionVersion)Resolved in
packages/react/src/paged-editor/PagedEditor.tsx. Kept the PR's reformatted imports and re-added theDecorationLayerimport. VerifiedsetTransactionVersion((v) => v + 1)still fires on every transaction (not gated by the incremental path), so comment sidebar resync, yCursor awareness pings, and generic PM decoration forwarding all keep working.Reviewer findings from #246 addressed
Two blockers from @jedrazb were already fixed in 9af4ddb. On top of those, this PR addresses Copilot findings left on the original thread:
toFlowBlocks.ts:1022convertTopLevelNodeno longerbreaks out of the switch fornumId == null/0. Section-break emission now runs for any paragraph, not just non-list ones.incrementalBlockCache.ts:410rebuildIndicesuses a runningposcounter instead of per-iterationgetNodeStartPos(). DropssaveBlockStatefrom O(n^2) to O(n).incrementalBlockCache.ts:181updateBlocksnow returnsdirtyBlockEndso callers know the real converted range (including list-counter propagation).PagedEditor.tsx:2109resumeFrom.dirtyTouses the returneddirtyBlockEndinstead of the hardcodeddirtyFrom + 10. Convergence checks no longer start inside the dirty region.layout-engine/index.ts:418earlyExitBlockis now tracked in a dedicatedearlyExitIndexvariable. The old code reportedstatesAtBlock.length - 1which gets filled fromprevStatesduring splice and returned the wrong index.PagedEditor.tsx:1921et alconsole.debugcalls are now gated behindglobalThis.__DOCX_EDITOR_LAYOUT_DEBUG__so they don't spam prod and don't trip the no-console eslint rule.console.warnslow-path thresholds are kept.One Copilot finding skipped on YAGNI grounds:
cumulativeYonly advances for blocks withtotalHeight, not Image/TextBox. The full-pipelinemeasureBlockshas the same behavior, so the incremental path is consistent. If it's a real bug for floating-zone calculations, it affects both paths and belongs in a separate PR.Impact on comments and tracked changes
Comment marks and insertion/deletion marks are PM marks on text nodes. Adding or removing them creates new text nodes, so the parent paragraph fails
newDoc.child(i) === prevDoc.child(i), gets marked dirty, and is fully re-converted, re-reading all marks throughtoFlowBlocks:206-227. Multi-paragraph comment anchors live in the Document model (commentRangeStart/commentRangeEnd) which this PR does not touch. The #258 resync hook (setTransactionVersionbumping on every transaction) is preserved, so the comment sidebar, y-cursor awareness, and decoration overlays stay live during typing.Test plan
bun run typecheckclean across all four packagesincrementalBlockCache.test.ts,paginator-snapshot.test.ts,layout-resume.test.ts)text-editing,comment-button,demo-docx,paragraph-styles(46 + 3 passes, no new failures)formatting.spec.tsandlists.spec.tsflakes are pre-existing on PR perf: incremental layout pipeline for 30x faster keystroke response #246 base (confirmed by re-running on the unpatched commit). CLAUDE.md already flags formatting as known-flakyCredit for the pipeline design, measurements, and tests goes to @sicko7947. This PR is the rebase + follow-up fixes.