diff --git a/.changeset/modern-mammals-walk.md b/.changeset/modern-mammals-walk.md new file mode 100644 index 0000000..6ccf81a --- /dev/null +++ b/.changeset/modern-mammals-walk.md @@ -0,0 +1,17 @@ +--- +"@portabletext/react": patch +--- + +Avoid repeated serialization of nested styled list items and reuse unchanged blocks +and prepared marks trees in the React Compiler build. + +Reuse requires immutable block identities, stable keys, components and missing-component +handlers, and unchanged block indices. Mounts, refetched documents with new objects, +and updates with new component maps can incur additional per-block overhead. + +Missing-component handlers no longer run for discarded styled-list subtrees or reused +block output. Each top-level block and list item now has a `RenderedNode` boundary +(including in the hook-free RSC build). Built-in block styles and list items render +native tags directly, so their default component functions no longer appear in React +DevTools or warning stacks. Custom components still receive the full props contract, +including mark text and the single-argument `renderNode` callback. diff --git a/.gitignore b/.gitignore index cc880c2..bbbdb2a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ jspm_packages # Compiled portable text library + demo /dist /demo/dist +/test/performance/dist *.iml .idea/ diff --git a/.oxlintrc.json b/.oxlintrc.json index a2cadfb..c898159 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,6 +4,7 @@ "dist/**", "coverage/**", "demo/dist/**", + "test/performance/dist/**", ".nyc_output/**", "test/typegen/sanity.types.ts", // The Next.js integration fixture resolves `next` types from its own node_modules, which diff --git a/README.md b/README.md index 310d9df..efb7c9e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Migrating from [@sanity/block-content-to-react](https://www.npmjs.com/package/@s - [Installation](#installation) - [Basic usage](#basic-usage) - [React Server Components](#react-server-components) +- [Rendering performance](#rendering-performance) - [Styling](#styling-the-output) - [Customizing components](#customizing-components) - [Available components](#available-components) @@ -50,6 +51,14 @@ import {PortableText} from '@portabletext/react' `` renders in both worlds without configuration: use it directly in a React Server Component, or in a file with a `'use client'` directive. The package publishes two builds selected automatically through export conditions — client components and SSR load a build optimized with [React Compiler](https://react.dev/learn/react-compiler), while server components load an uncompiled build through the `react-server` condition ([React Compiler output can't load in server components](https://github.com/facebook/react/issues/31702), and since server components render exactly once, memoization couldn't pay off there anyway). +## Rendering performance + +The compiled build can reuse unchanged blocks when the containing `value` array changes. Treat values as immutable: replace edited blocks and their children, retain unchanged block objects, and use stable `_key` values. Keep `components` and `onMissingComponent` referentially stable as well. Recreating every block (for example after a refetch) or passing a new `components` object forfeits rendered-output reuse. A block's index must also stay the same: inserting or deleting a block rerenders subsequent blocks whose indices shift. + +Marks-tree preparation is independent of presentation, so changing components can reuse prepared content. List nesting still processes a changed document, and generated list containers and items containing nested lists may need rebuilding. Context consumers inside custom components continue to update normally. Missing-component callbacks are not repeated for reused output. The server-component build uses the same rendering fast paths without React memoization hooks or cross-request caches. + +Reuse trades per-block bookkeeping and component boundaries for cheaper structurally shared updates. It can slow simple paragraph/list mounts, fully recreated documents, and updates with new component maps; these costs also matter for read-only CMS content. See the [benchmark guide](test/performance/README.md) to measure the full matrix on your workload rather than assuming a universal speedup. + ## Styling the output The rendered HTML does not have any styling applied, so you will either render a parent container with a class name you can target in your CSS, or pass [custom components](#customizing-components) if you want to control the direct markup and CSS of each element. diff --git a/package.json b/package.json index 40b70f6..865d19e 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,13 @@ }, "scripts": { "analyze": "ENABLE_BUNDLE_ANALYZER=true pnpm build", + "bench": "pnpm build && NODE_ENV=production vitest bench --run", + "bench:browser": "vite preview test/performance --config test/performance/vite.config.ts", "build": "tsdown", + "build:bench": "pnpm build && vite build test/performance --config test/performance/vite.config.ts --emptyOutDir", "prebuild:demo": "pnpm clean", "build:demo": "vite build demo --config=./vite.config.demo.ts --base=/react-portabletext/", - "clean": "rimraf dist coverage demo/dist .nyc_output", + "clean": "rimraf dist coverage demo/dist test/performance/dist .nyc_output", "dev": "vite demo --config=./vite.config.demo.ts", "format": "oxfmt .", "lint": "oxlint --type-aware --type-check --deny-warnings --report-unused-disable-directives", @@ -73,6 +76,7 @@ "esbuild": "^0.28.2", "esbuild-register": "^3.6.0", "groq": "^6.12.0", + "jsdom": "29.1.1", "leaflet": "^1.9.4", "oxc-transform-react": "^0.147.0", "oxfmt": "^0.66.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0468e7..974351f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: groq: specifier: ^6.12.0 version: 6.12.0 + jsdom: + specifier: 29.1.1 + version: 29.1.1(@noble/hashes@2.3.0) leaflet: specifier: ^1.9.4 version: 1.9.4 diff --git a/src/react-portable-text.tsx b/src/react-portable-text.tsx index d9b1df6..d88aebc 100644 --- a/src/react-portable-text.tsx +++ b/src/react-portable-text.tsx @@ -3,6 +3,7 @@ import { buildMarksTree, isPortableTextBlock, isPortableTextListItemBlock, + isPortableTextSpan, isPortableTextToolkitList, isPortableTextToolkitSpan, isPortableTextToolkitTextNode, @@ -13,11 +14,14 @@ import { import type {PortableTextBlock, PortableTextListItemBlock, TypedObject} from '@portabletext/types' import type {JSX, ReactNode} from 'react' -import {defaultComponents} from './components/defaults' +import {defaultBlockStyles, defaultComponents} from './components/defaults' +import {DefaultListItem} from './components/list' import {mergeComponents} from './components/merge' import type { + DefaultPortableTextBlockStyle, MissingComponentHandler, NodeRenderer, + PortableTextComponents, PortableTextProps, PortableTextReactComponents, ReactPortableTextList, @@ -33,6 +37,35 @@ import { unknownTypeWarning, } from './warnings' +// Native default tags offset the extra component boundary used for block-level reuse. +type BlockTag = Exclude | 'p' +const builtInBlocks = new Map( + Object.entries(defaultBlockStyles).map(([style, component]) => [ + component, + (style === 'normal' ? 'p' : style) as BlockTag, + ]), +) + +type BlockChildren = ReturnType | string +type ClassifiedNode = ( + | {kind: 'text'; node: ToolkitTextNode} + | {kind: 'list'; node: ReactPortableTextList} + | {kind: 'listItem'; node: PortableTextListItemBlock} + | {kind: 'span'; node: ToolkitNestedPortableTextSpan} + | {kind: 'block'; node: PortableTextBlock} + | {kind: 'custom' | 'unknown'; node: TypedObject} +) & {block?: PortableTextBlock} + +interface Renderer { + renderNode: NodeRenderer + classify: (node: TypedObject) => ClassifiedNode + render: ( + options: Serializable, + classified: ClassifiedNode, + children?: BlockChildren, + ) => ReactNode +} + export function PortableText({ value: input, components: componentOverrides, @@ -40,6 +73,25 @@ export function PortableText({ onMissingComponent: missingComponentHandler = printWarning, }: PortableTextProps): JSX.Element { const handleMissingComponent = missingComponentHandler || noop + const renderer = useRenderer(componentOverrides, handleMissingComponent) + const nested = useNestedBlocks(input, listNestingMode) + const rendered = nested.map((node, index) => ( + + )) + + return <>{rendered} +} + +function useNestedBlocks( + input: PortableTextProps['value'], + listNestingMode: PortableTextProps['listNestingMode'], +) { + 'use memo' let blocks: B[] if (Array.isArray(input)) { blocks = input @@ -48,63 +100,118 @@ export function PortableText({ } else { blocks = [input] } - const nested = nestLists(blocks, listNestingMode || LIST_NEST_MODE_HTML) + return nestLists(blocks, listNestingMode || LIST_NEST_MODE_HTML) +} - // No manual memoization: the compiled (`default` condition) build gets React Compiler - // auto-memoization, and the uncompiled `react-server` build renders exactly once anyway. - const components = componentOverrides - ? mergeComponents(defaultComponents, componentOverrides) - : defaultComponents +// Separate compiler scopes keep the renderer stable when only the document changes. +// These helpers use no React hooks in the uncompiled react-server build. +function useRenderer( + overrides: PortableTextComponents | undefined, + handleMissingComponent: MissingComponentHandler, +): Renderer { + 'use memo' + const components = overrides ? mergeComponents(defaultComponents, overrides) : defaultComponents + return getNodeRenderer(components, handleMissingComponent) +} - const renderNode = getNodeRenderer(components, handleMissingComponent) - const rendered = nested.map((node, index) => - renderNode({node: node, index, isInline: false, renderNode}), - ) +function RenderedNode({ + node, + index, + renderer, +}: Pick, 'node' | 'index'> & {renderer: Renderer}): ReactNode { + 'use memo' + const classified = useClassifiedNode(node, renderer.classify) + const children = useBlockChildren(classified.block) + const {renderNode} = renderer + return renderer.render({node, index, isInline: false, renderNode}, classified, children) +} - return <>{rendered} +function useClassifiedNode(node: TypedObject, classify: Renderer['classify']): ClassifiedNode { + 'use memo' + return classify(node) +} + +function useBlockChildren(node: PortableTextBlock | undefined): BlockChildren | undefined { + 'use memo' + return node ? prepareBlockChildren(node) : undefined +} + +function prepareBlockChildren(node: PortableTextBlock): BlockChildren { + const first = node.children[0] + return node.children.length === 1 && + first && + isPortableTextSpan(first) && + (!first.marks || first.marks.length === 0) && + !first.text.includes('\n') + ? first.text + : buildMarksTree(node) } const getNodeRenderer = ( components: PortableTextReactComponents, handleMissingComponent: MissingComponentHandler, -): NodeRenderer => { - function renderNode(options: Serializable): ReactNode { - const {node, index, isInline} = options - const key = node._key || `node-${index}` - +): Renderer => { + function classify(node: TypedObject): ClassifiedNode { + if (isPortableTextToolkitTextNode(node) && !hasCustomComponentForNode(node)) { + return {kind: 'text', node} + } if (isPortableTextToolkitList(node)) { - return renderList(node, index, key) + return {kind: 'list', node} } - - if (isPortableTextListItemBlock(node)) { - return renderListItem(node, index, key) + if ('listItem' in node && isPortableTextListItemBlock(node)) { + const customStyledBlock = + node.style && node.style !== 'normal' && hasCustomComponentForNode(node) + return {kind: 'listItem', node, block: customStyledBlock ? undefined : node} } - if (isPortableTextToolkitSpan(node)) { - return renderSpan(node, index, key) + return {kind: 'span', node} } - if (hasCustomComponentForNode(node)) { - return renderCustomBlock(node, index, key, isInline) + return {kind: 'custom', node} } - if (isPortableTextBlock(node)) { - return renderBlock(node, index, key, isInline) + return {kind: 'block', node, block: node} } + return {kind: 'unknown', node} + } - if (isPortableTextToolkitTextNode(node)) { - return renderText(node, key) - } + // Keep the public callback unary: Array#map supplies an index as its second argument. + const renderNode: NodeRenderer = (options) => render(options, classify(options.node)) - return renderUnknownType(node, index, key, isInline) + function render( + {node, index, isInline}: Serializable, + classified: ClassifiedNode, + children?: BlockChildren, + ): ReactNode { + const key = node._key || `node-${index}` + switch (classified.kind) { + case 'text': + return renderText(classified.node, key) + case 'list': + return renderList(classified.node, index, key) + case 'listItem': + return renderListItem(classified.node, index, key, children) + case 'span': + return renderSpan(classified.node, index, key) + case 'custom': + return renderCustomBlock(classified.node, index, key, isInline) + case 'block': + return renderBlock(classified.node, index, key, isInline, children) + default: + return renderUnknownType(classified.node, index, key, isInline) + } } function hasCustomComponentForNode(node: TypedObject): boolean { return node._type in components.types } - function renderListItem(node: PortableTextListItemBlock, index: number, key: string) { - const tree = serializeBlock({node, index, isInline: false, renderNode}) + function renderListItem( + node: PortableTextListItemBlock, + index: number, + key: string, + prepared?: BlockChildren, + ) { const renderer = components.listItem const handler = typeof renderer === 'function' ? renderer : renderer[node.listItem] const Li = handler || components.unknownListItem @@ -117,16 +224,25 @@ const getNodeRenderer = ( }) } - let children = tree.children + let children: ReactNode if (node.style && node.style !== 'normal') { // Wrap any other style in whatever the block serializer says to use const {listItem: _listItem, ...blockNode} = node - children = renderNode({ - node: blockNode, - index, - isInline: false, - renderNode, - }) + children = render( + {node: blockNode, index, isInline: false, renderNode}, + classify(blockNode), + prepared, + ) + } else { + children = serializeBlock( + {node, index, isInline: false, renderNode}, + components, + prepared, + ).children + } + + if (Li === DefaultListItem) { + return
  • {children}
  • } return ( @@ -137,14 +253,14 @@ const getNodeRenderer = ( } function renderList(node: ReactPortableTextList, index: number, key: string) { - const children = node.children.map((child, childIndex) => - renderNode({ - node: child._key ? child : {...child, _key: `li-${index}-${childIndex}`}, - index: childIndex, - isInline: false, - renderNode, - }), - ) + const children = node.children.map((child, childIndex) => ( + + )) const component = components.list const handler = typeof component === 'function' ? component : component[node.listItem] @@ -198,13 +314,18 @@ const getNodeRenderer = ( ) } - function renderBlock(node: PortableTextBlock, index: number, key: string, isInline: boolean) { - const {_key, ...props} = serializeBlock({ - node, - index, - isInline, - renderNode, - }) + function renderBlock( + node: PortableTextBlock, + index: number, + key: string, + isInline: boolean, + prepared?: BlockChildren, + ) { + const {_key, ...props} = serializeBlock( + {node, index, isInline, renderNode}, + components, + prepared, + ) const style = props.node.style || 'normal' const handler = typeof components.block === 'function' ? components.block : components.block[style] @@ -217,6 +338,11 @@ const getNodeRenderer = ( }) } + const Tag = builtInBlocks.get(Block) + if (Tag) { + return {props.children} + } + return } @@ -258,15 +384,27 @@ const getNodeRenderer = ( return Node ? : null } - return renderNode + const renderer = {renderNode, classify, render} + return renderer } -function serializeBlock(options: Serializable): SerializedBlock { +function serializeBlock( + options: Serializable, + components: PortableTextReactComponents, + prepared?: BlockChildren, +): SerializedBlock { const {node, index, isInline, renderNode} = options - const tree = buildMarksTree(node) - const children = tree.map((child, i) => - renderNode({node: child, isInline: true, index: i, renderNode}), - ) + const tree = prepared ?? prepareBlockChildren(node) + // A prepared string is independent of presentation. Custom text renderers receive the + // same virtual node as buildMarksTree would produce, without rebuilding the marks tree. + const children = + typeof tree === 'string' + ? [ + '@text' in components.types + ? renderNode({node: {_type: '@text', text: tree}, isInline: true, index: 0, renderNode}) + : tree, + ] + : tree.map((child, i) => renderNode({node: child, isInline: true, index: i, renderNode})) return { _key: node._key || `block-${index}`, diff --git a/src/types.ts b/src/types.ts index d5ef76c..8a86772 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,11 @@ export interface PortableTextProps< > { /** * One or more blocks to render + * + * Treat as immutable: replace edited blocks and retain unchanged block references. + * Rendered-output reuse in the compiled build also requires stable `_key` values, + * components, onMissingComponent, and block indices. Insertions/deletions rerender + * following blocks whose indices shift; recreating every block forfeits reuse. */ value: B | B[] | null | undefined diff --git a/test/compiled-reuse.test.tsx b/test/compiled-reuse.test.tsx new file mode 100644 index 0000000..ce334c5 --- /dev/null +++ b/test/compiled-reuse.test.tsx @@ -0,0 +1,375 @@ +import { + PortableText, + type NodeRenderer, + type PortableTextBlock, + type PortableTextComponentProps, + type PortableTextComponents, + type PortableTextProps, + type ReactPortableTextList, +} from '@portabletext/react' +import {buildMarksTree, nestLists} from '@portabletext/toolkit' +import {act, createContext, useContext, type ReactNode} from 'react' +import {createRoot, hydrateRoot, type Root} from 'react-dom/client' +import {renderToString} from 'react-dom/server' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' + +vi.mock('@portabletext/toolkit', async (importOriginal) => { + const toolkit = await importOriginal() + return { + ...toolkit, + buildMarksTree: vi.fn(toolkit.buildMarksTree), + nestLists: vi.fn(toolkit.nestLists), + } +}) + +interface CustomBlock { + _type: 'custom' + _key: string + text: string +} + +function custom(_key: string, text = _key): CustomBlock { + return {_type: 'custom', _key, text} +} + +function marked(_key: string, text = _key, mark = 'strong'): PortableTextBlock { + return { + _type: 'block', + _key, + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: `${_key}-span`, text, marks: [mark]}], + } +} + +let container: HTMLDivElement +let root: Root | undefined + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + container = document.createElement('div') + document.body.append(container) + vi.clearAllMocks() +}) + +afterEach(async () => { + await act(async () => root?.unmount()) + root = undefined + container.remove() + vi.unstubAllGlobals() +}) + +async function render(node: ReactNode) { + root ??= createRoot(container) + await act(async () => root?.render(node)) +} + +async function renderPortableText(props: PortableTextProps) { + await render() +} + +test('reuses custom blocks and the renderer across array replacement and a one-block edit', async () => { + const calls = vi.fn<(key: string, renderNode: NodeRenderer) => void>() + function Custom({value, renderNode}: PortableTextComponentProps) { + calls(value._key, renderNode) + return

    {value.text}

    + } + const components = {types: {custom: Custom}} + const first = custom('first') + const second = custom('second') + const value = [first, second] + + await renderPortableText({value, components}) + expect(calls).toHaveBeenCalledTimes(2) + const renderer = calls.mock.calls[0]![1] + expect(calls.mock.calls[1]![1]).toBe(renderer) + calls.mockClear() + + await renderPortableText({value, components}) + expect(calls).not.toHaveBeenCalled() + await renderPortableText({value: [...value], components}) + expect(calls).not.toHaveBeenCalled() + + await renderPortableText({value: [first, {...second, text: 'edited'}], components}) + expect(calls).toHaveBeenCalledExactlyOnceWith('second', renderer) + expect(container.innerHTML).toBe('

    first

    edited

    ') + calls.mockClear() + + await renderPortableText({value: [custom('first'), custom('second')], components}) + expect(calls).toHaveBeenCalledTimes(2) + expect(calls).toHaveBeenNthCalledWith(1, 'first', renderer) + expect(calls).toHaveBeenNthCalledWith(2, 'second', renderer) + expect(container.innerHTML).toBe('

    first

    second

    ') +}) + +test('prepares only an edited marked block and keeps the public renderer stable', async () => { + const calls = vi.fn<(key: string | undefined, renderNode: NodeRenderer) => void>() + function Block({value, children, renderNode}: PortableTextComponentProps) { + calls(value._key, renderNode) + return

    {children}

    + } + const components = {block: Block} + const first = marked('first') + const second = marked('second') + const value = [first, second] + await renderPortableText({value, components}) + expect(buildMarksTree).toHaveBeenCalledTimes(2) + const renderer = calls.mock.calls[0]![1] + vi.clearAllMocks() + + await renderPortableText({value: [...value], components}) + expect(buildMarksTree).not.toHaveBeenCalled() + expect(calls).not.toHaveBeenCalled() + + const edited = marked('second', 'edited') + await renderPortableText({value: [first, edited], components}) + expect(buildMarksTree).toHaveBeenCalledExactlyOnceWith(edited) + expect(calls).toHaveBeenCalledExactlyOnceWith('second', renderer) + expect(container.innerHTML).toBe('

    first

    edited

    ') +}) + +test('changes presentation without rebuilding unchanged marks trees', async () => { + const value = [marked('first'), marked('second')] + const firstComponents: PortableTextComponents = { + marks: {strong: ({children}) => {children}}, + } + const nextComponents: PortableTextComponents = { + marks: {strong: ({children}) => {children}}, + } + await renderPortableText({value, components: firstComponents}) + expect(buildMarksTree).toHaveBeenCalledTimes(2) + expect(container.innerHTML).toBe('

    first

    second

    ') + vi.clearAllMocks() + + await renderPortableText({value, components: nextComponents}) + expect(buildMarksTree).not.toHaveBeenCalled() + expect(container.innerHTML).toBe('

    first

    second

    ') +}) + +test('keeps nested list containers stable when only components change', async () => { + const calls = vi.fn<(value: ReactPortableTextList) => void>() + function List({value, children}: PortableTextComponentProps) { + calls(value) + return
      {children}
    + } + const value = [ + {...marked('first'), listItem: 'bullet', level: 1}, + {...marked('nested'), listItem: 'bullet', level: 2}, + ] + const firstComponents: PortableTextComponents = { + list: List, + marks: {strong: ({children}) => {children}}, + } + const nextComponents: PortableTextComponents = { + list: List, + marks: {strong: ({children}) => {children}}, + } + await renderPortableText({value, components: firstComponents}) + expect(nestLists).toHaveBeenCalledTimes(1) + expect(calls).toHaveBeenCalledTimes(2) + const containers = calls.mock.calls.map(([list]) => list) + vi.clearAllMocks() + + await renderPortableText({value, components: nextComponents}) + expect(nestLists).not.toHaveBeenCalled() + expect(buildMarksTree).not.toHaveBeenCalled() + expect(calls).toHaveBeenCalledTimes(2) + expect(calls.mock.calls[0]![0]).toBe(containers[0]) + expect(calls.mock.calls[1]![0]).toBe(containers[1]) + expect(container.innerHTML).toBe('
    • first
      • nested
    ') +}) + +test('switches toolkit text overrides without preparing a marks tree for plain text', async () => { + const block = marked('first') + block.children[0]!.marks = [] + const value = [block] + const firstComponents: PortableTextComponents = { + types: {'@text': ({value: text}) => {text.text}}, + } + const nextComponents: PortableTextComponents = { + types: {'@text': ({value: text}) => {text.text}}, + } + await renderPortableText({value}) + expect(container.innerHTML).toBe('

    first

    ') + await renderPortableText({value, components: firstComponents}) + expect(container.innerHTML).toBe('

    first

    ') + await renderPortableText({value, components: firstComponents}) + expect(container.innerHTML).toBe('

    first

    ') + await renderPortableText({value, components: nextComponents}) + expect(container.innerHTML).toBe('

    first

    ') + await renderPortableText({value}) + expect(container.innerHTML).toBe('

    first

    ') + expect(buildMarksTree).not.toHaveBeenCalled() +}) + +test('uses the changed missing-component handler without rebuilding marks trees', async () => { + const value = [marked('first', 'unknown', 'missing')] + const firstHandler = vi.fn() + const nextHandler = vi.fn() + await renderPortableText({value, onMissingComponent: firstHandler}) + expect(firstHandler).toHaveBeenCalledTimes(1) + expect(buildMarksTree).toHaveBeenCalledTimes(1) + vi.clearAllMocks() + + await renderPortableText({value, onMissingComponent: nextHandler}) + expect(firstHandler).not.toHaveBeenCalled() + expect(nextHandler).toHaveBeenCalledExactlyOnceWith(expect.any(String), { + type: 'missing', + nodeType: 'mark', + }) + expect(buildMarksTree).not.toHaveBeenCalled() + expect(container.textContent).toBe('unknown') +}) + +test('updates custom-block indices after keyed reordering and insertion', async () => { + function Custom({value, index}: PortableTextComponentProps) { + return

    {`${value.text}:${index}`}

    + } + const components = {types: {custom: Custom}} + const first = custom('first') + const second = custom('second') + await renderPortableText({value: [first, second], components}) + expect(container.innerHTML).toBe('

    first:0

    second:1

    ') + const firstElement = container.children[0] + const secondElement = container.children[1] + + await renderPortableText({value: [second, first], components}) + expect(container.innerHTML).toBe('

    second:0

    first:1

    ') + expect(container.children[0]).toBe(secondElement) + expect(container.children[1]).toBe(firstElement) + await renderPortableText({value: [custom('inserted'), second, first], components}) + expect(container.innerHTML).toBe('

    inserted:0

    second:1

    first:2

    ') + expect(container.children[1]).toBe(secondElement) + expect(container.children[2]).toBe(firstElement) + + await renderPortableText({value: [first], components}) + expect(container.innerHTML).toBe('

    first:0

    ') + expect(container.children[0]).toBe(firstElement) +}) + +test('updates marked-block indices without preparing unchanged blocks again', async () => { + function Block({children, index}: PortableTextComponentProps) { + return

    {children}

    + } + const components = {block: Block} + const first = marked('first') + const second = marked('second') + await renderPortableText({value: [first, second], components}) + vi.clearAllMocks() + + await renderPortableText({value: [second, first], components}) + expect(buildMarksTree).not.toHaveBeenCalled() + expect(container.innerHTML).toBe( + '

    second

    first

    ', + ) + + const inserted = marked('inserted') + await renderPortableText({value: [inserted, second, first], components}) + expect(buildMarksTree).toHaveBeenCalledExactlyOnceWith(inserted) + expect(container.innerHTML).toBe( + '

    inserted

    ' + + '

    second

    first

    ', + ) +}) + +test('cached custom components still receive context updates', async () => { + const Theme = createContext('light') + function Custom({value}: PortableTextComponentProps) { + const theme = useContext(Theme) + return

    {`${value.text}:${theme}`}

    + } + const components = {types: {custom: Custom}} + const value = [custom('first')] + const content = + await render({content}) + expect(container.textContent).toBe('first:light') + + await render({content}) + expect(container.textContent).toBe('first:dark') +}) + +test.each(['html', 'direct'] as const)( + 'updates list content and switches nesting mode from %s', + async (listNestingMode) => { + const first = {...marked('first'), listItem: 'bullet', level: 1} + const nested = {...marked('nested'), listItem: 'bullet', level: 2} + const last = {...marked('last'), listItem: 'bullet', level: 1} + const value = [first, nested, last] + const html = (text: string) => + '
    • first
      • ' + + `${text}
    • last
    ` + const direct = (text: string) => + '
    • first
      • ' + + `${text}
    • last
    ` + const expected = listNestingMode === 'html' ? html : direct + const switched = listNestingMode === 'html' ? direct : html + + await renderPortableText({value, listNestingMode}) + expect(container.innerHTML).toBe(expected('nested')) + const edited = {...nested, children: marked('nested', 'edited').children} + const nextValue = [first, edited, last] + await renderPortableText({value: nextValue, listNestingMode}) + expect(container.innerHTML).toBe(expected('edited')) + + await renderPortableText({ + value: nextValue, + listNestingMode: listNestingMode === 'html' ? 'direct' : 'html', + }) + expect(container.innerHTML).toBe(switched('edited')) + }, +) + +test('handles null, single-block, array and undefined transitions without stale output', async () => { + const block = marked('first') + await renderPortableText({value: null}) + expect(container.innerHTML).toBe('') + await renderPortableText({value: block}) + expect(container.innerHTML).toBe('

    first

    ') + vi.clearAllMocks() + + await renderPortableText({value: [block]}) + expect(container.innerHTML).toBe('

    first

    ') + expect(buildMarksTree).not.toHaveBeenCalled() + await renderPortableText({value: undefined}) + expect(container.innerHTML).toBe('') + await renderPortableText({value: marked('first', 'replaced')}) + expect(container.innerHTML).toBe('

    replaced

    ') + await renderPortableText({value: null}) + expect(container.innerHTML).toBe('') +}) + +test('hydrates compiled output and reuses unchanged blocks after hydration', async () => { + const calls = vi.fn<(key: string) => void>() + function Custom({value}: PortableTextComponentProps) { + calls(value._key) + return + } + const components = {types: {custom: Custom}} + const first = custom('first') + const second = marked('second') + const value = [first, second] + const onRecoverableError = vi.fn() + container.innerHTML = renderToString() + const serverNode = container.firstChild + vi.clearAllMocks() + + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError, + }) + }) + expect(onRecoverableError).not.toHaveBeenCalled() + expect(container.firstChild).toBe(serverNode) + expect(calls).toHaveBeenCalledExactlyOnceWith('first') + expect(buildMarksTree).toHaveBeenCalledExactlyOnceWith(second) + vi.clearAllMocks() + + await renderPortableText({value: [...value], components}) + expect(calls).not.toHaveBeenCalled() + expect(buildMarksTree).not.toHaveBeenCalled() + await renderPortableText({value: [{...first, text: 'edited'}, second], components}) + expect(calls).toHaveBeenCalledExactlyOnceWith('first') + expect(buildMarksTree).not.toHaveBeenCalled() + expect(container.innerHTML).toBe('

    second

    ') + expect(onRecoverableError).not.toHaveBeenCalled() +}) diff --git a/test/fast-paths.test.tsx b/test/fast-paths.test.tsx new file mode 100644 index 0000000..5057d43 --- /dev/null +++ b/test/fast-paths.test.tsx @@ -0,0 +1,234 @@ +import {defaultComponents, PortableText, type PortableTextProps} from '@portabletext/react' +import {buildMarksTree, spanToPlainText} from '@portabletext/toolkit' +import type {PortableTextBlock} from '@portabletext/types' +import {isValidElement, type ReactNode} from 'react' +import {renderToStaticMarkup} from 'react-dom/server' +import {beforeEach, expect, test, vi} from 'vitest' + +vi.mock('@portabletext/toolkit', async (importOriginal) => { + const toolkit = await importOriginal() + return { + ...toolkit, + buildMarksTree: vi.fn(toolkit.buildMarksTree), + spanToPlainText: vi.fn(toolkit.spanToPlainText), + } +}) + +const render = (props: PortableTextProps) => renderToStaticMarkup() +const block = (text: string, marks: string[] = []): PortableTextBlock => ({ + _type: 'block', + _key: 'block', + children: [{_type: 'span', _key: 'span', text, marks}], + markDefs: [], +}) + +beforeEach(() => vi.clearAllMocks()) + +test('serializes each nested styled list item only once', () => { + const value = Array.from({length: 6}, (_, index) => ({ + ...block(`Item ${index}`, ['strong']), + _key: `item-${index}`, + style: 'h2', + listItem: 'bullet', + level: index + 1, + })) + const html = render({value}) + expect(html.match(//g)).toHaveLength(6) + expect(buildMarksTree).toHaveBeenCalledTimes(6) +}) + +test('does not warn for discarded children of a custom styled list block', () => { + const onMissingComponent = vi.fn() + expect( + render({ + value: {...block('Hidden', ['unknown']), listItem: 'bullet', style: 'h2'}, + components: {types: {block: () =>

    Replacement

    }}, + onMissingComponent, + }), + ).toBe('
    • Replacement

    ') + expect(buildMarksTree).not.toHaveBeenCalled() + expect(onMissingComponent).not.toHaveBeenCalled() +}) + +test('warns once for an unknown mark inside a styled list item', () => { + const onMissingComponent = vi.fn() + render({ + value: {...block('Text', ['unknown']), listItem: 'bullet', style: 'h2'}, + onMissingComponent, + }) + expect(onMissingComponent).toHaveBeenCalledTimes(1) +}) + +test.each(['', 'Plain text', '&text'])('fast path preserves text %j', (text) => { + const value = block(text) + const inspect = vi.fn(({children}) =>

    {children}

    ) + render({value, components: {block: inspect}}) + expect(inspect.mock.calls[0]?.[0].children).toEqual([text]) + expect(buildMarksTree).not.toHaveBeenCalled() +}) + +test('fast path accepts absent marks and preserves block props', () => { + const value = block('Text') + delete value.children[0]!.marks + const inspect = vi.fn(({children}) =>

    {children}

    ) + expect(render({value, components: {block: inspect}})).toBe('

    Text

    ') + expect(inspect.mock.calls[0]?.[0]).toMatchObject({value, index: 0, isInline: false}) + expect(buildMarksTree).not.toHaveBeenCalled() +}) + +test('retains custom toolkit text dispatch', () => { + expect( + render({ + value: block('Text'), + components: {types: {'@text': ({value}) => {value.text}}}, + }), + ).toBe('

    Text

    ') + expect(buildMarksTree).not.toHaveBeenCalled() +}) + +test.each([undefined, false, () =>
    ] as const)('retains hard-break behavior', (hardBreak) => { + const components = hardBreak === undefined ? undefined : {hardBreak} + const result = render({value: block('a\n\nb'), components}) + expect(result).toBe( + hardBreak === false + ? '

    a\n\nb

    ' + : hardBreak + ? '

    a



    b

    ' + : '

    a

    b

    ', + ) + expect(buildMarksTree).toHaveBeenCalledTimes(1) +}) + +test('preserves aggregate text for built-in marks', () => { + expect(render({value: block('Text', ['strong', 'em'])})).toBe( + '

    Text

    ', + ) + expect(spanToPlainText).toHaveBeenCalledTimes(2) +}) + +test('built-in marks expose complete text through the public renderer', () => { + const value = block('Visible text', ['strong']) + render({ + value: {_type: 'inspect'}, + components: { + types: { + inspect: ({renderNode}) => { + const rendered = renderNode({node: value, index: 0, isInline: false, renderNode}) + expect(isValidElement(rendered)).toBe(true) + if (isValidElement<{children: ReactNode[]}>(rendered)) { + const mark = rendered.props.children[0] + expect(isValidElement(mark)).toBe(true) + if (isValidElement<{text: string}>(mark)) { + expect(mark.props.text).toBe('Visible text') + } + } + return rendered + }, + }, + }, + }) +}) + +test('preserves aggregate text for custom and unknown marks', () => { + const value = { + ...block('a\nb', ['strong', 'custom']), + children: [ + ...block('a\nb', ['strong', 'custom']).children, + {_type: 'inline', _key: 'inline', text: 'Excluded', marks: ['custom']}, + {...block('c', ['custom']).children[0]!, _key: 'last'}, + ], + } + const texts: string[] = [] + render({ + value, + components: { + types: {inline: () => Inline}, + marks: { + strong: ({text, children}) => { + texts.push(text) + return {children} + }, + }, + unknownMark: ({text, children}) => { + texts.push(text) + return {children} + }, + }, + onMissingComponent: false, + }) + expect(texts).toContain('a\nb') + expect(texts.join('')).not.toContain('Excluded') + expect(spanToPlainText).toHaveBeenCalled() +}) + +test('custom block and list item overrides still receive their full props', () => { + const value = {...block('Content'), listItem: 'bullet', style: 'h2'} + const heading = vi.fn(({children}) =>

    {children}

    ) + const listItem = vi.fn(({children}) =>
  • {children}
  • ) + expect(render({value, components: {block: {h2: heading}, listItem}})).toBe( + '
    • Content

    ', + ) + expect(listItem.mock.calls[0]?.[0]).toMatchObject({value, index: 0, isInline: false}) + expect(heading.mock.calls[0]?.[0]).toMatchObject({ + value: {style: 'h2', children: value.children}, + index: 0, + isInline: false, + renderNode: expect.any(Function), + }) +}) + +test('native default block fast paths follow component identity, not the style name', () => { + const defaults = defaultComponents.block + if (typeof defaults === 'function') throw new Error('Expected default style map') + expect( + render({ + value: {...block('Heading'), style: 'custom'}, + components: {block: {custom: defaults.h3}}, + }), + ).toBe('

    Heading

    ') +}) + +test('custom nested marks receive identical complete text including newlines', () => { + const text = 'Nested\ntext' + const inspect = vi.fn(({children}) => {children}) + render({ + value: block(text, ['outer', 'inner', 'strong']), + components: {marks: {outer: inspect, inner: inspect}}, + }) + expect(inspect).toHaveBeenCalledTimes(2) + for (const [props] of inspect.mock.calls) expect(props).toMatchObject({text}) +}) + +test.each([ + {text: 'Text', marks: [42]}, + {text: 42, marks: []}, +])('malformed spans do not take the fast path: %j', (span) => { + const value = block('Text') + value.children = [{_type: 'span', _key: 'span', ...span}] + const onMissingComponent = vi.fn() + render({value, onMissingComponent}) + expect(buildMarksTree).toHaveBeenCalledTimes(1) + expect(onMissingComponent).toHaveBeenCalledWith(expect.any(String), { + type: 'span', + nodeType: 'block', + }) +}) + +test('public renderNode ignores extra callback arguments from Array#map', () => { + expect( + render({ + value: {_type: 'custom', _key: 'custom'}, + components: { + types: { + custom: ({renderNode}) => ( +
    + {[block('First'), {...block('Second'), _key: 'second'}] + .map((node, index) => ({node, index, isInline: true, renderNode})) + .map(renderNode)} +
    + ), + }, + }, + }), + ).toBe('

    First

    Second

    ') +}) diff --git a/test/performance/README.md b/test/performance/README.md new file mode 100644 index 0000000..5272e7f --- /dev/null +++ b/test/performance/README.md @@ -0,0 +1,51 @@ +# Rendering benchmarks + +Run commands from the repository root. `pnpm test --run` runs the automated +rendering regressions, including compiler-managed reuse. The browser page below +is for manual production timing, not a substitute for CI tests. + +## Server rendering + +`pnpm bench` builds the package first, so `dist/` must be writable. It measures +static-markup first renders through both production entry points and checks +matching output in a suite setup hook. Rendering the uncompiled server entry +through `react-dom/server` measures serialization, not the full RSC transport. +`pnpm test:next` separately validates the packaged entry points in Next.js. + +Set `PORTABLETEXT_BENCHMARK_BASELINE` to a directory containing an earlier build's +`index.js` and `index.react-server.js` to add a comparison. + +**Both builds must resolve exactly the same React instance**, including +`react/compiler-runtime`, as this checkout's `react-dom/server`. Pointing at a +different checkout with its own React installation can cause an invalid-hook or +`useMemoCache` error. Copy the baseline artifacts into a temporary directory in +this checkout's ignored `node_modules/` (outside `dist/`, which the build cleans) +so dependencies resolve through this checkout. Do not install another React copy +beside those artifacts. + +## Browser rendering + +1. Run `pnpm build:bench`. +2. Run `pnpm bench:browser` and open the printed URL. +3. Click **Run benchmarks** and save the JSON output. + +The build writes to the ignored `test/performance/dist/` directory, emptying it on +each run. It does not modify the demo output. To benchmark an earlier build, set +`PORTABLETEXT_BENCHMARK_ENTRY` to its absolute `index.js` path when running +`pnpm build:bench`. The Vite config deduplicates React and React DOM. + +The fixtures cover plain prose, marks, flat lists, and deeply nested styled lists. +Report the **whole matrix**, including default/custom mounts, same-prop updates, +new arrays sharing blocks, one edited block, prepending, deleting the first block, +fully recreated values (such as refetches), and new component maps. Shared-object +updates can benefit substantially while simple mounts, refetches, and new component +maps can regress. Insertions/deletions shift indices and invalidate later blocks; +generated nested-list nodes also limit reuse. + +Results are medians of 20 samples after 5 warmups and include custom-component +render counts. Timings cover synchronous React rendering and commits, not paint, +network loading, or input cloning. Small timings are sensitive to timer resolution. +Record the compared commit hashes, browser version, machine, and all results. +Run allocation/GC profiling separately from timing measurements; sampled allocation +totals are not retained heap size. No machine-specific timing or allocation claims +are maintained in the package README. diff --git a/test/performance/cases.ts b/test/performance/cases.ts new file mode 100644 index 0000000..e39a7d0 --- /dev/null +++ b/test/performance/cases.ts @@ -0,0 +1,38 @@ +import type {PortableTextBlock} from '@portabletext/types' + +export function paragraph(key: string, text = key, marks: string[] = []): PortableTextBlock { + return { + _type: 'block', + _key: key, + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: `${key}-span`, text, marks}], + } +} + +export const cases: Record = { + prose: Array.from({length: 1000}, (_, i) => paragraph(`p-${i}`, 'A short plain paragraph.')), + marks: Array.from({length: 300}, (_, i) => ({ + ...paragraph(`m-${i}`), + children: Array.from({length: 12}, (_span, j) => ({ + _type: 'span' as const, + _key: `${i}-${j}`, + text: 'Marked text ', + marks: ['strong', 'em', 'underline'], + })), + })), + lists: Array.from({length: 1000}, (_, i) => ({ + ...paragraph(`l-${i}`), + listItem: 'bullet', + level: 1, + })), + styledLists: Array.from({length: 30}, (_, group) => [ + paragraph(`separator-${group}`), + ...Array.from({length: 8}, (_item, i) => ({ + ...paragraph(`s-${group}-${i}`, 'Nested styled item', ['strong', 'em']), + listItem: 'bullet', + style: 'h2', + level: i + 1, + })), + ]).flat(), +} diff --git a/test/performance/index.html b/test/performance/index.html new file mode 100644 index 0000000..c501462 --- /dev/null +++ b/test/performance/index.html @@ -0,0 +1,15 @@ + + + + + Portable Text production rendering benchmarks + + +

    Portable Text production rendering benchmarks

    +

    Run against a production build. Timings include synchronous React commits, not paint.

    + +
    Ready
    +
    + + + diff --git a/test/performance/main.tsx b/test/performance/main.tsx new file mode 100644 index 0000000..ce5e9cd --- /dev/null +++ b/test/performance/main.tsx @@ -0,0 +1,118 @@ +import { + PortableText, + type PortableTextComponentProps, + type PortableTextComponents, +} from '@portabletext/react' +import type {PortableTextBlock} from '@portabletext/types' +import {flushSync} from 'react-dom' +import {createRoot} from 'react-dom/client' + +import {cases, paragraph} from './cases' + +const fixture = document.querySelector('#fixture')! +const results = document.querySelector('#results')! +const button = document.querySelector('#run')! + +function median(samples: number[]) { + return [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]! +} + +function measure(value: PortableTextBlock[]) { + let renders = 0 + const recordRender = () => { + renders += 1 + } + function Block({children}: PortableTextComponentProps) { + recordRender() + return

    {children}

    + } + function ListItem({children}: PortableTextComponentProps) { + recordRender() + return
  • {children}
  • + } + const components: PortableTextComponents = {block: Block, listItem: ListItem} + const rows = [] + for (const scenario of [ + 'defaultMount', + 'mount', + 'sameProps', + 'newArray', + 'oneEdit', + 'prepend', + 'deleteFirst', + 'recreate', + 'newComponents', + ]) { + const samples: number[] = [] + const counts: number[] = [] + for (let run = 0; run < 25; run++) { + const root = createRoot(fixture) + try { + if (scenario !== 'mount' && scenario !== 'defaultMount') { + flushSync(() => root.render()) + } + let next = + scenario === 'recreate' + ? structuredClone(value) + : scenario === 'newArray' || scenario === 'oneEdit' + ? [...value] + : value + if (scenario === 'oneEdit') { + const index = Math.floor(next.length / 2) + const original = next[index]! + next[index] = { + ...original, + children: [ + ...original.children, + {_type: 'span', _key: 'edit', text: 'Edited', marks: []}, + ], + } + } + if (scenario === 'prepend') next = [paragraph('inserted', 'Inserted'), ...value] + if (scenario === 'deleteFirst') next = value.slice(1) + const overrides = + scenario === 'defaultMount' + ? undefined + : scenario === 'newComponents' + ? {...components} + : components + renders = 0 + const start = performance.now() + flushSync(() => root.render()) + const elapsed = performance.now() - start + if (run >= 5) { + samples.push(elapsed) + counts.push(renders) + } + } finally { + flushSync(() => root.unmount()) + } + } + rows.push({ + scenario, + medianMs: median(samples), + renders: scenario === 'defaultMount' ? undefined : median(counts), + }) + } + return rows +} + +button.addEventListener('click', async () => { + button.disabled = true + delete results.dataset['status'] + results.textContent = 'Running' + try { + const timings: Record> = {} + for (const [name, value] of Object.entries(cases)) { + await new Promise((resolve) => setTimeout(resolve, 0)) + timings[name] = measure(value) + results.textContent = JSON.stringify({timings}, null, 2) + } + results.dataset['status'] = 'passed' + } catch (error) { + results.textContent = error instanceof Error ? error.stack || error.message : String(error) + results.dataset['status'] = 'failed' + } finally { + button.disabled = false + } +}) diff --git a/test/performance/vite.config.ts b/test/performance/vite.config.ts new file mode 100644 index 0000000..5c51cfd --- /dev/null +++ b/test/performance/vite.config.ts @@ -0,0 +1,14 @@ +import {fileURLToPath} from 'node:url' + +import {defineConfig} from 'vite' + +export default defineConfig({ + resolve: { + alias: { + '@portabletext/react': + process.env['PORTABLETEXT_BENCHMARK_ENTRY'] || + fileURLToPath(new URL('../../dist/index.js', import.meta.url)), + }, + dedupe: ['react', 'react-dom'], + }, +}) diff --git a/test/render.bench.ts b/test/render.bench.ts new file mode 100644 index 0000000..8d39d67 --- /dev/null +++ b/test/render.bench.ts @@ -0,0 +1,45 @@ +import {resolve} from 'node:path' +import {pathToFileURL} from 'node:url' + +import {createElement} from 'react' +import {renderToStaticMarkup} from 'react-dom/server' +import {beforeAll, bench, describe, expect} from 'vitest' + +import {cases} from './performance/cases' + +// Benchmark built artifacts, not the source export used by the ordinary unit tests. +const entries: Array<[string, string]> = [ + ['current SSR', resolve('dist/index.js')], + ['current server entry', resolve('dist/index.react-server.js')], +] +const baseline = process.env['PORTABLETEXT_BENCHMARK_BASELINE'] +if (baseline) { + entries.push( + ['baseline SSR', resolve(baseline, 'index.js')], + ['baseline server entry', resolve(baseline, 'index.react-server.js')], + ) +} + +for (const [name, value] of Object.entries(cases)) { + describe(name, async () => { + const renders: Array<() => string> = [] + for (const [label, path] of entries) { + const {PortableText} = (await import( + /* @vite-ignore */ pathToFileURL(path).href + )) as typeof import('../src') + const render = () => renderToStaticMarkup(createElement(PortableText, {value})) + renders.push(render) + bench( + label, + () => { + render() + }, + {time: 1000, warmupTime: 200}, + ) + } + beforeAll(() => { + const reference = renders[0]?.() + for (const render of renders) expect(render()).toBe(reference) + }) + }) +} diff --git a/tsconfig.json b/tsconfig.json index a6762b7..7ffe871 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@sanity/tsconfig/strictest", - "exclude": ["demo/dist", "dist", "node_modules", "test/next-app"], + "exclude": ["demo/dist", "dist", "node_modules", "test/next-app", "test/performance/dist"], "compilerOptions": { "rootDir": ".", "outDir": "./dist", diff --git a/vitest.config.ts b/vitest.config.ts index 03a6501..64e9c48 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,7 @@ -import {defineConfig} from 'vitest/config' +import react from '@vitejs/plugin-react' +import {configDefaults, defineConfig} from 'vitest/config' + +const compiledTests = ['test/compiled-reuse.test.tsx'] export default defineConfig({ test: { @@ -6,5 +9,28 @@ export default defineConfig({ enabled: true, ignoreSourceErrors: true, }, + projects: [ + { + extends: true, + test: { + name: 'source', + exclude: [...configDefaults.exclude, ...compiledTests], + }, + }, + { + extends: true, + // Match the production compiler without compiling the test components themselves. + plugins: react({include: /\/src\/.*\.tsx?$/, compiler: {target: '19'}}).filter( + (plugin) => plugin.name === 'vite:react-compiler', + ), + test: { + name: 'compiled', + include: compiledTests, + environment: 'jsdom', + typecheck: {enabled: false}, + benchmark: {include: []}, + }, + }, + ], }, })