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
+ }
+ 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/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('')
+ 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(
+ '',
+ )
+ 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('')
+})
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.
+ Run benchmarks
+ 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: []},
+ },
+ },
+ ],
},
})