Skip to content

fix(perf): render the root block list in memoized chunks#2906

Draft
christianhg wants to merge 2 commits into
mainfrom
chunked-root-children
Draft

fix(perf): render the root block list in memoized chunks#2906
christianhg wants to merge 2 commits into
mainfrom
chunked-root-children

Conversation

@christianhg

@christianhg christianhg commented Jul 3, 2026

Copy link
Copy Markdown
Member

Typing in a large document paid a React cost proportional to the total block count: the whole document renders as one flat array under a single Children component, so every content edit re-ran the root useChildren (rebuilding all n JSX elements, ~12.3ms per keystroke at 8k blocks) and forced React to reconcile all n child positions (fresh props objects per rebuild force the MemoizedElement comparator per child before it can bail). The per-node memo chain held, only the edited block's components actually re-rendered, but the rebuild and reconcile above it were O(n) per keystroke. This is the flat-root term from the render-architecture review (EDEX-1488), the dominant per-keystroke cost after #2893 and #2900, and the classic Slate scaling wall: upstream Slate grew experimental chunking for exactly this, and this codebase removed its inherited copy as unused (58d4eb11b), so this is a fresh root-only design rather than a revert.

The root children now render as contiguous chunks. editor.rootChunks is maintained per operation by transformRootChunks (internal-utils/root-chunks.ts), which follows the transformBlockIndexMap pattern exactly: a pure per-op transform that resolves the operation's root index against the pre-op blockIndexMap (verified against the chunks, scan fallback for unmaintained maps, full-rebuild fallback for unresolvable shapes), rebuilds only the owning chunk's block array, splits chunks above 200 blocks, and drops empty ones. Chunks never merge, a deliberate ceiling marked in the code: scattered deletions can accumulate small chunks and degrade the dispatch back toward the flat behavior in the pathological limit, and merge-with-neighbor is the upgrade if chunk counts ever show up in a profile. One ordering subtlety carries the correctness: every op except set.selection replaces the owning root block's reference (the tree updates immutably), so the transform must see text ops too and runs before the update-value subscriber's text-op early return. The oracle test pins flatten(chunks) === value across a 400-step fuzzed op sequence plus explicit identity-reuse, split, drop, map-miss, and numeric-path cases. The unit fuzz simulates op semantics by hand, so a browser integration test closes the loop against the real engine: it asserts flatten(editor.rootChunks) equals the value by per-block reference equality through a mixed session (bulk insert across chunk boundaries, real typing, splits, backspace merges, undos, select-all delete across every chunk, undo of the delete).

On the render side, the root useChildren dispatches to MemoizedChunk components keyed by chunk id, each re-entering useChildren with an explicit chunkBlocks slice. splitDecorationsByChild accepts the same override, so every chunk resolves the full root decorations against its own key-to-index map and decorations addressing blocks outside the chunk fall away naturally, no separate per-chunk decoration splitting exists. The chunk memo bails on chunk identity (the transform's contract: a chunk object is replaced exactly when its own blocks changed), render-prop identity, and decoration content equality, content rather than identity because the editable recreates the root decorations array every render. Chunks render as fragments, so the contenteditable DOM is byte-identical to before: selection, IME, toDOMNode, and RestoreDOM are untouched, which is the property that makes this a contained render refactor instead of a risky editor feature.

One regression the suite caught and the fix it forced: chunk-level render decisions (container resolution, pipeline membership) read the registration maps, and the chunk memo blocks the root re-render that used to refresh them, container-normalization.test.tsx failed on unmounting a ContainerPlugin. Chunks therefore subscribe to the useRegistrationsSelector channel from #2900, which fires only on renderer register/unregister; registrations are config-time, so the subscription is off the hot path. Everything else that used to reach blocks through root re-renders arrives via contexts, which pierce the memo.

React-side cost per keystroke (chromium, warmed and settled, medians): root dispatch plus one chunk body is 0.87ms at 1k blocks and 1.27ms at 8k, versus 0.85ms and 12.3ms for the flat map, size-independent where it was linear, with exactly one chunk body re-running per keystroke. Probe wall time per keystroke at 8k dropped from ~61ms to ~23.5ms. Full suite green: 848 unit including the new transform oracle, 1694 chromium. A second commit adds a typing-in-a-large-document baseline to the performance test file.

@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b12e714

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 Jul 3, 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 Jul 3, 2026 12:02pm
portable-text-example-basic Ready Ready Preview, Comment Jul 3, 2026 12:02pm
portable-text-playground Ready Ready Preview, Comment Jul 3, 2026 12:02pm

Request Review

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (9a98074b)

@portabletext/editor

Metric Value vs main (9a98074)
Internal (raw) 803.1 KB +4.5 KB, +0.6%
Internal (gzip) 153.6 KB +1.0 KB, +0.7%
Bundled (raw) 1.41 MB +4.5 KB, +0.3%
Bundled (gzip) 317.8 KB +1.1 KB, +0.3%
Import time 97ms -0ms, -0.4%

@portabletext/editor/behaviors

Metric Value vs main (9a98074)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms -0ms, -0.4%

@portabletext/editor/plugins

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

@portabletext/editor/selectors

Metric Value vs main (9a98074)
Internal (raw) 80.1 KB -
Internal (gzip) 14.7 KB -
Bundled (raw) 75.5 KB -
Bundled (gzip) 13.5 KB -
Import time 8ms -0ms, -0.9%

@portabletext/editor/traversal

Metric Value vs main (9a98074)
Internal (raw) 26.9 KB -
Internal (gzip) 5.3 KB -
Bundled (raw) 26.7 KB -
Bundled (gzip) 5.2 KB -
Import time 6ms +0ms, +0.9%

@portabletext/editor/utils

Metric Value vs main (9a98074)
Internal (raw) 29.7 KB -
Internal (gzip) 6.2 KB -
Bundled (raw) 27.1 KB -
Bundled (gzip) 5.8 KB -
Import time 6ms -0ms, -1.1%

🗺️ . · ./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 (9a98074b)

Metric Value vs main (9a98074)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 39ms +2ms, +4.4%

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

The document rendered as one flat array of children under a single
`Children` component. Every content edit re-ran the root `useChildren`
(rebuilding all n JSX elements, ~12.3ms per keystroke at 8k blocks,
~1.5us per child) and forced React to reconcile all n positions (fresh
props objects defeat element-identity shortcuts, so the
`MemoizedElement` comparator ran per child). The per-node memo chain
held, only the edited block's components re-rendered, but the rebuild
and reconcile above it were O(n) per keystroke.

The root children now render as contiguous chunks. `editor.rootChunks`
(`internal-utils/root-chunks.ts`) is maintained per operation by
`transformRootChunks`, following the `transformBlockIndexMap` pattern:
a pure transform that resolves the operation's root index against the
pre-op `blockIndexMap` (verified against the chunks, with a scan
fallback for unmaintained maps and a full-rebuild fallback for
unresolvable shapes), rebuilds only the owning chunk's block array,
splits chunks above 200 blocks, and drops empty ones. Every op except
`set.selection` refreshes the owning chunk because the tree updates
immutably, even a text op replaces the owning root block's reference;
the transform therefore runs before the update-value subscriber's
text-op early return. The oracle test pins `flatten(chunks) === value`
across a 400-step fuzzed op sequence plus explicit identity-reuse
cases.

At the root, `useChildren` dispatches to `MemoizedChunk` components
(one per chunk, keyed by chunk id) which re-enter `useChildren` with
an explicit `chunkBlocks` slice; `splitDecorationsByChild` accepts the
same override, so each chunk resolves the full root decorations
against its own key-to-index map and decorations outside the chunk
fall away naturally. The chunk memo bails on chunk identity (the
transform's contract), render-prop identity, and decoration content
equality (the root decorations array is recreated every render, so
identity comparison would defeat the memo). Chunks render as
fragments: the contenteditable DOM is byte-identical, which leaves
selection, IME, `toDOMNode`, and `RestoreDOM` untouched.

Chunk-level render decisions (container resolution, pipeline
membership) read the registration maps, and the chunk memo blocks the
root re-render that used to refresh them, caught by the
container-normalization suite. Chunks therefore subscribe to the
registrations selector channel, which fires only on renderer
register/unregister; registrations are config-time, so this is off the
hot path.

React-side cost per keystroke (chromium, warmed, settled): root
dispatch + one chunk body 0.87ms at 1k blocks and 1.27ms at 8k, versus
0.85ms and 12.3ms for the flat map, size-independent where it was
linear. Probe wall time per keystroke at 8k: ~61ms to ~23.5ms. Full
suite green: 848 unit (including the new transform oracle), 1694
chromium.
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