Skip to content

fix(perf): rebuild the list-index map lazily on read instead of per operation#2802

Draft
christianhg wants to merge 1 commit into
mainfrom
editor-perf-lazy-list-index
Draft

fix(perf): rebuild the list-index map lazily on read instead of per operation#2802
christianhg wants to merge 1 commit into
mainfrom
editor-perf-lazy-list-index

Conversation

@christianhg

Copy link
Copy Markdown
Member

Editing a document rebuilt listIndexMap from scratch on every structural operation, walking the entire value each time, so a batch of N operations did N full O(value) walks before React ever rendered. Nothing reads the map until render, and the rebuild was gated on operation.path.length <= 2, silently assuming list-item numbering can only live at the document root.

listIndexMap (the source of data-list-index on rendered list items) is consumed only by the text-block renderer, never by operations or selectors. Structural operations now just mark it dirty (on any path, dropping the depth assumption) and getListIndexMap rebuilds it the next time the renderer reads it. A batch of operations collapses to one rebuild per render regardless of size, and the map stays correct for structural changes at any depth, not just the root.

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

This is one of two extracted performance PRs. The other (editor-perf-dedupe-key-scan) removes a separate per-operation O(n) rescan from duplicate-key normalization; the two target different costs and are additive. They touch the same engine fields (subscribeUpdateValue, the engine type, engine setup), so whichever merges second needs a trivial rebase.

Chromium, headless, medians of 3 runs, tests/performance.test.tsx, this change alone vs main:

scenario before after
insert 1000 blocks (empty editor) 244ms 237ms
insert 1000 tables (empty editor) 723ms 681ms

The larger win shows on list-heavy or large documents, where the per-operation full walk dominated; the generic insert benchmark only lightly exercises it.

…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.
@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:48am
portable-text-example-basic Ready Ready Preview, Comment Jun 17, 2026 8:48am
portable-text-playground Ready Ready Preview, Comment Jun 17, 2026 8:48am

Request Review

@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4e4ded3

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

@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) 786.1 KB +107 B, +0.0%
Internal (gzip) 150.1 KB +79 B, +0.1%
Bundled (raw) 1.39 MB +115 B, +0.0%
Bundled (gzip) 311.9 KB +72 B, +0.0%
Import time 95ms -0ms, -0.2%

@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.7%

@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.0%

@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, +1.4%

@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, +2.9%

@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, -0.6%

🗺️ . · ./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 39ms +0ms, +1.2%

🗺️ 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.

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