Skip to content

perf: incremental layout pipeline (rebased #246 + review fixes)#263

Closed
jedrazb wants to merge 6 commits intomainfrom
perf/incremental-layout-v2
Closed

perf: incremental layout pipeline (rebased #246 + review fixes)#263
jedrazb wants to merge 6 commits intomainfrom
perf/incremental-layout-v2

Conversation

@jedrazb
Copy link
Copy Markdown
Contributor

@jedrazb jedrazb commented Apr 15, 2026

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: auto on 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 the DecorationLayer import. Verified setTransactionVersion((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:

File Fix
toFlowBlocks.ts:1022 convertTopLevelNode no longer breaks out of the switch for numId == null/0. Section-break emission now runs for any paragraph, not just non-list ones.
incrementalBlockCache.ts:410 rebuildIndices uses a running pos counter instead of per-iteration getNodeStartPos(). Drops saveBlockState from O(n^2) to O(n).
incrementalBlockCache.ts:181 updateBlocks now returns dirtyBlockEnd so callers know the real converted range (including list-counter propagation).
PagedEditor.tsx:2109 resumeFrom.dirtyTo uses the returned dirtyBlockEnd instead of the hardcoded dirtyFrom + 10. Convergence checks no longer start inside the dirty region.
layout-engine/index.ts:418 earlyExitBlock is now tracked in a dedicated earlyExitIndex variable. The old code reported statesAtBlock.length - 1 which gets filled from prevStates during splice and returned the wrong index.
PagedEditor.tsx:1921 et al Per-step console.debug calls are now gated 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 are kept.

One Copilot finding skipped on YAGNI grounds: cumulativeY only advances for blocks with totalHeight, not Image/TextBox. The full-pipeline measureBlocks has 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 through toFlowBlocks:206-227. Multi-paragraph comment anchors live in the Document model (commentRangeStart / commentRangeEnd) which this PR does not touch. The #258 resync hook (setTransactionVersion bumping on every transaction) is preserved, so the comment sidebar, y-cursor awareness, and decoration overlays stay live during typing.

Test plan

Credit for the pipeline design, measurements, and tests goes to @sicko7947. This PR is the rebase + follow-up fixes.

sicko7947 and others added 5 commits April 15, 2026 15:45
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>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docx-editor Ready Ready Preview, Comment Apr 16, 2026 5:38am

Request Review

@jedrazb
Copy link
Copy Markdown
Contributor Author

jedrazb commented Apr 16, 2026

I ran performance test on this end-to-end against main and #267 on the 310-page perf benchmark (n=30):

Branch Start-of-doc keystroke latency
main 82 ms
#263 125 ms (+53%)
#263 with duplicate-pipeline bug patched ~77 ms (−5% vs main)

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.

@jedrazb jedrazb closed this Apr 16, 2026
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.

2 participants