Skip to content

Commit d0470bf

Browse files
committed
fix: reference root block objects by name at every nested position
The #2774 memo made conversion time linear by building shared `OfDefinition` objects, a DAG. But each named type's single expansion still inlined the expansions of every type not on its first-visit ancestor path, so the emitted definition was factorial in the number of mutually-embedding types AS A TREE, which is the shape every consumer walks: `compileSchema`, React reconciliation, any clone or serialization. On the mutually-embedding benchmark's own 12-type schema, conversion measured 2.5ms (test green) while the output serialized to 57 MB; at a field-reported ~25 types with several recursion edges, studios exhausted the heap on document open. The blow-up lived one package boundary downstream of every profiler frame and benchmark assertion, which is why conversion-only repros passed. `scheduleOfMember` now emits a bare `{type: name}` reference whenever the member is one of the root block-object instances, at every position, not just on ancestor cycles. The root `blockObjects` entry materializes the fields exactly once, and `getSubSchema` resolves bare references against exactly that collection (the `@portabletext/schema` 2.2.3 contract). The check is keyed by canonical instance rather than name, so an inline declaration that merely shares a name with a root type keeps its own inline shape. The oracle's vendored recursive reference gains the same emission rule, keeping the oracle pinned to what it exists to check: that the iterative mechanics match the recursive ones. The mutually-embedding benchmark now also bounds `JSON.stringify(schema).length`, the assertion whose absence let a 57 MB output ship green, and a new pipeline test runs convert-then-`compileSchema` on the reported shape (25 containers, recursive, annotation-rich) and bounds both tree size and end-to-end time.
1 parent 9407ce4 commit d0470bf

5 files changed

Lines changed: 203 additions & 5 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@portabletext/sanity-bridge': patch
3+
---
4+
5+
fix: reference root block objects by name at every nested position
6+
7+
Converting a Sanity schema whose types recurse back into the shared Portable Text array (containers, table cells, sidebars embedding `blockContent`) produced a definition that was cheap to build but factorially large as a tree: each type's single expansion inlined the expansions of every other type not on its first-visit path. Studios with wide mutually recursive schemas froze and ran out of memory on document open, not during the conversion itself but when the result was compiled, rendered, or serialized downstream.
8+
9+
Members that are root block objects now emit a bare `{type: name}` reference at every nested position, and their fields are materialized exactly once in the root `blockObjects`. The emitted definition is linear in the schema size, and `getSubSchema` (which has resolved bare references against root block objects since `@portabletext/schema` 2.2.3) resolves them as before. Inline declarations that merely share a name with a root type keep their own inline shape. Resolving these references requires `@portabletext/schema` 2.2.3 or later in the tree: guaranteed from `@portabletext/editor` 7.10.7, and satisfied by any fresh install of earlier 7.x releases (their `^2.2.2` range accepts it), so only lockfiles that pin `@portabletext/schema` at 2.2.2 or older need a refresh.

packages/sanity-bridge/src/mutually-embedding-types-performance.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ describe('mutually-embedding block objects', () => {
6767
// Generous bound - the conversion takes ~1ms when linear, while the
6868
// unmemoized walk does not finish in any practical amount of time.
6969
expect(durationMs).toBeLessThan(2_000)
70+
71+
// The emitted definition must be small AS A TREE, not merely cheap to
72+
// build. The memoized walk builds shared `OfDefinition` objects (a
73+
// DAG) in linear time, but before root block objects referenced by
74+
// name at every position, each type's single expansion inlined the
75+
// expansions of every type not on its first-visit ancestor path:
76+
// 57 MB serialized for these 12 types, out-of-memory in real studios
77+
// with ~25. Every consumer walks the tree (`compileSchema`, React,
78+
// serialization), so tree size is the contract, and conversion time
79+
// alone cannot pin it.
80+
expect(JSON.stringify(schema).length).toBeLessThan(100_000)
7081
}, 10_000)
7182

7283
test('a marketing-site schema converts to a PTE schema that container resolution can walk at every depth', () => {
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {compileSchema} from '@portabletext/schema'
2+
import {Schema as SanitySchema} from '@sanity/schema'
3+
import {expect, test} from 'vitest'
4+
import {sanitySchemaToPortableTextSchema} from './sanity-schema-to-portable-text-schema'
5+
6+
/**
7+
* Regression test for the field-reported Studio freeze/OOM on doc-open
8+
* with a large mutually recursive block schema: ~25 members where several
9+
* container types recurse back into the shared Portable Text array.
10+
*
11+
* The conversion builds shared `OfDefinition` objects, so it is fast
12+
* regardless of the emitted shape; the failure lived in the output's
13+
* TREE size, which everything downstream walks. This test runs the
14+
* pipeline a studio runs, convert from the document field's type, then
15+
* `compileSchema`, and bounds both the tree size and the end-to-end
16+
* time. Before root block objects referenced by name at every position,
17+
* this shape exhausted the heap.
18+
*/
19+
test('a wide mutually recursive schema converts and compiles small and fast', () => {
20+
const containerCount = 25
21+
const types: Array<Record<string, unknown>> = []
22+
const memberRefs: Array<Record<string, unknown>> = [
23+
{
24+
type: 'block',
25+
marks: {
26+
annotations: Array.from({length: 8}, (_, index) => ({
27+
type: 'object',
28+
name: `annotation${index}`,
29+
fields: [{name: 'href', type: 'string'}],
30+
})),
31+
},
32+
},
33+
]
34+
for (let index = 0; index < containerCount; index++) {
35+
types.push({
36+
type: 'object',
37+
name: `container${index}`,
38+
fields: [
39+
{name: 'label', type: 'string'},
40+
{name: 'content', type: 'blockContent'},
41+
],
42+
})
43+
memberRefs.push({type: `container${index}`})
44+
}
45+
types.push({type: 'array', name: 'blockContent', of: memberRefs})
46+
types.push({
47+
type: 'document',
48+
name: 'article',
49+
fields: [{name: 'body', type: 'blockContent'}],
50+
})
51+
52+
const sanitySchema = SanitySchema.compile({name: 'test', types})
53+
const article = sanitySchema.get('article') as unknown as {
54+
fields: Array<{name: string; type: unknown}>
55+
}
56+
const bodyType = article.fields.find((field) => field.name === 'body')!.type
57+
58+
const startedAt = performance.now()
59+
const definition = sanitySchemaToPortableTextSchema(bodyType as never)
60+
const compiled = compileSchema(definition as never)
61+
const durationMs = performance.now() - startedAt
62+
63+
expect(definition.blockObjects).toHaveLength(containerCount)
64+
expect(compiled.blockObjects).toHaveLength(containerCount)
65+
expect(JSON.stringify(definition).length).toBeLessThan(200_000)
66+
expect(durationMs).toBeLessThan(2_000)
67+
})
68+
69+
/**
70+
* Root block objects are referenced by name at every nested position, but
71+
* the check is keyed by the compiled type instance, not the name: an
72+
* inline declaration that merely shares a root type's name is a different
73+
* instance and must keep its own inline shape. A name-keyed check would
74+
* stub it, and resolution would silently hand back the root type's
75+
* fields.
76+
*/
77+
test('an inline declaration sharing a root type name keeps its own shape', () => {
78+
const sanitySchema = SanitySchema.compile({
79+
name: 'test',
80+
types: [
81+
{
82+
type: 'array',
83+
name: 'blockContent',
84+
of: [{type: 'block'}, {type: 'card'}, {type: 'holder'}],
85+
},
86+
{
87+
type: 'object',
88+
name: 'card',
89+
fields: [{name: 'rootField', type: 'string'}],
90+
},
91+
{
92+
type: 'object',
93+
name: 'holder',
94+
fields: [
95+
{
96+
name: 'items',
97+
type: 'array',
98+
of: [
99+
{
100+
type: 'object',
101+
name: 'card',
102+
fields: [{name: 'nestedField', type: 'number'}],
103+
},
104+
],
105+
},
106+
],
107+
},
108+
],
109+
})
110+
111+
const definition = sanitySchemaToPortableTextSchema(
112+
sanitySchema.get('blockContent') as never,
113+
)
114+
115+
const holder = definition.blockObjects.find(
116+
(blockObject) => blockObject.name === 'holder',
117+
)
118+
expect(holder).toEqual({
119+
name: 'holder',
120+
title: 'Holder',
121+
fields: [
122+
{
123+
name: 'items',
124+
type: 'array',
125+
title: 'Items',
126+
of: [
127+
{
128+
type: 'object',
129+
name: 'card',
130+
title: 'Card',
131+
fields: [
132+
{name: 'nestedField', type: 'number', title: 'Nested Field'},
133+
],
134+
},
135+
],
136+
},
137+
],
138+
})
139+
140+
const card = definition.blockObjects.find(
141+
(blockObject) => blockObject.name === 'card',
142+
)
143+
expect(card).toEqual({
144+
name: 'card',
145+
title: 'Card',
146+
fields: [{name: 'rootField', type: 'string', title: 'Root Field'}],
147+
})
148+
})

packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.oracle.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ describe('conversion oracle', () => {
199199
* </EditorProvider>
200200
* ```
201201
*/
202+
let referenceRootBlockObjects = new Set<SchemaType>()
203+
202204
function referenceSanitySchemaToPortableTextSchema(
203205
sanitySchema: ArraySchemaType<unknown> | ArrayDefinition,
204206
): Schema {
@@ -265,6 +267,8 @@ function sanitySchemaTypeToSchema(
265267
// mutually-embedding types, which grows combinatorially.
266268
const memo = new Map<SchemaType, OfDefinition>()
267269

270+
referenceRootBlockObjects = new Set<SchemaType>(blockObjectTypes)
271+
268272
return {
269273
block: {
270274
name: blockType.name,
@@ -468,9 +472,13 @@ function sanityOfMemberToOfDefinition(
468472
'fields' in memberType &&
469473
Array.isArray((memberType as ObjectSchemaType).fields)
470474

471-
if (!hasFields || ancestorNames.has(memberType.name)) {
472-
// Bare reference. The editor's resolver looks up `memberType.name`
473-
// in `blockObjects` / `inlineObjects`.
475+
if (
476+
!hasFields ||
477+
ancestorNames.has(memberType.name) ||
478+
referenceRootBlockObjects.has(memberType)
479+
) {
480+
// Bare reference, mirroring the production emission rule: root block
481+
// objects reference by name at every nested position.
474482
return {
475483
type: memberType.name,
476484
...(memberType.title ? {title: memberType.title} : {}),

packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ function sanitySchemaTypeToSchema(
122122
distinctAncestorCount: 0,
123123
memo: new Map<SchemaType, OfDefinition>(),
124124
inFlight: new Map<unknown, Array<number>>(),
125+
rootBlockObjects: new Set<SchemaType>(blockObjectTypes),
125126
}
126127
const pendingWork: Array<Work> = []
127128

@@ -227,6 +228,21 @@ type Conversion = {
227228
* overflowed the stack.
228229
*/
229230
inFlight: Map<unknown, Array<number>>
231+
/**
232+
* The canonical instances of the root-level block object types. Members
233+
* reaching one of these at any `of` position emit a bare
234+
* `{type: name}` reference instead of an inline expansion: the root
235+
* `blockObjects` entry materializes the fields exactly once, and
236+
* `getSubSchema` resolves bare references against that collection.
237+
* Without this, each type's single memoized expansion inlines the
238+
* expansions of every type not on its first-visit ancestor path, and
239+
* the emitted definition, while cheap to build as a shared DAG, is
240+
* factorially large as a tree, which is what every consumer
241+
* (`compileSchema`, React, serialization) walks. Keyed by instance so
242+
* a same-named but structurally different inline declaration keeps its
243+
* own inline shape.
244+
*/
245+
rootBlockObjects: Set<SchemaType>
230246
}
231247

232248
function pushAncestor(conversion: Conversion, name: string): void {
@@ -405,9 +421,15 @@ function scheduleOfMember(
405421
'fields' in memberType &&
406422
Array.isArray((memberType as ObjectSchemaType).fields)
407423

408-
if (!hasFields || hasAncestor(conversion, memberType.name)) {
424+
if (
425+
!hasFields ||
426+
hasAncestor(conversion, memberType.name) ||
427+
conversion.rootBlockObjects.has(memberType)
428+
) {
409429
// Bare reference. The editor's resolver looks up `memberType.name`
410-
// in `blockObjects` / `inlineObjects`.
430+
// in the root `blockObjects`. Root block objects take this branch at
431+
// every nested position, not just on cycles; see
432+
// `Conversion.rootBlockObjects`.
411433
target[index] = {
412434
type: memberType.name,
413435
...(memberType.title ? {title: memberType.title} : {}),

0 commit comments

Comments
 (0)