Skip to content

fix(perf): stop per-operation O(n) rescans during bulk structural edits#2801

Closed
christianhg wants to merge 2 commits into
mainfrom
editor-perf-gaming
Closed

fix(perf): stop per-operation O(n) rescans during bulk structural edits#2801
christianhg wants to merge 2 commits into
mainfrom
editor-perf-gaming

Conversation

@christianhg

@christianhg christianhg commented Jun 17, 2026

Copy link
Copy Markdown
Member

Inserting many blocks at once, or editing inside a large document, did work whose cost grew with the size of the whole document on every operation, so a bulk insert of N blocks spent O(N^2) in the engine before React ever rendered. Two independent per-operation rescans were responsible; this removes both. Each is its own commit.

listIndexMap rebuilt on every operation

listIndexMap (the source of data-list-index on rendered list items) is derived from the whole value, because list-item numbering depends on block adjacency. The update-value subscriber rebuilt it from scratch after every operation whose path was at most two segments deep, walking all root blocks each time. A batch of N root operations therefore did N full walks, O(N^2), even though nothing reads the map until the next render, and the path.length <= 2 gate silently assumed list numbering can only live at the document root.

The only reader is the text-block renderer. Structural operations now just mark the map dirty (on any path, dropping the depth assumption) and getListIndexMap rebuilds it on the next read, so a batch collapses to a single rebuild per render and the map stays correct for structural changes at any depth.

Duplicate-_key normalization scanned siblings per node

normalizeNode's duplicate-key fix resolved one node at a time: for each dirty node it fetched the node's siblings and scanned them for a collision, and at the root it rebuilt a wrapper array (value.map(...) plus a spread) per node. Normalizing a sibling group of n nodes was O(n^2) and, at the root, allocated O(n) throwaway arrays per node, so a bulk insert of pre-keyed blocks spent O(n^2) here finding nothing, because the keys were already unique.

A sibling group's key-uniqueness only changes when its membership changes, which mints a fresh parent reference. Nested groups now cache the verified-unique verdict in a WeakMap keyed by parent node and re-scan only when that reference changes. The root group has no parent node (its container is the editor, whose reference never changes), so its verdict lives in editor.rootKeysVerifiedUnique, set after a clean scan and cleared by the op stream only when an operation can add, remove, or re-key a direct root child (affectsRootMembership). The scan itself reads the raw child array straight off the parent node (the field segment is already in the node's path) instead of re-resolving children through the schema, and short-circuits groups of one child, which can never collide. The membership condition is exact, not a depth heuristic, and over-reports rather than under-reports, so a missed duplicate is impossible.

Blast radius

Net behavior is unchanged: rendered data-list-index values are identical, and duplicate keys are still detected and the same later occurrence re-keyed in the same order. Only when the work happens changed (per-operation to per-read for the list map; once-per-group instead of once-per-node for the key scan). The full unit suite (801) and the chromium (1645) and webkit (1624) browser suites pass; tests/focus.test.tsx fails only in firefox and fails identically on main (it passes in isolation; a pre-existing focus-stealing flake), so it is untouched by this change.

Chromium, headless, medians of 3 runs, tests/performance.test.tsx:

scenario before after
insert 1000 blocks (empty editor) 244ms 210ms
insert 1000 blocks before a block 182ms 149ms
insert 1000 blocks after a block 159ms 130ms
insert 1000 tables (empty editor) 723ms 624ms
insert 1000 tables before a block 739ms 635ms
insert 1000 tables after a block 767ms 591ms

The remaining time is dominated by React mounting the 1000 components, which is inherent and untouched.

…peration

`listIndexMap` (the source of `data-list-index` on rendered list items)
is derived from the whole value: list-item numbering depends on block
adjacency, so any structural operation could change it. The update-value
subscriber rebuilt it from scratch after every operation whose path was
at most two segments deep, walking all root blocks each time. A bulk edit
applying N root operations therefore did O(N) full walks, i.e. O(N^2),
even though nothing reads the map until the next render, and the
`path.length <= 2` gate silently assumed list numbering can only live at
the document root.

The only reader is the text-block renderer. Structural operations now
just mark the map dirty (on any path, dropping the depth assumption) and
`getListIndexMap` rebuilds it on the next read. A batch of operations
collapses to a single rebuild per render regardless of size, and the map
is recomputed for structural changes at any depth.

Net behavior unchanged: the rendered `data-list-index` values are
identical; only when the rebuild happens moved from per-operation to
per-read.
… per node

normalizeNode's duplicate-`_key` fix resolved one node at a time: for each
dirty node it fetched the node's siblings and scanned them for a key
collision. At the root it rebuilt a wrapper array (`value.map(...)` plus a
spread) on every node. Normalizing a sibling group of n nodes therefore
scanned O(n^2) and, at the root, allocated O(n) throwaway arrays per node,
so a bulk insert of n pre-keyed blocks spent O(n^2) in this check alone,
finding nothing because the keys were already unique.

A sibling group's key-uniqueness only changes when its membership changes,
which mints a fresh parent reference. Nested groups now cache the verified-
unique verdict in a `WeakMap` keyed by parent node and re-scan only when
that reference changes. The root group has no parent node (its container is
the editor, whose reference never changes), so its verdict lives in
`editor.rootKeysVerifiedUnique`, set after a clean scan and cleared by the
op stream only when an operation can add, remove, or re-key a direct root
child (`affectsRootMembership`). The scan reads the raw child array off the
parent node (the field segment is already in the node's path) instead of
re-resolving children through the schema, and short-circuits groups of one
child, which can never collide.

Net behavior unchanged: duplicate keys are detected and the same later
occurrence is re-keyed, in the same order; only the redundant rescans are
removed. The membership condition is exact, not a depth heuristic, and
over-reports rather than under-reports, so a missed duplicate is impossible.
@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 23f053c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment Jun 17, 2026 8:11am
portable-text-example-basic Ready Ready Preview, Comment Jun 17, 2026 8:11am
portable-text-playground Ready Ready Preview, Comment Jun 17, 2026 8:11am

Request Review

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (525dac02)

@portabletext/editor

Metric Value vs main (525dac0)
Internal (raw) 787.7 KB +1.7 KB, +0.2%
Internal (gzip) 150.4 KB +440 B, +0.3%
Bundled (raw) 1.39 MB +1.8 KB, +0.1%
Bundled (gzip) 312.3 KB +419 B, +0.1%
Import time 99ms -2ms, -2.1%

@portabletext/editor/behaviors

Metric Value vs main (525dac0)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms -0ms, -0.8%

@portabletext/editor/plugins

Metric Value vs main (525dac0)
Internal (raw) 2.7 KB -
Internal (gzip) 894 B -
Bundled (raw) 2.5 KB -
Bundled (gzip) 827 B -
Import time 7ms -0ms, -1.7%

@portabletext/editor/selectors

Metric Value vs main (525dac0)
Internal (raw) 78.5 KB -
Internal (gzip) 14.4 KB -
Bundled (raw) 74.0 KB -
Bundled (gzip) 13.3 KB -
Import time 8ms +0ms, +0.7%

@portabletext/editor/traversal

Metric Value vs main (525dac0)
Internal (raw) 24.6 KB -
Internal (gzip) 4.9 KB -
Bundled (raw) 24.5 KB -
Bundled (gzip) 4.8 KB -
Import time 6ms +0ms, +3.4%

@portabletext/editor/utils

Metric Value vs main (525dac0)
Internal (raw) 28.8 KB -
Internal (gzip) 6.0 KB -
Bundled (raw) 26.7 KB -
Bundled (gzip) 5.7 KB -
Import time 6ms +0ms, +1.9%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @portabletext/markdown

Compared against main (525dac02)

Metric Value vs main (525dac0)
Internal (raw) 53.0 KB -
Internal (gzip) 9.6 KB -
Bundled (raw) 347.6 KB -
Bundled (gzip) 96.0 KB -
Import time 41ms +2ms, +4.3%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@christianhg

Copy link
Copy Markdown
Member Author

Superseded by two isolated PRs: #2802 (lazy list-index map) and #2803 (verified-unique sibling-group key scan). The duplicate-key change there is reworked to use a serialized-path set invalidated by the op stream rather than a WeakMap keyed on the parent node, so it is uniform at every depth and an edit deep inside a large container no longer re-scans its ancestors.

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