From 4846a83e5326235fd342847b1391393103b93c31 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:08:14 +0000
Subject: [PATCH 1/4] Avoid duplicate styled-list serialization and unnecessary
text processing
Co-authored-by: stipsan <81981+stipsan@users.noreply.github.com>
---
src/react-portable-text.tsx | 40 +++++---
test/rendering-performance.test.tsx | 138 ++++++++++++++++++++++++++++
2 files changed, 164 insertions(+), 14 deletions(-)
create mode 100644 test/rendering-performance.test.tsx
diff --git a/src/react-portable-text.tsx b/src/react-portable-text.tsx
index d9b1df6..f5d4469 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,
@@ -14,6 +15,7 @@ import type {PortableTextBlock, PortableTextListItemBlock, TypedObject} from '@p
import type {JSX, ReactNode} from 'react'
import {defaultComponents} from './components/defaults'
+import {defaultMarks} from './components/marks'
import {mergeComponents} from './components/merge'
import type {
MissingComponentHandler,
@@ -33,6 +35,8 @@ import {
unknownTypeWarning,
} from './warnings'
+const builtInMarks = new Set(Object.values(defaultMarks))
+
export function PortableText({
value: input,
components: componentOverrides,
@@ -104,7 +108,6 @@ const getNodeRenderer = (
}
function renderListItem(node: PortableTextListItemBlock, index: number, key: string) {
- const tree = serializeBlock({node, index, isInline: false, renderNode})
const renderer = components.listItem
const handler = typeof renderer === 'function' ? renderer : renderer[node.listItem]
const Li = handler || components.unknownListItem
@@ -117,7 +120,7 @@ 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
@@ -127,6 +130,8 @@ const getNodeRenderer = (
isInline: false,
renderNode,
})
+ } else {
+ children = serializeBlock({node, index, isInline: false, renderNode}, components).children
}
return (
@@ -187,7 +192,7 @@ const getNodeRenderer = (
return (
): SerializedBlock {
+function serializeBlock(
+ options: Serializable,
+ components: PortableTextReactComponents,
+): 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 first = node.children[0]
+ // Keep the one-span case allocation-light, but preserve custom toolkit text renderers.
+ const children =
+ node.children.length === 1 &&
+ first &&
+ isPortableTextSpan(first) &&
+ (!first.marks || first.marks.length === 0) &&
+ !first.text.includes('\n') &&
+ !('@text' in components.types)
+ ? [first.text]
+ : buildMarksTree(node).map((child, i) =>
+ renderNode({node: child, isInline: true, index: i, renderNode}),
+ )
return {
_key: node._key || `block-${index}`,
diff --git a/test/rendering-performance.test.tsx b/test/rendering-performance.test.tsx
new file mode 100644
index 0000000..c15048e
--- /dev/null
+++ b/test/rendering-performance.test.tsx
@@ -0,0 +1,138 @@
+import {PortableText, type PortableTextProps} from '@portabletext/react'
+import {buildMarksTree, spanToPlainText} from '@portabletext/toolkit'
+import type {PortableTextBlock} from '@portabletext/types'
+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('')
+ 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).toHaveBeenCalledTimes(1)
+})
+
+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('does not aggregate unused plain text for built-in marks', () => {
+ expect(render({value: block('Text', ['strong', 'em'])})).toBe(
+ 'Text
',
+ )
+ expect(spanToPlainText).not.toHaveBeenCalled()
+})
+
+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()
+})
From e70d1ef92ea337e0d5f87e23162434f8c3265eba Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:19:34 +0000
Subject: [PATCH 2/4] Reuse unchanged blocks and prepared content with
compiler-managed boundaries
Co-authored-by: stipsan <81981+stipsan@users.noreply.github.com>
---
README.md | 15 ++
package.json | 3 +
src/react-portable-text.tsx | 189 ++++++++++++++-----
src/types.ts | 3 +
test/performance/cases.ts | 38 ++++
test/performance/index.html | 15 ++
test/performance/main.tsx | 281 ++++++++++++++++++++++++++++
test/performance/vite.config.ts | 14 ++
test/render.bench.ts | 43 +++++
test/rendering-performance.test.tsx | 52 ++++-
10 files changed, 602 insertions(+), 51 deletions(-)
create mode 100644 test/performance/cases.ts
create mode 100644 test/performance/index.html
create mode 100644 test/performance/main.tsx
create mode 100644 test/performance/vite.config.ts
create mode 100644 test/render.bench.ts
diff --git a/README.md b/README.md
index 310d9df..f313909 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,20 @@ 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 reuses unchanged blocks even 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. Recreating every block forfeits this reuse. Keep `components` and `onMissingComponent` referentially stable as well.
+
+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. The server-component build uses the same rendering fast paths without React memoization hooks or cross-request caches.
+
+### Running the benchmarks
+
+- `pnpm bench` measures first-render static markup from both production entry points and checks their output matches.
+- `pnpm build:bench && pnpm bench:browser` serves a production-browser benchmark page. Click **Run benchmarks and regression checks**. It checks block reuse, component/handler invalidation, context, keys, and list edits, then measures mounts and updates with identical props, new arrays, one edited block, recreated values, and changed components.
+- To compare an earlier build, set `PORTABLETEXT_BENCHMARK_BASELINE` to a directory containing its `index.js` and `index.react-server.js` when running `pnpm bench`. For the browser build, set `PORTABLETEXT_BENCHMARK_ENTRY` to the earlier build's absolute `index.js` path when running `pnpm build:bench`, then open the page with `?baseline`. That flag skips optimization-specific assertions, not output checks. The earlier artifacts must be able to resolve their dependencies.
+
+Browser results report median synchronous render-and-commit time after warmup and custom-component render counts; they exclude paint, network loading, and input cloning. Small timings are sensitive to browser timer resolution. Compare the same fixtures, browser, and machine, and use a browser allocation/GC profile separately from timing runs to assess memory costs.
+
## 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..76c46a4 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,10 @@
},
"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 --outDir ../../demo/dist/performance",
"build": "tsdown",
+ "build:bench": "pnpm build && vite build test/performance --config test/performance/vite.config.ts --outDir ../../demo/dist/performance",
"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",
diff --git a/src/react-portable-text.tsx b/src/react-portable-text.tsx
index f5d4469..b55fe98 100644
--- a/src/react-portable-text.tsx
+++ b/src/react-portable-text.tsx
@@ -14,12 +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 {defaultMarks} from './components/marks'
import {mergeComponents} from './components/merge'
import type {
+ DefaultPortableTextBlockStyle,
MissingComponentHandler,
- NodeRenderer,
+ PortableTextComponents,
PortableTextProps,
PortableTextReactComponents,
ReactPortableTextList,
@@ -36,6 +38,19 @@ import {
} from './warnings'
const builtInMarks = new Set(Object.values(defaultMarks))
+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
+interface Renderer {
+ renderNode: (options: Serializable, children?: BlockChildren) => ReactNode
+ types: PortableTextReactComponents['types']
+}
export function PortableText({
value: input,
@@ -44,6 +59,26 @@ 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
@@ -52,36 +87,73 @@ export function PortableText({
} else {
blocks = [input]
}
- const nested = nestLists(blocks, listNestingMode || LIST_NEST_MODE_HTML)
+ return nestLists(blocks, listNestingMode || LIST_NEST_MODE_HTML)
+}
+
+// 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)
+}
- // 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
+function RenderedNode({
+ node,
+ index,
+ isInline,
+ renderer,
+}: Omit, 'renderNode'> & {renderer: Renderer}): ReactNode {
+ 'use memo'
+ const custom = node._type in renderer.types
+ const listItem = 'listItem' in node && isPortableTextListItemBlock(node)
+ const prepare = listItem
+ ? !(custom && node.style && node.style !== 'normal')
+ : !custom && isPortableTextBlock(node)
+ const children = useBlockChildren(prepare ? (node as PortableTextBlock) : undefined)
+ const {renderNode} = renderer
+ return renderNode({node, index, isInline, renderNode}, children)
+}
- const renderNode = getNodeRenderer(components, handleMissingComponent)
- const rendered = nested.map((node, index) =>
- renderNode({node: node, index, isInline: false, renderNode}),
- )
+function useBlockChildren(node: PortableTextBlock | undefined): BlockChildren | undefined {
+ 'use memo'
+ return node ? prepareBlockChildren(node) : undefined
+}
- return <>{rendered}>
+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 {
+): Renderer => {
+ function renderNode(
+ options: Serializable,
+ children?: BlockChildren,
+ ): ReactNode {
const {node, index, isInline} = options
+ if (isPortableTextToolkitTextNode(node) && !hasCustomComponentForNode(node)) {
+ return node.text === '\n' ? renderText(node, node._key || `node-${index}`) : node.text
+ }
const key = node._key || `node-${index}`
if (isPortableTextToolkitList(node)) {
return renderList(node, index, key)
}
- if (isPortableTextListItemBlock(node)) {
- return renderListItem(node, index, key)
+ if ('listItem' in node && isPortableTextListItemBlock(node)) {
+ return renderListItem(node, index, key, children)
}
if (isPortableTextToolkitSpan(node)) {
@@ -93,11 +165,7 @@ const getNodeRenderer = (
}
if (isPortableTextBlock(node)) {
- return renderBlock(node, index, key, isInline)
- }
-
- if (isPortableTextToolkitTextNode(node)) {
- return renderText(node, key)
+ return renderBlock(node, index, key, isInline, children)
}
return renderUnknownType(node, index, key, isInline)
@@ -107,7 +175,12 @@ const getNodeRenderer = (
return node._type in components.types
}
- function renderListItem(node: PortableTextListItemBlock, index: number, key: string) {
+ 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
@@ -124,14 +197,17 @@ const getNodeRenderer = (
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 = renderNode({node: blockNode, index, isInline: false, renderNode}, prepared)
} else {
- children = serializeBlock({node, index, isInline: false, renderNode}, components).children
+ children = serializeBlock(
+ {node, index, isInline: false, renderNode},
+ components,
+ prepared,
+ ).children
+ }
+
+ if (Li === DefaultListItem) {
+ return {children}
}
return (
@@ -142,14 +218,15 @@ 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]
@@ -203,8 +280,18 @@ const getNodeRenderer = (
)
}
- function renderBlock(node: PortableTextBlock, index: number, key: string, isInline: boolean) {
- const {_key, ...props} = serializeBlock({node, index, isInline, renderNode}, components)
+ 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 +304,11 @@ const getNodeRenderer = (
})
}
+ const Tag = builtInBlocks.get(Block)
+ if (Tag) {
+ return {props.children}
+ }
+
return
}
@@ -258,25 +350,22 @@ const getNodeRenderer = (
return Node ? : null
}
- return renderNode
+ const renderer = {renderNode, types: components.types}
+ return renderer
}
function serializeBlock(
options: Serializable,
components: PortableTextReactComponents,
+ prepared?: BlockChildren,
): SerializedBlock {
const {node, index, isInline, renderNode} = options
- const first = node.children[0]
+ const tree = prepared ?? prepareBlockChildren(node)
// Keep the one-span case allocation-light, but preserve custom toolkit text renderers.
const children =
- node.children.length === 1 &&
- first &&
- isPortableTextSpan(first) &&
- (!first.marks || first.marks.length === 0) &&
- !first.text.includes('\n') &&
- !('@text' in components.types)
- ? [first.text]
- : buildMarksTree(node).map((child, i) =>
+ typeof tree === 'string' && !('@text' in components.types)
+ ? [tree]
+ : (typeof tree === 'string' ? buildMarksTree(node) : tree).map((child, i) =>
renderNode({node: child, isInline: true, index: i, renderNode}),
)
diff --git a/src/types.ts b/src/types.ts
index d5ef76c..532c227 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -23,6 +23,9 @@ export interface PortableTextProps<
> {
/**
* One or more blocks to render
+ *
+ * Treat as immutable: replace edited blocks and retain unchanged block references
+ * to allow the compiled build to reuse their rendered output.
*/
value: B | B[] | null | undefined
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..fe580ad
--- /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.
+ Run benchmarks and regression checks
+ Ready
+
+
+
+
diff --git a/test/performance/main.tsx b/test/performance/main.tsx
new file mode 100644
index 0000000..d8f5d13
--- /dev/null
+++ b/test/performance/main.tsx
@@ -0,0 +1,281 @@
+import {
+ PortableText,
+ type PortableTextComponentProps,
+ type PortableTextComponents,
+ type PortableTextProps,
+} from '@portabletext/react'
+import type {PortableTextBlock} from '@portabletext/types'
+import {createContext, useContext, useState} from 'react'
+import {flushSync} from 'react-dom'
+import {createRoot, hydrateRoot} from 'react-dom/client'
+import {renderToStaticMarkup} from 'react-dom/server'
+
+import {cases, paragraph} from './cases'
+
+const fixture = document.querySelector('#fixture')!
+const results = document.querySelector('#results')!
+const button = document.querySelector('#run')!
+const baseline = new URLSearchParams(location.search).has('baseline')
+
+function assert(condition: unknown, message: string): void {
+ if (!condition) throw new Error(message)
+}
+
+async function checkUpdates() {
+ const root = createRoot(fixture)
+ let renders = 0
+ const recordRender = () => {
+ renders += 1
+ }
+ let lookups = 0
+ const block = (key: string, text: string) => {
+ const value = paragraph(key, text, ['annotation'])
+ value.markDefs = [{_type: 'label', _key: 'annotation'}]
+ const find = value.markDefs.find.bind(value.markDefs)
+ Object.defineProperty(value.markDefs, 'find', {
+ value: (...args: Parameters) => {
+ lookups += 1
+ return find(...args)
+ },
+ })
+ return value
+ }
+ const context = createContext('initial')
+ function Block({children, index, value}: PortableTextComponentProps) {
+ recordRender()
+ const current = useContext(context)
+ const [identity] = useState(value._key)
+ return (
+
+ {children}
+
+ )
+ }
+ const components: PortableTextComponents = {
+ block: Block,
+ marks: {label: ({children, text}) => {children} },
+ }
+ const first = block('a', 'A')
+ const second = block('b', 'B')
+ let value = [first, second]
+ const render = (props: PortableTextProps, contextValue = 'initial') =>
+ flushSync(() =>
+ root.render(
+
+
+ ,
+ ),
+ )
+ try {
+ render({value, components})
+ assert(renders === 2 && lookups === 2, 'initial render serializes both blocks')
+ renders = 0
+ lookups = 0
+ render({value, components})
+ assert(renders === 0 && lookups === 0, 'identical props reuse the entire tree')
+ render({value: [...value], components})
+ if (!baseline) assert(renders === 0 && lookups === 0, 'new array reuses unchanged blocks')
+
+ renders = 0
+ lookups = 0
+ value = [first, block('b', 'Changed')]
+ render({value, components})
+ if (!baseline) assert(renders === 1 && lookups === 1, 'only the edited block is serialized')
+ assert(fixture.textContent === 'AChanged', 'edited content is visible')
+ render({value: structuredClone(value), components})
+ assert(fixture.textContent === 'AChanged', 'recreated objects with the same keys are fresh')
+ render({value, components})
+
+ renders = 0
+ lookups = 0
+ const updatedComponents: PortableTextComponents = {
+ ...components,
+ marks: {label: ({text}) => {text.toUpperCase()} },
+ }
+ render({value, components: updatedComponents})
+ assert(fixture.textContent === 'ACHANGED', 'component overrides invalidate rendered children')
+ if (!baseline) assert(lookups === 0, 'presentation changes reuse prepared marks trees')
+ render({value, components: updatedComponents}, 'updated')
+ assert(
+ [...fixture.querySelectorAll('p')].every((p) => p.dataset['context'] === 'updated'),
+ 'context consumers still update through reused elements',
+ )
+
+ render({value: [value[1]!, value[0]!], components})
+ assert(fixture.querySelector('p')?.dataset['identity'] === 'b', 'keys preserve component state')
+ assert(fixture.querySelector('p')?.dataset['index'] === '0', 'reordered indices update')
+
+ let warnings = 0
+ const unknown = [{_type: 'missing', _key: 'unknown'}]
+ render({
+ value: unknown,
+ onMissingComponent: () => {
+ warnings++
+ },
+ })
+ render({
+ value: unknown,
+ onMissingComponent: () => {
+ warnings += 10
+ },
+ })
+ assert(warnings === 11, 'missing-component handler updates are not stale')
+ render({value: unknown, onMissingComponent: false})
+ assert(warnings === 11, 'warnings can be disabled after rendering')
+
+ const list = [
+ {...paragraph('one', 'One'), listItem: 'bullet', level: 1},
+ {...paragraph('two', 'Two'), listItem: 'bullet', level: 2, style: 'h2'},
+ {...paragraph('three', 'Three'), listItem: 'number', level: 1},
+ ]
+ for (const mode of ['html', 'direct'] as const) {
+ for (const current of [list, list.slice(1), list.toReversed()]) {
+ const props = {value: current, listNestingMode: mode}
+ render(props)
+ const expected = document.createElement('div')
+ expected.innerHTML = renderToStaticMarkup( )
+ assert(fixture.innerHTML === expected.innerHTML, 'list edits match a fresh render')
+ }
+ }
+ render({
+ value: [{_type: 'custom', _key: 'a'}],
+ components: {
+ types: {
+ custom: ({renderNode}) =>
+ renderNode({
+ node: paragraph('nested', 'Nested'),
+ index: 7,
+ isInline: true,
+ renderNode,
+ }),
+ },
+ },
+ })
+ assert(fixture.textContent === 'Nested', 'public renderNode remains synchronous and usable')
+ for (const current of [null, undefined, [], paragraph('single', 'Single')]) {
+ render({value: current})
+ assert(
+ fixture.textContent === (current && !Array.isArray(current) ? 'Single' : ''),
+ 'empty and single-block transitions render correctly',
+ )
+ }
+ } finally {
+ flushSync(() => root.unmount())
+ }
+ const hydrationValue = cases['marks']!.slice(0, 2)
+ fixture.innerHTML = renderToStaticMarkup( )
+ const html = fixture.innerHTML
+ const errors: unknown[] = []
+ const hydrated = hydrateRoot(fixture, , {
+ onRecoverableError: (error) => errors.push(error),
+ })
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ assert(errors.length === 0 && fixture.innerHTML === html, 'SSR markup hydrates without errors')
+ flushSync(() => hydrated.render( ))
+ assert(fixture.innerHTML === html, 'hydrated content supports subsequent updates')
+ } finally {
+ flushSync(() => hydrated.unmount())
+ }
+}
+
+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',
+ '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( ))
+ }
+ const 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: []},
+ ],
+ }
+ }
+ 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 {
+ await checkUpdates()
+ 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({checks: 'passed', 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..9445d3e
--- /dev/null
+++ b/test/render.bench.ts
@@ -0,0 +1,43 @@
+import {resolve} from 'node:path'
+import {pathToFileURL} from 'node:url'
+
+import {createElement} from 'react'
+import {renderToStaticMarkup} from 'react-dom/server'
+import {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 = [
+ ['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 () => {
+ let reference: string | undefined
+ 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}))
+ const html = render()
+ reference ??= html
+ expect(html).toBe(reference)
+ bench(
+ label!,
+ () => {
+ render()
+ },
+ {time: 1000, warmupTime: 200},
+ )
+ }
+ })
+}
diff --git a/test/rendering-performance.test.tsx b/test/rendering-performance.test.tsx
index c15048e..286e8dd 100644
--- a/test/rendering-performance.test.tsx
+++ b/test/rendering-performance.test.tsx
@@ -1,4 +1,4 @@
-import {PortableText, type PortableTextProps} from '@portabletext/react'
+import {defaultComponents, PortableText, type PortableTextProps} from '@portabletext/react'
import {buildMarksTree, spanToPlainText} from '@portabletext/toolkit'
import type {PortableTextBlock} from '@portabletext/types'
import {renderToStaticMarkup} from 'react-dom/server'
@@ -136,3 +136,53 @@ test('preserves aggregate text for custom and unknown marks', () => {
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(
+ '',
+ )
+ 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('empty and malformed span marks do not incorrectly take the fast path', () => {
+ const value = block('Text')
+ value.children[0]!.marks = [42]
+ const onMissingComponent = vi.fn()
+ render({value, onMissingComponent})
+ expect(buildMarksTree).toHaveBeenCalledTimes(1)
+ expect(onMissingComponent).toHaveBeenCalledWith(expect.any(String), {
+ type: 'span',
+ nodeType: 'block',
+ })
+})
From 0e51a965632f7e2c902a2b641d542e8294b4b6e0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Oct 2026 09:24:36 +0000
Subject: [PATCH 3/4] Verify production rendering benchmarks and document
performance tradeoffs
Co-authored-by: stipsan <81981+stipsan@users.noreply.github.com>
---
README.md | 11 +++++++++++
test/performance/main.tsx | 4 ++--
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index f313909..28ad638 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,17 @@ Marks-tree preparation is independent of presentation, so changing components ca
Browser results report median synchronous render-and-commit time after warmup and custom-component render counts; they exclude paint, network loading, and input cloning. Small timings are sensitive to browser timer resolution. Compare the same fixtures, browser, and machine, and use a browser allocation/GC profile separately from timing runs to assess memory costs.
+Representative Linux/Chrome 154 production measurements (20 samples after 5 warmups):
+
+| Fixture / operation | Before | After |
+| ---------------------------------- | ---------------------------: | ----------------------: |
+| Nested styled lists, default mount | 18.8 ms | 1.7 ms |
+| Marked paragraphs, edit one block | 4.5 ms / 300 block renders | 0.2 ms / 1 block render |
+| Plain paragraphs, edit one block | 1.1 ms / 1,000 block renders | 0.5 ms / 1 block render |
+| Plain paragraphs, default mount | 2.8 ms | 3.4 ms |
+
+These are synthetic results, not universal speedup guarantees. Per-block bookkeeping can cost more on simple mounts or fully recreated documents. Generated nested-list items are not all reusable. In a separate allocation-sampling run of the full browser suite, estimated allocations fell from 8.8 GB to 1.6 GB and GC events from 290 to 71, largely because deeply styled lists no longer build discarded subtrees; these totals are not retained heap size.
+
## 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/test/performance/main.tsx b/test/performance/main.tsx
index d8f5d13..96cacb5 100644
--- a/test/performance/main.tsx
+++ b/test/performance/main.tsx
@@ -8,7 +8,7 @@ import type {PortableTextBlock} from '@portabletext/types'
import {createContext, useContext, useState} from 'react'
import {flushSync} from 'react-dom'
import {createRoot, hydrateRoot} from 'react-dom/client'
-import {renderToStaticMarkup} from 'react-dom/server'
+import {renderToStaticMarkup, renderToString} from 'react-dom/server'
import {cases, paragraph} from './cases'
@@ -163,7 +163,7 @@ async function checkUpdates() {
flushSync(() => root.unmount())
}
const hydrationValue = cases['marks']!.slice(0, 2)
- fixture.innerHTML = renderToStaticMarkup( )
+ fixture.innerHTML = renderToString( )
const html = fixture.innerHTML
const errors: unknown[] = []
const hydrated = hydrateRoot(fixture, , {
From 6d6b6262ba1b54820f93ec5e9ddf047ba740538c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:44:27 +0000
Subject: [PATCH 4/4] Address rendering review: preserve callback API and
automate compiler reuse tests
Co-authored-by: stipsan <81981+stipsan@users.noreply.github.com>
---
.changeset/modern-mammals-walk.md | 17 +
.gitignore | 1 +
.oxlintrc.json | 1 +
README.md | 23 +-
package.json | 7 +-
pnpm-lock.yaml | 3 +
src/react-portable-text.tsx | 121 ++++--
src/types.ts | 6 +-
test/compiled-reuse.test.tsx | 375 ++++++++++++++++++
...rformance.test.tsx => fast-paths.test.tsx} | 56 ++-
test/performance/README.md | 51 +++
test/performance/index.html | 2 +-
test/performance/main.tsx | 177 +--------
test/render.bench.ts | 18 +-
tsconfig.json | 2 +-
vitest.config.ts | 28 +-
16 files changed, 635 insertions(+), 253 deletions(-)
create mode 100644 .changeset/modern-mammals-walk.md
create mode 100644 test/compiled-reuse.test.tsx
rename test/{rendering-performance.test.tsx => fast-paths.test.tsx} (78%)
create mode 100644 test/performance/README.md
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 28ad638..efb7c9e 100644
--- a/README.md
+++ b/README.md
@@ -53,28 +53,11 @@ import {PortableText} from '@portabletext/react'
## Rendering performance
-The compiled build reuses unchanged blocks even 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. Recreating every block forfeits this reuse. Keep `components` and `onMissingComponent` referentially stable as well.
+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. The server-component build uses the same rendering fast paths without React memoization hooks or cross-request caches.
+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.
-### Running the benchmarks
-
-- `pnpm bench` measures first-render static markup from both production entry points and checks their output matches.
-- `pnpm build:bench && pnpm bench:browser` serves a production-browser benchmark page. Click **Run benchmarks and regression checks**. It checks block reuse, component/handler invalidation, context, keys, and list edits, then measures mounts and updates with identical props, new arrays, one edited block, recreated values, and changed components.
-- To compare an earlier build, set `PORTABLETEXT_BENCHMARK_BASELINE` to a directory containing its `index.js` and `index.react-server.js` when running `pnpm bench`. For the browser build, set `PORTABLETEXT_BENCHMARK_ENTRY` to the earlier build's absolute `index.js` path when running `pnpm build:bench`, then open the page with `?baseline`. That flag skips optimization-specific assertions, not output checks. The earlier artifacts must be able to resolve their dependencies.
-
-Browser results report median synchronous render-and-commit time after warmup and custom-component render counts; they exclude paint, network loading, and input cloning. Small timings are sensitive to browser timer resolution. Compare the same fixtures, browser, and machine, and use a browser allocation/GC profile separately from timing runs to assess memory costs.
-
-Representative Linux/Chrome 154 production measurements (20 samples after 5 warmups):
-
-| Fixture / operation | Before | After |
-| ---------------------------------- | ---------------------------: | ----------------------: |
-| Nested styled lists, default mount | 18.8 ms | 1.7 ms |
-| Marked paragraphs, edit one block | 4.5 ms / 300 block renders | 0.2 ms / 1 block render |
-| Plain paragraphs, edit one block | 1.1 ms / 1,000 block renders | 0.5 ms / 1 block render |
-| Plain paragraphs, default mount | 2.8 ms | 3.4 ms |
-
-These are synthetic results, not universal speedup guarantees. Per-block bookkeeping can cost more on simple mounts or fully recreated documents. Generated nested-list items are not all reusable. In a separate allocation-sampling run of the full browser suite, estimated allocations fell from 8.8 GB to 1.6 GB and GC events from 290 to 71, largely because deeply styled lists no longer build discarded subtrees; these totals are not retained heap size.
+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
diff --git a/package.json b/package.json
index 76c46a4..865d19e 100644
--- a/package.json
+++ b/package.json
@@ -39,12 +39,12 @@
"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 --outDir ../../demo/dist/performance",
+ "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 --outDir ../../demo/dist/performance",
+ "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",
@@ -76,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 b55fe98..d88aebc 100644
--- a/src/react-portable-text.tsx
+++ b/src/react-portable-text.tsx
@@ -16,11 +16,11 @@ import type {JSX, ReactNode} from 'react'
import {defaultBlockStyles, defaultComponents} from './components/defaults'
import {DefaultListItem} from './components/list'
-import {defaultMarks} from './components/marks'
import {mergeComponents} from './components/merge'
import type {
DefaultPortableTextBlockStyle,
MissingComponentHandler,
+ NodeRenderer,
PortableTextComponents,
PortableTextProps,
PortableTextReactComponents,
@@ -37,7 +37,7 @@ import {
unknownTypeWarning,
} from './warnings'
-const builtInMarks = new Set(Object.values(defaultMarks))
+// 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]) => [
@@ -47,9 +47,23 @@ const builtInBlocks = new Map(
)
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: (options: Serializable, children?: BlockChildren) => ReactNode
- types: PortableTextReactComponents['types']
+ renderNode: NodeRenderer
+ classify: (node: TypedObject) => ClassifiedNode
+ render: (
+ options: Serializable,
+ classified: ClassifiedNode,
+ children?: BlockChildren,
+ ) => ReactNode
}
export function PortableText({
@@ -66,7 +80,6 @@ export function PortableText({
key={node._key || `node-${index}`}
node={node}
index={index}
- isInline={false}
renderer={renderer}
/>
))
@@ -104,18 +117,18 @@ function useRenderer(
function RenderedNode({
node,
index,
- isInline,
renderer,
-}: Omit, 'renderNode'> & {renderer: Renderer}): ReactNode {
+}: Pick, 'node' | 'index'> & {renderer: Renderer}): ReactNode {
'use memo'
- const custom = node._type in renderer.types
- const listItem = 'listItem' in node && isPortableTextListItemBlock(node)
- const prepare = listItem
- ? !(custom && node.style && node.style !== 'normal')
- : !custom && isPortableTextBlock(node)
- const children = useBlockChildren(prepare ? (node as PortableTextBlock) : undefined)
+ const classified = useClassifiedNode(node, renderer.classify)
+ const children = useBlockChildren(classified.block)
const {renderNode} = renderer
- return renderNode({node, index, isInline, renderNode}, children)
+ return renderer.render({node, index, isInline: false, renderNode}, classified, children)
+}
+
+function useClassifiedNode(node: TypedObject, classify: Renderer['classify']): ClassifiedNode {
+ 'use memo'
+ return classify(node)
}
function useBlockChildren(node: PortableTextBlock | undefined): BlockChildren | undefined {
@@ -138,37 +151,55 @@ const getNodeRenderer = (
components: PortableTextReactComponents,
handleMissingComponent: MissingComponentHandler,
): Renderer => {
- function renderNode(
- options: Serializable,
- children?: BlockChildren,
- ): ReactNode {
- const {node, index, isInline} = options
+ function classify(node: TypedObject): ClassifiedNode {
if (isPortableTextToolkitTextNode(node) && !hasCustomComponentForNode(node)) {
- return node.text === '\n' ? renderText(node, node._key || `node-${index}`) : node.text
+ return {kind: 'text', node}
}
- const key = node._key || `node-${index}`
-
if (isPortableTextToolkitList(node)) {
- return renderList(node, index, key)
+ return {kind: 'list', node}
}
-
if ('listItem' in node && isPortableTextListItemBlock(node)) {
- return renderListItem(node, index, key, children)
+ 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, children)
+ return {kind: 'block', node, block: node}
}
+ return {kind: 'unknown', node}
+ }
+
+ // 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 {
@@ -197,7 +228,11 @@ const getNodeRenderer = (
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}, prepared)
+ children = render(
+ {node: blockNode, index, isInline: false, renderNode},
+ classify(blockNode),
+ prepared,
+ )
} else {
children = serializeBlock(
{node, index, isInline: false, renderNode},
@@ -223,7 +258,6 @@ const getNodeRenderer = (
key={child._key || `li-${index}-${childIndex}`}
node={child._key ? child : {...child, _key: `li-${index}-${childIndex}`}}
index={childIndex}
- isInline={false}
renderer={renderer}
/>
))
@@ -269,7 +303,7 @@ const getNodeRenderer = (
return (
: null
}
- const renderer = {renderNode, types: components.types}
+ const renderer = {renderNode, classify, render}
return renderer
}
@@ -361,13 +395,16 @@ function serializeBlock(
): SerializedBlock {
const {node, index, isInline, renderNode} = options
const tree = prepared ?? prepareBlockChildren(node)
- // Keep the one-span case allocation-light, but preserve custom toolkit text renderers.
+ // 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)
- ? [tree]
- : (typeof tree === 'string' ? buildMarksTree(node) : tree).map((child, i) =>
- renderNode({node: child, isInline: true, index: i, renderNode}),
- )
+ 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 532c227..8a86772 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -24,8 +24,10 @@ export interface PortableTextProps<
/**
* One or more blocks to render
*
- * Treat as immutable: replace edited blocks and retain unchanged block references
- * to allow the compiled build to reuse their rendered output.
+ * 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
+ }
+ 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('')
+})
+
+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) =>
+ '`
+ const direct = (text: string) =>
+ '`
+ 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/rendering-performance.test.tsx b/test/fast-paths.test.tsx
similarity index 78%
rename from test/rendering-performance.test.tsx
rename to test/fast-paths.test.tsx
index 286e8dd..5057d43 100644
--- a/test/rendering-performance.test.tsx
+++ b/test/fast-paths.test.tsx
@@ -1,6 +1,7 @@
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'
@@ -82,7 +83,7 @@ test('retains custom toolkit text dispatch', () => {
components: {types: {'@text': ({value}) => {value.text} }},
}),
).toBe('Text
')
- expect(buildMarksTree).toHaveBeenCalledTimes(1)
+ expect(buildMarksTree).not.toHaveBeenCalled()
})
test.each([undefined, false, () => ] as const)('retains hard-break behavior', (hardBreak) => {
@@ -98,11 +99,34 @@ test.each([undefined, false, () => ] as const)('retains hard-break behavio
expect(buildMarksTree).toHaveBeenCalledTimes(1)
})
-test('does not aggregate unused plain text for built-in marks', () => {
+test('preserves aggregate text for built-in marks', () => {
expect(render({value: block('Text', ['strong', 'em'])})).toBe(
'Text
',
)
- expect(spanToPlainText).not.toHaveBeenCalled()
+ 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', () => {
@@ -175,9 +199,12 @@ test('custom nested marks receive identical complete text including newlines', (
for (const [props] of inspect.mock.calls) expect(props).toMatchObject({text})
})
-test('empty and malformed span marks do not incorrectly take the fast path', () => {
+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[0]!.marks = [42]
+ value.children = [{_type: 'span', _key: 'span', ...span}]
const onMissingComponent = vi.fn()
render({value, onMissingComponent})
expect(buildMarksTree).toHaveBeenCalledTimes(1)
@@ -186,3 +213,22 @@ test('empty and malformed span marks do not incorrectly take the fast path', ()
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('')
+})
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/index.html b/test/performance/index.html
index fe580ad..c501462 100644
--- a/test/performance/index.html
+++ b/test/performance/index.html
@@ -7,7 +7,7 @@
Portable Text production rendering benchmarks
Run against a production build. Timings include synchronous React commits, not paint.
- Run benchmarks and regression checks
+ Run benchmarks
Ready
diff --git a/test/performance/main.tsx b/test/performance/main.tsx
index 96cacb5..ce5e9cd 100644
--- a/test/performance/main.tsx
+++ b/test/performance/main.tsx
@@ -2,182 +2,16 @@ import {
PortableText,
type PortableTextComponentProps,
type PortableTextComponents,
- type PortableTextProps,
} from '@portabletext/react'
import type {PortableTextBlock} from '@portabletext/types'
-import {createContext, useContext, useState} from 'react'
import {flushSync} from 'react-dom'
-import {createRoot, hydrateRoot} from 'react-dom/client'
-import {renderToStaticMarkup, renderToString} from 'react-dom/server'
+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')!
-const baseline = new URLSearchParams(location.search).has('baseline')
-
-function assert(condition: unknown, message: string): void {
- if (!condition) throw new Error(message)
-}
-
-async function checkUpdates() {
- const root = createRoot(fixture)
- let renders = 0
- const recordRender = () => {
- renders += 1
- }
- let lookups = 0
- const block = (key: string, text: string) => {
- const value = paragraph(key, text, ['annotation'])
- value.markDefs = [{_type: 'label', _key: 'annotation'}]
- const find = value.markDefs.find.bind(value.markDefs)
- Object.defineProperty(value.markDefs, 'find', {
- value: (...args: Parameters) => {
- lookups += 1
- return find(...args)
- },
- })
- return value
- }
- const context = createContext('initial')
- function Block({children, index, value}: PortableTextComponentProps) {
- recordRender()
- const current = useContext(context)
- const [identity] = useState(value._key)
- return (
-
- {children}
-
- )
- }
- const components: PortableTextComponents = {
- block: Block,
- marks: {label: ({children, text}) => {children} },
- }
- const first = block('a', 'A')
- const second = block('b', 'B')
- let value = [first, second]
- const render = (props: PortableTextProps, contextValue = 'initial') =>
- flushSync(() =>
- root.render(
-
-
- ,
- ),
- )
- try {
- render({value, components})
- assert(renders === 2 && lookups === 2, 'initial render serializes both blocks')
- renders = 0
- lookups = 0
- render({value, components})
- assert(renders === 0 && lookups === 0, 'identical props reuse the entire tree')
- render({value: [...value], components})
- if (!baseline) assert(renders === 0 && lookups === 0, 'new array reuses unchanged blocks')
-
- renders = 0
- lookups = 0
- value = [first, block('b', 'Changed')]
- render({value, components})
- if (!baseline) assert(renders === 1 && lookups === 1, 'only the edited block is serialized')
- assert(fixture.textContent === 'AChanged', 'edited content is visible')
- render({value: structuredClone(value), components})
- assert(fixture.textContent === 'AChanged', 'recreated objects with the same keys are fresh')
- render({value, components})
-
- renders = 0
- lookups = 0
- const updatedComponents: PortableTextComponents = {
- ...components,
- marks: {label: ({text}) => {text.toUpperCase()} },
- }
- render({value, components: updatedComponents})
- assert(fixture.textContent === 'ACHANGED', 'component overrides invalidate rendered children')
- if (!baseline) assert(lookups === 0, 'presentation changes reuse prepared marks trees')
- render({value, components: updatedComponents}, 'updated')
- assert(
- [...fixture.querySelectorAll('p')].every((p) => p.dataset['context'] === 'updated'),
- 'context consumers still update through reused elements',
- )
-
- render({value: [value[1]!, value[0]!], components})
- assert(fixture.querySelector('p')?.dataset['identity'] === 'b', 'keys preserve component state')
- assert(fixture.querySelector('p')?.dataset['index'] === '0', 'reordered indices update')
-
- let warnings = 0
- const unknown = [{_type: 'missing', _key: 'unknown'}]
- render({
- value: unknown,
- onMissingComponent: () => {
- warnings++
- },
- })
- render({
- value: unknown,
- onMissingComponent: () => {
- warnings += 10
- },
- })
- assert(warnings === 11, 'missing-component handler updates are not stale')
- render({value: unknown, onMissingComponent: false})
- assert(warnings === 11, 'warnings can be disabled after rendering')
-
- const list = [
- {...paragraph('one', 'One'), listItem: 'bullet', level: 1},
- {...paragraph('two', 'Two'), listItem: 'bullet', level: 2, style: 'h2'},
- {...paragraph('three', 'Three'), listItem: 'number', level: 1},
- ]
- for (const mode of ['html', 'direct'] as const) {
- for (const current of [list, list.slice(1), list.toReversed()]) {
- const props = {value: current, listNestingMode: mode}
- render(props)
- const expected = document.createElement('div')
- expected.innerHTML = renderToStaticMarkup( )
- assert(fixture.innerHTML === expected.innerHTML, 'list edits match a fresh render')
- }
- }
- render({
- value: [{_type: 'custom', _key: 'a'}],
- components: {
- types: {
- custom: ({renderNode}) =>
- renderNode({
- node: paragraph('nested', 'Nested'),
- index: 7,
- isInline: true,
- renderNode,
- }),
- },
- },
- })
- assert(fixture.textContent === 'Nested', 'public renderNode remains synchronous and usable')
- for (const current of [null, undefined, [], paragraph('single', 'Single')]) {
- render({value: current})
- assert(
- fixture.textContent === (current && !Array.isArray(current) ? 'Single' : ''),
- 'empty and single-block transitions render correctly',
- )
- }
- } finally {
- flushSync(() => root.unmount())
- }
- const hydrationValue = cases['marks']!.slice(0, 2)
- fixture.innerHTML = renderToString( )
- const html = fixture.innerHTML
- const errors: unknown[] = []
- const hydrated = hydrateRoot(fixture, , {
- onRecoverableError: (error) => errors.push(error),
- })
- try {
- await new Promise((resolve) => setTimeout(resolve, 50))
- assert(errors.length === 0 && fixture.innerHTML === html, 'SSR markup hydrates without errors')
- flushSync(() => hydrated.render( ))
- assert(fixture.innerHTML === html, 'hydrated content supports subsequent updates')
- } finally {
- flushSync(() => hydrated.unmount())
- }
-}
function median(samples: number[]) {
return [...samples].sort((a, b) => a - b)[Math.floor(samples.length / 2)]!
@@ -204,6 +38,8 @@ function measure(value: PortableTextBlock[]) {
'sameProps',
'newArray',
'oneEdit',
+ 'prepend',
+ 'deleteFirst',
'recreate',
'newComponents',
]) {
@@ -215,7 +51,7 @@ function measure(value: PortableTextBlock[]) {
if (scenario !== 'mount' && scenario !== 'defaultMount') {
flushSync(() => root.render( ))
}
- const next =
+ let next =
scenario === 'recreate'
? structuredClone(value)
: scenario === 'newArray' || scenario === 'oneEdit'
@@ -232,6 +68,8 @@ function measure(value: PortableTextBlock[]) {
],
}
}
+ if (scenario === 'prepend') next = [paragraph('inserted', 'Inserted'), ...value]
+ if (scenario === 'deleteFirst') next = value.slice(1)
const overrides =
scenario === 'defaultMount'
? undefined
@@ -264,12 +102,11 @@ button.addEventListener('click', async () => {
delete results.dataset['status']
results.textContent = 'Running'
try {
- await checkUpdates()
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({checks: 'passed', timings}, null, 2)
+ results.textContent = JSON.stringify({timings}, null, 2)
}
results.dataset['status'] = 'passed'
} catch (error) {
diff --git a/test/render.bench.ts b/test/render.bench.ts
index 9445d3e..8d39d67 100644
--- a/test/render.bench.ts
+++ b/test/render.bench.ts
@@ -3,12 +3,12 @@ import {pathToFileURL} from 'node:url'
import {createElement} from 'react'
import {renderToStaticMarkup} from 'react-dom/server'
-import {bench, describe, expect} from 'vitest'
+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 = [
+const entries: Array<[string, string]> = [
['current SSR', resolve('dist/index.js')],
['current server entry', resolve('dist/index.react-server.js')],
]
@@ -22,22 +22,24 @@ if (baseline) {
for (const [name, value] of Object.entries(cases)) {
describe(name, async () => {
- let reference: string | undefined
+ const renders: Array<() => string> = []
for (const [label, path] of entries) {
const {PortableText} = (await import(
- /* @vite-ignore */ pathToFileURL(path!).href
+ /* @vite-ignore */ pathToFileURL(path).href
)) as typeof import('../src')
const render = () => renderToStaticMarkup(createElement(PortableText, {value}))
- const html = render()
- reference ??= html
- expect(html).toBe(reference)
+ renders.push(render)
bench(
- label!,
+ 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: []},
+ },
+ },
+ ],
},
})