fix(perf): render the root block list in memoized chunks#2906
Draft
christianhg wants to merge 2 commits into
Draft
fix(perf): render the root block list in memoized chunks#2906christianhg wants to merge 2 commits into
christianhg wants to merge 2 commits into
Conversation
🦋 Changeset detectedLatest commit: b12e714 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📦 Bundle Stats —
|
| 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.
822bd75 to
d6f81a5
Compare
d6f81a5 to
6d3778a
Compare
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.
6d3778a to
b12e714
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Childrencomponent, so every content edit re-ran the rootuseChildren(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 theMemoizedElementcomparator 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.rootChunksis maintained per operation bytransformRootChunks(internal-utils/root-chunks.ts), which follows thetransformBlockIndexMappattern exactly: a pure per-op transform that resolves the operation's root index against the pre-opblockIndexMap(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 exceptset.selectionreplaces 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 pinsflatten(chunks) === valueacross 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 assertsflatten(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
useChildrendispatches toMemoizedChunkcomponents keyed by chunk id, each re-enteringuseChildrenwith an explicitchunkBlocksslice.splitDecorationsByChildaccepts 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, andRestoreDOMare 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.tsxfailed on unmounting aContainerPlugin. Chunks therefore subscribe to theuseRegistrationsSelectorchannel 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.