diff --git a/.changeset/chunked-root-children.md b/.changeset/chunked-root-children.md new file mode 100644 index 0000000000..f7e61bcf55 --- /dev/null +++ b/.changeset/chunked-root-children.md @@ -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. diff --git a/packages/editor/src/editor/create-editor-engine.tsx b/packages/editor/src/editor/create-editor-engine.tsx index f8a919cea8..aa32229c9b 100644 --- a/packages/editor/src/editor/create-editor-engine.tsx +++ b/packages/editor/src/editor/create-editor-engine.tsx @@ -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' @@ -104,6 +105,8 @@ export function createEditorEngine( editor: editorEngine, }) + editor.rootChunks = buildRootChunks(editor.snapshot.context.value) + buildIndexMaps( { schema: context.schema, diff --git a/packages/editor/src/editor/subscriber.update-value.ts b/packages/editor/src/editor/subscriber.update-value.ts index d2a570d3bd..860f60924f 100644 --- a/packages/editor/src/editor/subscriber.update-value.ts +++ b/packages/editor/src/editor/subscriber.update-value.ts @@ -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, @@ -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. diff --git a/packages/editor/src/engine/dom/utils/range-list.ts b/packages/editor/src/engine/dom/utils/range-list.ts index 72df769e2f..90dda977d1 100644 --- a/packages/editor/src/engine/dom/utils/range-list.ts +++ b/packages/editor/src/engine/dom/utils/range-list.ts @@ -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) { diff --git a/packages/editor/src/engine/react/hooks/use-children.tsx b/packages/editor/src/engine/react/hooks/use-children.tsx index 12647ae503..3401ae084b 100644 --- a/packages/editor/src/engine/react/hooks/use-children.tsx +++ b/packages/editor/src/engine/react/hooks/use-children.tsx @@ -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' @@ -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' /** @@ -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 }): React.ReactNode => { const { decorations, @@ -72,7 +82,9 @@ const useChildren = (props: { renderElement, renderText, renderLeaf, + chunkBlocks, } = props + const renderAsChunks = isEditor(node) && chunkBlocks === undefined const editor = useEngineStatic() editor.isNodeMapDirty = false @@ -84,6 +96,7 @@ const useChildren = (props: { node, parentPath, decorations, + renderAsChunks ? EMPTY_CHILDREN : chunkBlocks, ) let children: Array = [] @@ -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 @@ -287,6 +302,19 @@ const useChildren = (props: { return false } + if (renderAsChunks) { + return editor.rootChunks.map((chunk) => ( + + )) + } + const elements = children.map((n: Node, i: number) => { if (isTextBlock({schema: editor.snapshot.context.schema}, n)) { return wrapNewPipeline( @@ -343,4 +371,65 @@ const useChildren = (props: { return <>{elements} } +const EMPTY_CHILDREN: Array = [] + +const registrationMapsSelector = (editor: Editor) => + [ + editor.containers, + editor.textBlocks, + editor.blockObjects, + editor.inlineObjects, + editor.spans, + ] as const + +const tupleRefEqual = ( + 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 diff --git a/packages/editor/src/engine/react/hooks/use-decorations-by-child.ts b/packages/editor/src/engine/react/hooks/use-decorations-by-child.ts index 99a5789fa6..daf8b4cf33 100644 --- a/packages/editor/src/engine/react/hooks/use-decorations-by-child.ts +++ b/packages/editor/src/engine/react/hooks/use-decorations-by-child.ts @@ -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(decorationsByChild) const next = stabilizeDecorationsByChild(stable, decorationsByChild) diff --git a/packages/editor/src/internal-utils/root-chunks.test.ts b/packages/editor/src/internal-utils/root-chunks.test.ts new file mode 100644 index 0000000000..d565ea7d63 --- /dev/null +++ b/packages/editor/src/internal-utils/root-chunks.test.ts @@ -0,0 +1,267 @@ +import {describe, expect, test} from 'vitest' +import type {Node} from '../engine/interfaces/node' +import type {EngineOperation} from '../engine/interfaces/operation' +import {serializePath} from '../paths/serialize-path' +import { + buildRootChunks, + transformRootChunks, + type RootChunk, +} from './root-chunks' + +function flatten(chunks: Array): Array { + return chunks.flatMap((chunk) => chunk.blocks) +} + +function indexMapOf(value: Array): Map { + const map = new Map() + for (let index = 0; index < value.length; index++) { + const key = value[index]!._key + if (key !== undefined) { + map.set(serializePath([{_key: key}]), index) + } + } + return map +} + +function block(key: string, text = key): Node { + return { + _type: 'block', + _key: key, + children: [{_type: 'span', _key: `${key}-s`, text, marks: []}], + markDefs: [], + style: 'normal', + } as unknown as Node +} + +function makeValue(count: number): Array { + return Array.from({length: count}, (_, index) => block(`b${index}`)) +} + +describe(transformRootChunks.name, () => { + test('Scenario: A text op replaces only the owning chunk object', () => { + const value = makeValue(250) + const chunks = buildRootChunks(value) + expect(chunks.map((chunk) => chunk.blocks.length)).toEqual([100, 100, 50]) + + // A text edit replaces the root block ref at index 150 (chunk 1). + const afterValue = value.slice() + afterValue[150] = block('b150', 'edited') + const operation: EngineOperation = { + type: 'insert.text', + path: [{_key: 'b150'}, 'children', {_key: 'b150-s'}], + offset: 0, + text: 'x', + } + + const next = transformRootChunks( + chunks, + operation, + indexMapOf(value), + afterValue, + ) + + expect(flatten(next)).toEqual(afterValue) + expect(next[0]).toBe(chunks[0]) + expect(next[2]).toBe(chunks[2]) + expect(next[1]).not.toBe(chunks[1]) + expect(next[1]!.id).toBe(chunks[1]!.id) + }) + + test('Scenario: Root inserts and removals shift only the owning chunk', () => { + const value = makeValue(250) + const chunks = buildRootChunks(value) + + // Insert after b120 (chunk 1). + const inserted = block('new1') + const afterInsert = value.slice() + afterInsert.splice(121, 0, inserted) + const insertOp: EngineOperation = { + type: 'insert', + path: [{_key: 'b120'}], + position: 'after', + node: inserted, + } + const afterInsertChunks = transformRootChunks( + chunks, + insertOp, + indexMapOf(value), + afterInsert, + ) + expect(flatten(afterInsertChunks)).toEqual(afterInsert) + expect(afterInsertChunks[0]).toBe(chunks[0]) + expect(afterInsertChunks[2]).toBe(chunks[2]) + expect(afterInsertChunks[1]!.blocks.length).toBe(101) + + // Remove b0 (chunk 0). + const afterRemove = afterInsert.slice(1) + const removeOp: EngineOperation = { + type: 'unset', + path: [{_key: 'b0'}], + } + const afterRemoveChunks = transformRootChunks( + afterInsertChunks, + removeOp, + indexMapOf(afterInsert), + afterRemove, + ) + expect(flatten(afterRemoveChunks)).toEqual(afterRemove) + expect(afterRemoveChunks[1]).toBe(afterInsertChunks[1]) + expect(afterRemoveChunks[2]).toBe(afterInsertChunks[2]) + expect(afterRemoveChunks[0]!.blocks.length).toBe(99) + }) + + test('Scenario: A chunk splits when it outgrows the max size', () => { + let value = makeValue(150) + let chunks = [ + {id: 'single', blocks: value.slice()}, + ] satisfies Array + + for (let extra = 0; extra < 60; extra++) { + const inserted = block(`extra${extra}`) + const afterValue = value.slice() + afterValue.splice(75, 0, inserted) + chunks = transformRootChunks( + chunks, + { + type: 'insert', + path: [{_key: value[74]!._key as string}], + position: 'after', + node: inserted, + }, + indexMapOf(value), + afterValue, + ) + value = afterValue + expect(flatten(chunks)).toEqual(value) + } + + expect(chunks.length).toBeGreaterThan(1) + for (const chunk of chunks) { + expect(chunk.blocks.length).toBeLessThanOrEqual(200) + } + }) + + test('Scenario: Removing a chunk\u2019s last block drops the chunk', () => { + const value = [block('a'), block('b')] + const chunks = [ + {id: 'c1', blocks: [value[0]!]}, + {id: 'c2', blocks: [value[1]!]}, + ] satisfies Array + + const afterValue = [value[0]!] + const next = transformRootChunks( + chunks, + {type: 'unset', path: [{_key: 'b'}]}, + indexMapOf(value), + afterValue, + ) + expect(next).toEqual([{id: 'c1', blocks: [value[0]!]}]) + expect(next[0]).toBe(chunks[0]) + }) + + test('Scenario: Fuzzed op sequence keeps chunks equal to the value', () => { + let value = makeValue(120) + let chunks = buildRootChunks(value) + let seed = 42 + + const random = () => { + // Park-Miller PRNG, deterministic across runs. + seed = (seed * 16807) % 2147483647 + return seed / 2147483647 + } + + for (let step = 0; step < 400; step++) { + const kind = random() + const index = Math.floor(random() * value.length) + const target = value[index] + const beforeIndexMap = indexMapOf(value) + + let operation: EngineOperation + let afterValue: Array + + if (kind < 0.3 && target) { + // Root insert before/after a random anchor. + const inserted = block(`f${step}`) + const after = random() < 0.5 + afterValue = value.slice() + afterValue.splice(after ? index + 1 : index, 0, inserted) + operation = { + type: 'insert', + path: [{_key: target._key as string}], + position: after ? 'after' : 'before', + node: inserted, + } + } else if (kind < 0.55 && target && value.length > 1) { + // Root removal. + afterValue = value.slice() + afterValue.splice(index, 1) + operation = {type: 'unset', path: [{_key: target._key as string}]} + } else if (kind < 0.75 && target) { + // Whole-node replacement at the same position. + const replacement = block(`r${step}`) + afterValue = value.slice() + afterValue[index] = replacement + operation = { + type: 'set', + path: [{_key: target._key as string}], + value: replacement, + } + } else if (kind < 0.95 && target) { + // Nested edit: the root block ref is replaced in place. + const edited = block(target._key as string, `edited-${step}`) + afterValue = value.slice() + afterValue[index] = edited + operation = { + type: 'insert.text', + path: [{_key: target._key as string}, 'children', {_key: 'x'}], + offset: 0, + text: 'x', + } + } else { + // Whole-value replacement. + afterValue = makeValue(Math.floor(random() * 150)) + operation = {type: 'set', path: [], value: afterValue} + } + + chunks = transformRootChunks( + chunks, + operation, + beforeIndexMap, + afterValue, + ) + value = afterValue + + expect(flatten(chunks), `step ${step}`).toEqual(value) + } + }) + + test('Scenario: Map miss falls back to scanning the chunks', () => { + const value = makeValue(10) + const chunks = buildRootChunks(value) + const afterValue = value.slice() + afterValue.splice(5, 1) + + const next = transformRootChunks( + chunks, + {type: 'unset', path: [{_key: 'b5'}]}, + new Map(), + afterValue, + ) + expect(flatten(next)).toEqual(afterValue) + }) + + test('Scenario: Numeric root path', () => { + const value = makeValue(10) + const chunks = buildRootChunks(value) + const afterValue = value.slice() + afterValue.splice(3, 1) + + const next = transformRootChunks( + chunks, + {type: 'unset', path: [3]}, + indexMapOf(value), + afterValue, + ) + expect(flatten(next)).toEqual(afterValue) + }) +}) diff --git a/packages/editor/src/internal-utils/root-chunks.ts b/packages/editor/src/internal-utils/root-chunks.ts new file mode 100644 index 0000000000..ba1ea0796c --- /dev/null +++ b/packages/editor/src/internal-utils/root-chunks.ts @@ -0,0 +1,194 @@ +import type {Node} from '../engine/interfaces/node' +import type {EngineOperation} from '../engine/interfaces/operation' +import {serializePath} from '../paths/serialize-path' +import {isKeyedSegment} from '../utils/util.is-keyed-segment' + +/** + * A contiguous slice of the root value array, rendered as one memoized + * fragment so a change to one block reconciles one chunk's children + * instead of the whole document's. The object identity is the render + * contract: `transformRootChunks` replaces the chunk object whose + * blocks changed and reuses every other chunk object untouched, so the + * chunk component's `React.memo` bails on `prev.chunk === next.chunk`. + */ +export type RootChunk = { + id: string + blocks: Array +} + +const TARGET_CHUNK_SIZE = 100 +const MAX_CHUNK_SIZE = 200 + +let nextChunkId = 0 + +function createChunk(blocks: Array): RootChunk { + nextChunkId++ + return {id: `chunk-${nextChunkId}`, blocks} +} + +export function buildRootChunks(value: ReadonlyArray): Array { + const chunks: Array = [] + for (let start = 0; start < value.length; start += TARGET_CHUNK_SIZE) { + chunks.push(createChunk(value.slice(start, start + TARGET_CHUNK_SIZE))) + } + return chunks +} + +/** + * Apply an editor operation's effect on the root value array to the + * chunk list. Pure w.r.t. inputs: given chunks whose flattened blocks + * equal the value before `operation` and the value after it, returns a + * chunk list whose flattened blocks equal `afterValue`, rebuilding only + * the chunk that owns the operation's root position. Every op except + * `set.selection` replaces the owning root block's reference (the tree + * is updated immutably), so the owning chunk is refreshed even for text + * ops. + * + * `beforeIndexMap` must reflect the value before the operation (call + * this before `transformBlockIndexMap`); a miss falls back to scanning + * the chunks, and unresolvable shapes fall back to a full rebuild, so + * an unmaintained map only costs performance, never correctness. The + * oracle test in `root-chunks.test.ts` pins `flatten(chunks) === + * value` and the identity reuse of untouched chunks. + */ +export function transformRootChunks( + chunks: Array, + operation: EngineOperation, + beforeIndexMap: ReadonlyMap, + afterValue: ReadonlyArray, +): Array { + if (operation.type === 'set.selection') { + return chunks + } + + const path = operation.path + + if (path.length === 0) { + // Whole-value set/unset. + return buildRootChunks(afterValue) + } + + const rootSegment = path[0] + let beforeIndex: number | undefined + + if (typeof rootSegment === 'number') { + beforeIndex = rootSegment + } else if (isKeyedSegment(rootSegment)) { + const mappedIndex = beforeIndexMap.get(serializePath([rootSegment])) + if ( + mappedIndex !== undefined && + blockAtIndex(chunks, mappedIndex)?._key === rootSegment._key + ) { + beforeIndex = mappedIndex + } else { + // The map can miss or disagree with the chunks (unmaintained or + // stale maps); fall back to scanning the chunks, mirroring + // `getNode`/`getChildren`. + beforeIndex = indexOfKeyInChunks(chunks, rootSegment._key) + } + } + + if (beforeIndex === undefined || beforeIndex < 0) { + return buildRootChunks(afterValue) + } + + // The root-membership delta this operation applied: a root-level + // insert adds one block, a root-level node removal removes one, + // everything else (nested edits, text ops, node replacement, + // re-keying) keeps the length and only replaces the owning block's + // reference. + const lastSegment = path[path.length - 1] + const delta = + operation.type === 'insert' && path.length === 1 + ? 1 + : operation.type === 'unset' && + path.length === 1 && + (isKeyedSegment(lastSegment) || typeof lastSegment === 'number') + ? -1 + : 0 + + // Locate the owning chunk by walking prefix sums of the before-state + // spans. For inserts the anchor's chunk receives the new block + // ('before' inserts at the anchor's index, 'after' appends behind + // it), so ownership by before-index is correct for every delta. + let start = 0 + let ownerIndex = -1 + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const length = chunks[chunkIndex]!.blocks.length + if (beforeIndex < start + length) { + ownerIndex = chunkIndex + break + } + start += length + } + + if (ownerIndex === -1) { + if (delta === 1 && chunks.length > 0) { + // Out-of-range insert anchors clamp to an append; the last chunk + // owns it. + ownerIndex = chunks.length - 1 + start -= chunks[ownerIndex]!.blocks.length + } else { + return buildRootChunks(afterValue) + } + } + + const owner = chunks[ownerIndex]! + const newLength = owner.blocks.length + delta + const newBlocks = afterValue.slice(start, start + newLength) as Array + + const result = chunks.slice() + + if (newBlocks.length === 0) { + // ponytail: chunks split but never merge, so scattered deletions can + // accumulate small chunks and degrade the root dispatch toward O(n) + // in the pathological limit. Merge-with-neighbor below a floor is + // the upgrade if chunk counts ever show up in a profile. + result.splice(ownerIndex, 1) + return result + } + + if (newBlocks.length > MAX_CHUNK_SIZE) { + const half = Math.ceil(newBlocks.length / 2) + result.splice( + ownerIndex, + 1, + createChunk(newBlocks.slice(0, half)), + createChunk(newBlocks.slice(half)), + ) + return result + } + + result[ownerIndex] = {id: owner.id, blocks: newBlocks} + return result +} + +function blockAtIndex( + chunks: Array, + index: number, +): Node | undefined { + let start = 0 + for (const chunk of chunks) { + if (index < start + chunk.blocks.length) { + return chunk.blocks[index - start] + } + start += chunk.blocks.length + } + return undefined +} + +function indexOfKeyInChunks( + chunks: Array, + key: string, +): number | undefined { + let start = 0 + for (const chunk of chunks) { + for (let blockIndex = 0; blockIndex < chunk.blocks.length; blockIndex++) { + if (chunk.blocks[blockIndex]!._key === key) { + return start + blockIndex + } + } + start += chunk.blocks.length + } + return undefined +} diff --git a/packages/editor/src/types/editor-engine.ts b/packages/editor/src/types/editor-engine.ts index 89c3e9f8e1..5047c802b2 100644 --- a/packages/editor/src/types/editor-engine.ts +++ b/packages/editor/src/types/editor-engine.ts @@ -4,6 +4,7 @@ import type {EditorSnapshot} from '../editor/editor-snapshot' import type {DecoratedRange} from '../editor/range-decorations-machine' import type {DOMEditor} from '../engine/dom/plugin/dom-editor' import type {EngineOperation} from '../engine/interfaces/operation' +import type {RootChunk} from '../internal-utils/root-chunks' import type { BlockObjectConfig, InlineObjectConfig, @@ -52,6 +53,14 @@ export interface PortableTextEditorEngine extends DOMEditor { * per render, regardless of how many operations a batch applied. */ listIndexMapDirty: boolean + /** + * The root value array split into contiguous chunks for rendering. + * Maintained per operation by `transformRootChunks` in the + * update-value subscriber; chunk object identity changes only when a + * chunk's own blocks changed, which the chunk component's memo + * relies on. + */ + rootChunks: Array /** * Per-notify pending flags for the segmented selector channels * (`EngineSelectorChannel`). Set where the corresponding state mutates diff --git a/packages/editor/tests/performance.test.tsx b/packages/editor/tests/performance.test.tsx index eed44eb437..a64afa3b3c 100644 --- a/packages/editor/tests/performance.test.tsx +++ b/packages/editor/tests/performance.test.tsx @@ -1,5 +1,6 @@ import React from 'react' import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' import {defineSchema} from '../src' import {InternalEditorEngineRefPlugin} from '../src/plugins/plugin.internal.editor-engine-ref' import {NodePlugin} from '../src/plugins/plugin.node' @@ -36,6 +37,53 @@ describe('Performance', () => { console.warn(`Inserted 1000 blocks in ${duration.toFixed(2)}ms`) }) + test('Typing 20 characters in a 1000-block document', async () => { + const {editor, locator} = await createTestEditor() + + editor.send({ + type: 'insert.blocks', + blocks: Array.from({length: 1000}, (_, i) => ({ + _type: 'block', + _key: `b${i}`, + children: [ + {_type: 'span', _key: `s${i}`, text: `block ${i}`, marks: []}, + ], + markDefs: [], + style: 'normal', + })), + placement: 'auto', + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value.length).toBe(1000) + + expect(locator.getByText('block 999')).toBeInTheDocument() + }) + + await userEvent.click(locator.getByText('block 500')) + + const start = performance.now() + + await userEvent.keyboard('performance!!!!!!!!!') + + await vi.waitFor(() => { + const children = ( + editor.getSnapshot().context.value[500] as { + children?: Array<{text?: string}> + } + ).children + expect( + children?.some((child) => child.text?.includes('performance')), + ).toBe(true) + }) + + const duration = performance.now() - start + + console.warn( + `Typed 20 characters in a 1000-block document in ${duration.toFixed(2)}ms`, + ) + }) + test('Inserting 1000 blocks before an existing block', async () => { const {editor, locator} = await createTestEditor() diff --git a/packages/editor/tests/root-chunks.integration.test.tsx b/packages/editor/tests/root-chunks.integration.test.tsx new file mode 100644 index 0000000000..473df55035 --- /dev/null +++ b/packages/editor/tests/root-chunks.integration.test.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import {InternalEditorEngineRefPlugin} from '../src/plugins/plugin.internal.editor-engine-ref' +import {createTestEditor} from '../src/test/vitest' +import type {PortableTextEditorEngine} from '../src/types/editor-engine' + +describe('root chunks against real engine editing', () => { + test('Scenario: `flatten(rootChunks)` equals the value through a mixed editing session', async () => { + const engineRef = React.createRef() + const {editor, locator} = await createTestEditor({ + children: , + }) + const value = () => editor.getSnapshot().context.value + + async function expectChunksMatchValue(label: string) { + // The actor-side value can settle before the engine finishes + // applying a burst of ops, so the invariant is asserted with a + // retry. + await vi.waitFor(() => { + const flattened = engineRef.current!.rootChunks.flatMap( + (chunk) => chunk.blocks, + ) + // Reference equality per block: the chunks must hold the + // value's actual block objects, not stale copies. + expect(flattened.length, label).toBe(value().length) + flattened.forEach((block, index) => { + expect(block, `${label}: block ${index}`).toBe(value()[index]) + }) + }) + } + + // Programmatic bulk insert (a paste-sized batch crossing chunk + // boundaries). + editor.send({ + type: 'insert.blocks', + placement: 'auto', + blocks: Array.from({length: 250}, (_, index) => ({ + _type: 'block', + _key: `b${index}`, + children: [ + {_type: 'span', _key: `s${index}`, text: `block ${index}`, marks: []}, + ], + markDefs: [], + style: 'normal', + })), + }) + await vi.waitFor(() => expect(value().length).toBe(250)) + await expectChunksMatchValue('after bulk insert') + + // Real typing, splitting, and merging at a chunk-interior position. + await userEvent.click(locator.getByText('block 125')) + await userEvent.keyboard('typed{Enter}split{Enter}') + await vi.waitFor(() => expect(value().length).toBe(252)) + await expectChunksMatchValue('after typing and splits') + + await userEvent.keyboard( + '{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}', + ) + await vi.waitFor(() => expect(value().length).toBe(251)) + await expectChunksMatchValue('after backspace merges') + + // Undo the typing session step by step (bounded so the bulk insert + // itself stays). + for (let undoStep = 0; undoStep < 5; undoStep++) { + editor.send({type: 'history.undo'}) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + await expectChunksMatchValue('after undos') + + // Select-all delete across every chunk. + const blockCountBeforeDelete = value().length + editor.send({ + type: 'select', + at: { + anchor: {path: [{_key: 'b0'}, 'children', {_key: 's0'}], offset: 0}, + focus: { + path: [{_key: 'b249'}, 'children', {_key: 's249'}], + offset: 'block 249'.length, + }, + }, + }) + editor.send({type: 'delete'}) + await vi.waitFor(() => expect(value().length).toBe(1)) + await expectChunksMatchValue('after select-all delete') + + editor.send({type: 'history.undo'}) + await vi.waitFor(() => expect(value().length).toBe(blockCountBeforeDelete)) + await expectChunksMatchValue('after undoing the delete') + }, 120000) +})