Skip to content

perf(editor-ops): copy-on-write structural sharing for editing - #7

Open
nathan-gage wants to merge 1 commit into
extend-hq:mainfrom
nathan-gage:ngage/editing-perf-structural-sharing
Open

perf(editor-ops): copy-on-write structural sharing for editing#7
nathan-gage wants to merge 1 commit into
extend-hq:mainfrom
nathan-gage:ngage/editing-perf-structural-sharing

Conversation

@nathan-gage

Copy link
Copy Markdown

Summary

Editing a loaded document deep-cloned the entire DocModel on every keystroke. Each single-node editor-ops transform (typing, run-style toggles, paragraph insert/remove/duplicate, paste, single-cell table edits) opened with cloneDocModel(model), which walks every paragraph/run/table plus all metadata (paragraph styles, numbering, header/footer/footnote trees). So per-edit cost scaled with total document size, not the edited region.

This PR makes those ops copy-on-write: a single-node edit deep-clones only the touched node and shares metadata and every untouched sibling node by reference.

Approach

  • New helpers in @extend-ai/react-docx-doc-model:
    • cloneDocModelWithNode(model, index) — shares metadata + siblings by reference; deep-clones only nodes[index] via the same authoritative cloneDocNode.
    • cloneDocModelNodes(model) — shallow nodes-array copy for splice ops (insert/remove/paste).
  • editor-ops single-node and splice ops routed through them. The edited node is produced by the same cloneDocNode, so node content is byte-identical to before — only sibling/metadata sharing changes.
  • cloneDocModel is unchanged and still used by replaceText (which genuinely touches every node).

Why it's safe

  • Undo history stores models by reference and every op clones-before-mutate, so untouched shared subtrees are never mutated.
  • New editor-ops identity tests assert: untouched siblings + metadata keep their identity across an edit, and the input model is never mutated.

Benchmark

Per-keystroke editing ops on a realistic document (multi-run paragraphs + 30 paragraph styles + numbering definitions + header/footer sections). Median ms over 25 iterations, from tests/benchmarks/editing-performance.test.ts.

Hardware: Apple M4 Pro (14 cores), 48 GB RAM, macOS 26.5, Node 24.12.0.

operation paragraphs before after speedup
insert character 500 0.114 ms 0.002 ms ~57×
insert character 2000 0.288 ms 0.002 ms ~144×
insert character 5000 0.673 ms 0.002 ms ~337×
toggle / apply run style 500 0.152 ms 0.001 ms ~152×
toggle / apply run style 2000 0.558 ms 0.001 ms ~558×
toggle / apply run style 5000 1.440 ms 0.002 ms ~720×
paste paragraphs 500 0.073 ms 0.001 ms ~73×
paste paragraphs 2000 0.282 ms 0.003 ms ~94×
paste paragraphs 5000 0.671 ms 0.005 ms ~134×

Per-edit cost is now flat vs document size (was linear). The "after" figures sit near the measurement floor, so the exact multipliers are approximate — the point is the asymptotic change from O(document) to O(edited node). Numbers are single-machine medians and will vary by hardware.

Testing

  • pnpm test:unit: 231 passed / 18 failed. The 18 failures are pre-existing on main (tests importing symbols not yet present in the source, e.g. resolveUiActiveTextRange, isLikelyFullPageCoverFloatingImage) and are unrelated to this change — 0 new failures.
  • pnpm typecheck: clean across all packages + the playground.
  • Manually verified in the playground that a real ~35-page DOCX imports and renders identically (this change does not touch the import/render path — only edit operations).

Notes

  • Relocated the perf tests to tests/benchmarks/ (the existing performance.test.ts moved alongside the new editing benchmark) and added the directory to the vitest config.
  • Includes a changeset (patch bump for @extend-ai/react-docx).

🤖 Generated with Claude Code

Single-paragraph and single-cell editor operations deep-cloned the entire
DocModel on every edit via cloneDocModel, making per-keystroke cost
O(document size). Add copy-on-write helpers (cloneDocModelWithNode /
cloneDocModelNodes) in doc-model and route the single-node and splice ops
through them, so an edit deep-clones only the touched node and shares
metadata + sibling nodes by reference. The edited node is produced by the
same cloneDocNode, so content is byte-identical; document-wide replaceText
keeps the full clone.

Per-keystroke cost is now flat vs document size (~50-700x faster at 5000
paragraphs on an M4 Pro). Untouched nodes keep object identity, enabling
downstream identity-based memoization.

Adds an editing benchmark and structural-sharing identity tests, and
relocates the perf tests to tests/benchmarks/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

@nathan-gage is attempting to deploy a commit to the Extend Team on Vercel.

A member of the Team first needs to authorize it.

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