Skip to content

Commit 0d1a545

Browse files
committed
fix(perf): render the root block list in memoized chunks
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.
1 parent 9a98074 commit 0d1a545

10 files changed

Lines changed: 692 additions & 6 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@portabletext/editor': patch
3+
---
4+
5+
fix(perf): render the root block list in memoized chunks
6+
7+
Typing and editing in large documents no longer pays a React
8+
reconciliation cost proportional to the total block count. The root
9+
block list now renders in contiguous chunks whose components skip
10+
re-rendering unless one of their own blocks changed, so a keystroke
11+
rebuilds and reconciles one chunk instead of the whole document. The
12+
rendered DOM is unchanged, chunks render as fragments.

packages/editor/src/editor/create-editor-engine.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {BlockIndexMap} from '../internal-utils/block-index-map'
55
import {buildIndexMaps} from '../internal-utils/build-index-maps'
66
import {createPlaceholderBlock} from '../internal-utils/create-placeholder-block'
77
import {debug} from '../internal-utils/debug'
8+
import {buildRootChunks} from '../internal-utils/root-chunks'
89
import type {PortableTextEditorEngine} from '../types/editor-engine'
910
import type {EditorActor} from './editor-machine'
1011
import {setupRemotePatches} from './remote-patches'
@@ -104,6 +105,8 @@ export function createEditorEngine(
104105
editor: editorEngine,
105106
})
106107

108+
editor.rootChunks = buildRootChunks(editor.snapshot.context.value)
109+
107110
buildIndexMaps(
108111
{
109112
schema: context.schema,

packages/editor/src/editor/subscriber.update-value.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {Node} from '../engine/interfaces/node'
33
import type {EngineOperation} from '../engine/interfaces/operation'
44
import type {Path} from '../engine/interfaces/path'
55
import {debug} from '../internal-utils/debug'
6+
import {transformRootChunks} from '../internal-utils/root-chunks'
67
import {safeStringify} from '../internal-utils/safe-json'
78
import {
89
transformBlockIndexMap,
@@ -198,6 +199,17 @@ export function subscribeUpdateValue(
198199
return
199200
}
200201

202+
// Chunk maintenance must see every content operation (even a text op
203+
// replaces the owning root block's reference) and must run before
204+
// `transformBlockIndexMap` because it resolves the operation's root
205+
// index against the pre-op map state.
206+
editor.rootChunks = transformRootChunks(
207+
editor.rootChunks,
208+
operation,
209+
editor.blockIndexMap,
210+
editor.snapshot.context.value,
211+
)
212+
201213
if (operation.type === 'insert.text' || operation.type === 'remove.text') {
202214
// Inserting and removing text has no effect on index maps so there is
203215
// no need to rebuild those.

packages/editor/src/engine/dom/utils/range-list.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,19 @@ export const splitDecorationsByChild = (
116116
node: Editor | Node,
117117
path: Path,
118118
decorations: DecoratedRange[],
119+
childrenOverride?: readonly Node[],
119120
): DecoratedRange[][] => {
120-
const children: readonly unknown[] = isEditor(node)
121-
? editor.snapshot.context.value
122-
: isTextBlock({schema: editor.snapshot.context.schema}, node)
123-
? node.children
124-
: []
121+
// A chunk passes its own slice of the root children; decorations
122+
// addressing blocks outside the slice resolve to no child index and
123+
// are skipped, so passing the full root decorations to every chunk
124+
// is correct.
125+
const children: readonly unknown[] =
126+
childrenOverride ??
127+
(isEditor(node)
128+
? editor.snapshot.context.value
129+
: isTextBlock({schema: editor.snapshot.context.schema}, node)
130+
? node.children
131+
: [])
125132
const decorationsByChild = Array.from(children, (): DecoratedRange[] => [])
126133

127134
if (decorations.length === 0) {

packages/editor/src/engine/react/hooks/use-children.tsx

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import {
1010
ParentContainerContext,
1111
useParentContainer,
1212
} from '../../../editor/parent-container-context'
13+
import type {RootChunk} from '../../../internal-utils/root-chunks'
1314
import type {ContainerConfig} from '../../../renderers/renderer.types'
1415
import {isObject} from '../../../traversal/is-object'
16+
import {isElementDecorationsEqual} from '../../dom/utils/range-list'
1517
import {isEditor} from '../../editor/is-editor'
1618
import type {Editor} from '../../interfaces/editor'
1719
import type {Node} from '../../interfaces/node'
@@ -28,6 +30,7 @@ import ElementComponent from '../components/element'
2830
import ObjectNodeComponent from '../components/object-node'
2931
import TextComponent from '../components/text'
3032
import {useDecorationsByChild} from './use-decorations-by-child'
33+
import {useRegistrationsSelector} from './use-engine-selector'
3134
import {useEngineStatic} from './use-engine-static'
3235

3336
/**
@@ -64,6 +67,13 @@ const useChildren = (props: {
6467
renderElement: (props: RenderElementProps) => JSX.Element
6568
renderText?: (props: RenderTextProps) => JSX.Element
6669
renderLeaf?: (props: RenderLeafProps) => JSX.Element
70+
/**
71+
* A chunk's slice of the root children. When set, elements are built
72+
* for exactly these blocks instead of the node's own children; when
73+
* the node is the editor root and this is unset, rendering dispatches
74+
* to memoized chunk components instead of building elements here.
75+
*/
76+
chunkBlocks?: Array<Node>
6777
}): React.ReactNode => {
6878
const {
6979
decorations,
@@ -72,7 +82,9 @@ const useChildren = (props: {
7282
renderElement,
7383
renderText,
7484
renderLeaf,
85+
chunkBlocks,
7586
} = props
87+
const renderAsChunks = isEditor(node) && chunkBlocks === undefined
7688
const editor = useEngineStatic()
7789
editor.isNodeMapDirty = false
7890

@@ -84,6 +96,7 @@ const useChildren = (props: {
8496
node,
8597
parentPath,
8698
decorations,
99+
renderAsChunks ? EMPTY_CHILDREN : chunkBlocks,
87100
)
88101

89102
let children: Array<Node> = []
@@ -94,7 +107,9 @@ const useChildren = (props: {
94107
// is itself a container object.
95108
let childContainer: ContainerConfig | undefined = parentContainer
96109

97-
if (isEditor(node)) {
110+
if (chunkBlocks !== undefined) {
111+
children = chunkBlocks
112+
} else if (isEditor(node)) {
98113
children = node.snapshot.context.value
99114
} else if (isTextBlock({schema: editor.snapshot.context.schema}, node)) {
100115
children = node.children
@@ -287,6 +302,19 @@ const useChildren = (props: {
287302
return false
288303
}
289304

305+
if (renderAsChunks) {
306+
return editor.rootChunks.map((chunk) => (
307+
<MemoizedChunk
308+
chunk={chunk}
309+
decorations={decorations}
310+
key={chunk.id}
311+
renderElement={renderElement}
312+
renderLeaf={renderLeaf}
313+
renderText={renderText}
314+
/>
315+
))
316+
}
317+
290318
const elements = children.map((n: Node, i: number) => {
291319
if (isTextBlock({schema: editor.snapshot.context.schema}, n)) {
292320
return wrapNewPipeline(
@@ -343,4 +371,65 @@ const useChildren = (props: {
343371
return <>{elements}</>
344372
}
345373

374+
const EMPTY_CHILDREN: Array<Node> = []
375+
376+
const registrationMapsSelector = (editor: Editor) =>
377+
[
378+
editor.containers,
379+
editor.textBlocks,
380+
editor.blockObjects,
381+
editor.inlineObjects,
382+
editor.spans,
383+
] as const
384+
385+
const tupleRefEqual = <T extends readonly unknown[]>(
386+
a: T | null,
387+
b: T,
388+
): boolean =>
389+
a !== null &&
390+
a.length === b.length &&
391+
a.every((item, index) => item === b[index])
392+
393+
const Chunk = (props: {
394+
chunk: RootChunk
395+
decorations: DecoratedRange[]
396+
renderElement: (props: RenderElementProps) => JSX.Element
397+
renderText?: (props: RenderTextProps) => JSX.Element
398+
renderLeaf?: (props: RenderLeafProps) => JSX.Element
399+
}) => {
400+
const editor = useEngineStatic()
401+
// Chunk-level render decisions (container resolution, pipeline
402+
// membership) read the registration maps, and the chunk memo blocks
403+
// the root re-render that used to refresh them. Re-render the chunk
404+
// when any registration map is swapped; registrations are
405+
// config-time, so this is off the hot path.
406+
useRegistrationsSelector(registrationMapsSelector, tupleRefEqual)
407+
const children = useChildren({
408+
chunkBlocks: props.chunk.blocks,
409+
decorations: props.decorations,
410+
node: editor,
411+
path: [],
412+
renderElement: props.renderElement,
413+
renderLeaf: props.renderLeaf,
414+
renderText: props.renderText,
415+
})
416+
return <>{children}</>
417+
}
418+
419+
/**
420+
* Bails unless the chunk's own blocks changed (`transformRootChunks`
421+
* replaces the chunk object exactly then) or the decorations changed by
422+
* content (the root decorations array is recreated every render, so
423+
* identity comparison would defeat the memo).
424+
*/
425+
const MemoizedChunk = React.memo(Chunk, (prev, next) => {
426+
return (
427+
prev.chunk === next.chunk &&
428+
prev.renderElement === next.renderElement &&
429+
prev.renderText === next.renderText &&
430+
prev.renderLeaf === next.renderLeaf &&
431+
isElementDecorationsEqual(prev.decorations, next.decorations)
432+
)
433+
})
434+
346435
export default useChildren

packages/editor/src/engine/react/hooks/use-decorations-by-child.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@ export const useDecorationsByChild = (
5959
node: Editor | Node,
6060
path: Path,
6161
decorations: DecoratedRange[],
62+
childrenOverride?: readonly Node[],
6263
): DecoratedRange[][] => {
6364
const decorationsByChild = splitDecorationsByChild(
6465
editor,
6566
node,
6667
path,
6768
decorations,
69+
childrenOverride,
6870
)
6971
const [stable, setStable] = useState<DecoratedRange[][]>(decorationsByChild)
7072
const next = stabilizeDecorationsByChild(stable, decorationsByChild)

0 commit comments

Comments
 (0)