diff --git a/packages/sanity-bridge/src/mutually-embedding-types-performance.test.ts b/packages/sanity-bridge/src/mutually-embedding-types-performance.test.ts index 05ca128a2b..46fd1d2c12 100644 --- a/packages/sanity-bridge/src/mutually-embedding-types-performance.test.ts +++ b/packages/sanity-bridge/src/mutually-embedding-types-performance.test.ts @@ -67,5 +67,16 @@ describe('mutually-embedding block objects', () => { // Generous bound - the conversion takes ~1ms when linear, while the // unmemoized walk does not finish in any practical amount of time. expect(durationMs).toBeLessThan(2_000) + + // The emitted definition must be small AS A TREE, not merely cheap to + // build. The memoized walk builds shared `OfDefinition` objects (a + // DAG) in linear time, but before root block objects referenced by + // name at every position, each type's single expansion inlined the + // expansions of every type not on its first-visit ancestor path: + // tens of megabytes serialized for these types, out-of-memory in + // real studios. Every consumer walks the tree (schema compilation, + // React, serialization), so tree size is the contract, and + // conversion time alone cannot pin it. + expect(JSON.stringify(schema).length).toBeLessThan(100_000) }, 10_000) }) diff --git a/packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.ts b/packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.ts index a488ac0ae8..26b84c20c1 100644 --- a/packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.ts +++ b/packages/sanity-bridge/src/sanity-schema-to-portable-text-schema.ts @@ -102,6 +102,17 @@ function sanitySchemaTypeToSchema( // per-branch ancestor sets below enumerate every simple path through // mutually-embedding types, which grows combinatorially. const memo = new Map() + // The canonical instances of the root-level block object types. Members + // reaching one of these at any `of` position emit a bare `{type: name}` + // reference instead of an inline expansion: the root `blockObjects` + // entry materializes the fields exactly once. Without this, each type's + // single memoized expansion inlines the expansions of every type not on + // its first-visit ancestor path, and the emitted definition, while + // cheap to build as a shared DAG, is factorially large as a tree, which + // is what every consumer (schema compilation, React, serialization) + // walks. Keyed by instance so a same-named but structurally different + // inline declaration keeps its own inline shape. + const rootBlockObjects = new Set(blockObjectTypes) return { block: { @@ -129,21 +140,31 @@ function sanitySchemaTypeToSchema( name: annotation.name, title: annotation.title, fields: annotation.fields.map((field) => - sanityFieldToSchemaField(field, new Set(), memo), + sanityFieldToSchemaField(field, new Set(), memo, rootBlockObjects), ), })), blockObjects: blockObjectTypes.map((blockObject) => ({ name: blockObject.name, title: blockObject.title, fields: blockObject.fields.map((field) => - sanityFieldToSchemaField(field, new Set([blockObject.name]), memo), + sanityFieldToSchemaField( + field, + new Set([blockObject.name]), + memo, + rootBlockObjects, + ), ), })), inlineObjects: inlineObjectTypes.map((inlineObject) => ({ name: inlineObject.name, title: inlineObject.title, fields: inlineObject.fields.map((field) => - sanityFieldToSchemaField(field, new Set([inlineObject.name]), memo), + sanityFieldToSchemaField( + field, + new Set([inlineObject.name]), + memo, + rootBlockObjects, + ), ), })), } @@ -168,6 +189,7 @@ function sanityFieldToSchemaField( }, ancestorNames: ReadonlySet, memo: Map, + rootBlockObjects: ReadonlySet, ): FieldDefinition { if (field.type.jsonType === 'array') { const ofMembers = safeGetOf(field.type) @@ -177,7 +199,12 @@ function sanityFieldToSchemaField( ...(field.type.title ? {title: field.type.title} : {}), of: ofMembers ? ofMembers.map((member) => - sanityOfMemberToOfDefinition(member, ancestorNames, memo), + sanityOfMemberToOfDefinition( + member, + ancestorNames, + memo, + rootBlockObjects, + ), ) : [], } @@ -194,6 +221,7 @@ function sanityOfMemberToOfDefinition( memberType: SchemaType, ancestorNames: ReadonlySet, memo: Map, + rootBlockObjects: ReadonlySet, ): OfDefinition { if (findBlockType(memberType)) { return {type: 'block'} @@ -208,9 +236,14 @@ function sanityOfMemberToOfDefinition( 'fields' in memberType && Array.isArray((memberType as ObjectSchemaType).fields) - if (!hasFields || ancestorNames.has(memberType.name)) { + if ( + !hasFields || + ancestorNames.has(memberType.name) || + rootBlockObjects.has(memberType) + ) { // Bare reference. The editor's resolver looks up `memberType.name` - // in `blockObjects` / `inlineObjects`. + // in the root `blockObjects`. Root block objects take this branch at + // every nested position, not just on cycles; see `rootBlockObjects`. return { type: memberType.name, ...(memberType.title ? {title: memberType.title} : {}), @@ -233,7 +266,7 @@ function sanityOfMemberToOfDefinition( name: memberType.name, ...(memberType.title ? {title: memberType.title} : {}), fields: (memberType as ObjectSchemaType).fields.map((field) => - sanityFieldToSchemaField(field, nextAncestors, memo), + sanityFieldToSchemaField(field, nextAncestors, memo, rootBlockObjects), ), } memo.set(memberType, definition)