Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/chunked-root-children.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@portabletext/editor': patch
---

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

Typing and editing in large documents no longer pays a React
reconciliation cost proportional to the total block count. The root
block list now renders in contiguous chunks whose components skip
re-rendering unless one of their own blocks changed, so a keystroke
rebuilds and reconciles one chunk instead of the whole document. The
rendered DOM is unchanged, chunks render as fragments.
3 changes: 3 additions & 0 deletions packages/editor/src/editor/create-editor-engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {BlockIndexMap} from '../internal-utils/block-index-map'
import {buildIndexMaps} from '../internal-utils/build-index-maps'
import {createPlaceholderBlock} from '../internal-utils/create-placeholder-block'
import {debug} from '../internal-utils/debug'
import {buildRootChunks} from '../internal-utils/root-chunks'
import type {PortableTextEditorEngine} from '../types/editor-engine'
import type {EditorActor} from './editor-machine'
import {setupRemotePatches} from './remote-patches'
Expand Down Expand Up @@ -104,6 +105,8 @@ export function createEditorEngine(
editor: editorEngine,
})

editor.rootChunks = buildRootChunks(editor.snapshot.context.value)

buildIndexMaps(
{
schema: context.schema,
Expand Down
12 changes: 12 additions & 0 deletions packages/editor/src/editor/subscriber.update-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {Node} from '../engine/interfaces/node'
import type {EngineOperation} from '../engine/interfaces/operation'
import type {Path} from '../engine/interfaces/path'
import {debug} from '../internal-utils/debug'
import {transformRootChunks} from '../internal-utils/root-chunks'
import {safeStringify} from '../internal-utils/safe-json'
import {
transformBlockIndexMap,
Expand Down Expand Up @@ -198,6 +199,17 @@ export function subscribeUpdateValue(
return
}

// Chunk maintenance must see every content operation (even a text op
// replaces the owning root block's reference) and must run before
// `transformBlockIndexMap` because it resolves the operation's root
// index against the pre-op map state.
editor.rootChunks = transformRootChunks(
editor.rootChunks,
operation,
editor.blockIndexMap,
editor.snapshot.context.value,
)

if (operation.type === 'insert.text' || operation.type === 'remove.text') {
// Inserting and removing text has no effect on index maps so there is
// no need to rebuild those.
Expand Down
17 changes: 12 additions & 5 deletions packages/editor/src/engine/dom/utils/range-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,19 @@ export const splitDecorationsByChild = (
node: Editor | Node,
path: Path,
decorations: DecoratedRange[],
childrenOverride?: readonly Node[],
): DecoratedRange[][] => {
const children: readonly unknown[] = isEditor(node)
? editor.snapshot.context.value
: isTextBlock({schema: editor.snapshot.context.schema}, node)
? node.children
: []
// A chunk passes its own slice of the root children; decorations
// addressing blocks outside the slice resolve to no child index and
// are skipped, so passing the full root decorations to every chunk
// is correct.
const children: readonly unknown[] =
childrenOverride ??
(isEditor(node)
? editor.snapshot.context.value
: isTextBlock({schema: editor.snapshot.context.schema}, node)
? node.children
: [])
const decorationsByChild = Array.from(children, (): DecoratedRange[] => [])

if (decorations.length === 0) {
Expand Down
91 changes: 90 additions & 1 deletion packages/editor/src/engine/react/hooks/use-children.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {
ParentContainerContext,
useParentContainer,
} from '../../../editor/parent-container-context'
import type {RootChunk} from '../../../internal-utils/root-chunks'
import type {ContainerConfig} from '../../../renderers/renderer.types'
import {isObject} from '../../../traversal/is-object'
import {isElementDecorationsEqual} from '../../dom/utils/range-list'
import {isEditor} from '../../editor/is-editor'
import type {Editor} from '../../interfaces/editor'
import type {Node} from '../../interfaces/node'
Expand All @@ -28,6 +30,7 @@ import ElementComponent from '../components/element'
import ObjectNodeComponent from '../components/object-node'
import TextComponent from '../components/text'
import {useDecorationsByChild} from './use-decorations-by-child'
import {useRegistrationsSelector} from './use-engine-selector'
import {useEngineStatic} from './use-engine-static'

/**
Expand Down Expand Up @@ -64,6 +67,13 @@ const useChildren = (props: {
renderElement: (props: RenderElementProps) => JSX.Element
renderText?: (props: RenderTextProps) => JSX.Element
renderLeaf?: (props: RenderLeafProps) => JSX.Element
/**
* A chunk's slice of the root children. When set, elements are built
* for exactly these blocks instead of the node's own children; when
* the node is the editor root and this is unset, rendering dispatches
* to memoized chunk components instead of building elements here.
*/
chunkBlocks?: Array<Node>
}): React.ReactNode => {
const {
decorations,
Expand All @@ -72,7 +82,9 @@ const useChildren = (props: {
renderElement,
renderText,
renderLeaf,
chunkBlocks,
} = props
const renderAsChunks = isEditor(node) && chunkBlocks === undefined
const editor = useEngineStatic()
editor.isNodeMapDirty = false

Expand All @@ -84,6 +96,7 @@ const useChildren = (props: {
node,
parentPath,
decorations,
renderAsChunks ? EMPTY_CHILDREN : chunkBlocks,
)

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

if (isEditor(node)) {
if (chunkBlocks !== undefined) {
children = chunkBlocks
} else if (isEditor(node)) {
children = node.snapshot.context.value
} else if (isTextBlock({schema: editor.snapshot.context.schema}, node)) {
children = node.children
Expand Down Expand Up @@ -287,6 +302,19 @@ const useChildren = (props: {
return false
}

if (renderAsChunks) {
return editor.rootChunks.map((chunk) => (
<MemoizedChunk
chunk={chunk}
decorations={decorations}
key={chunk.id}
renderElement={renderElement}
renderLeaf={renderLeaf}
renderText={renderText}
/>
))
}

const elements = children.map((n: Node, i: number) => {
if (isTextBlock({schema: editor.snapshot.context.schema}, n)) {
return wrapNewPipeline(
Expand Down Expand Up @@ -343,4 +371,65 @@ const useChildren = (props: {
return <>{elements}</>
}

const EMPTY_CHILDREN: Array<Node> = []

const registrationMapsSelector = (editor: Editor) =>
[
editor.containers,
editor.textBlocks,
editor.blockObjects,
editor.inlineObjects,
editor.spans,
] as const

const tupleRefEqual = <T extends readonly unknown[]>(
a: T | null,
b: T,
): boolean =>
a !== null &&
a.length === b.length &&
a.every((item, index) => item === b[index])

const Chunk = (props: {
chunk: RootChunk
decorations: DecoratedRange[]
renderElement: (props: RenderElementProps) => JSX.Element
renderText?: (props: RenderTextProps) => JSX.Element
renderLeaf?: (props: RenderLeafProps) => JSX.Element
}) => {
const editor = useEngineStatic()
// 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. Re-render the chunk
// when any registration map is swapped; registrations are
// config-time, so this is off the hot path.
useRegistrationsSelector(registrationMapsSelector, tupleRefEqual)
const children = useChildren({
chunkBlocks: props.chunk.blocks,
decorations: props.decorations,
node: editor,
path: [],
renderElement: props.renderElement,
renderLeaf: props.renderLeaf,
renderText: props.renderText,
})
return <>{children}</>
}

/**
* Bails unless the chunk's own blocks changed (`transformRootChunks`
* replaces the chunk object exactly then) or the decorations changed by
* content (the root decorations array is recreated every render, so
* identity comparison would defeat the memo).
*/
const MemoizedChunk = React.memo(Chunk, (prev, next) => {
return (
prev.chunk === next.chunk &&
prev.renderElement === next.renderElement &&
prev.renderText === next.renderText &&
prev.renderLeaf === next.renderLeaf &&
isElementDecorationsEqual(prev.decorations, next.decorations)
)
})

export default useChildren
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ export const useDecorationsByChild = (
node: Editor | Node,
path: Path,
decorations: DecoratedRange[],
childrenOverride?: readonly Node[],
): DecoratedRange[][] => {
const decorationsByChild = splitDecorationsByChild(
editor,
node,
path,
decorations,
childrenOverride,
)
const [stable, setStable] = useState<DecoratedRange[][]>(decorationsByChild)
const next = stabilizeDecorationsByChild(stable, decorationsByChild)
Expand Down
Loading
Loading