Skip to content

Commit 4afde2a

Browse files
committed
feat(schema): de-dupe re-used fields in the descriptor
1 parent ab15495 commit 4afde2a

File tree

9 files changed

+284
-120
lines changed

9 files changed

+284
-120
lines changed

dev/test-studio/schema/standard/portableText/manyEditors.js

Lines changed: 76 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,81 @@ const myStringType = defineField({
4040
fields: [{type: 'string', name: 'mystring', validation: (Rule) => Rule.required()}],
4141
})
4242

43+
const bodyMembers = [
44+
defineArrayMember({
45+
type: 'block',
46+
marks: {
47+
annotations: [linkType, myStringType],
48+
},
49+
of: [
50+
{type: 'image', name: 'image'},
51+
myStringType,
52+
{
53+
type: 'reference',
54+
name: 'strongAuthorRef',
55+
title: 'A strong author ref',
56+
to: {type: 'author'},
57+
},
58+
],
59+
validation: (Rule) =>
60+
Rule.custom((block) => {
61+
const text = extractTextFromBlocks([block])
62+
return text.length === 1 ? 'Please write a longer paragraph.' : true
63+
}),
64+
options: {
65+
spellCheck: true,
66+
},
67+
}),
68+
{
69+
type: 'image',
70+
name: 'image',
71+
options: {
72+
modal: {
73+
// The default `type` of object blocks is 'dialog'
74+
// type: 'dialog',
75+
// The default `width` of object blocks is 'medium'
76+
// width: 'small',
77+
},
78+
},
79+
},
80+
{
81+
type: 'object',
82+
name: 'callout',
83+
title: 'Callout',
84+
components: {
85+
preview: CalloutPreview,
86+
},
87+
fields: [
88+
{
89+
type: 'string',
90+
name: 'title',
91+
title: 'Title',
92+
},
93+
{
94+
type: 'string',
95+
name: 'tone',
96+
title: 'Tone',
97+
options: {
98+
list: [
99+
{value: 'default', title: 'Default'},
100+
{value: 'primary', title: 'Primary'},
101+
{value: 'positive', title: 'Positive'},
102+
{value: 'caution', title: 'Caution'},
103+
{value: 'critical', title: 'Critical'},
104+
],
105+
},
106+
},
107+
],
108+
preview: {
109+
select: {
110+
title: 'title',
111+
tone: 'tone',
112+
},
113+
},
114+
},
115+
myStringType,
116+
]
117+
43118
const createBodyField = (title, name, size = 1) => {
44119
const fields = []
45120
for (let i = 1; i <= size; i++) {
@@ -48,80 +123,7 @@ const createBodyField = (title, name, size = 1) => {
48123
name: `${name}${i}`,
49124
title: title,
50125
type: 'array',
51-
of: [
52-
defineArrayMember({
53-
type: 'block',
54-
marks: {
55-
annotations: [linkType, myStringType],
56-
},
57-
of: [
58-
{type: 'image', name: 'image'},
59-
myStringType,
60-
{
61-
type: 'reference',
62-
name: 'strongAuthorRef',
63-
title: 'A strong author ref',
64-
to: {type: 'author'},
65-
},
66-
],
67-
validation: (Rule) =>
68-
Rule.custom((block) => {
69-
const text = extractTextFromBlocks([block])
70-
return text.length === 1 ? 'Please write a longer paragraph.' : true
71-
}),
72-
options: {
73-
spellCheck: true,
74-
},
75-
}),
76-
{
77-
type: 'image',
78-
name: 'image',
79-
options: {
80-
modal: {
81-
// The default `type` of object blocks is 'dialog'
82-
// type: 'dialog',
83-
// The default `width` of object blocks is 'medium'
84-
// width: 'small',
85-
},
86-
},
87-
},
88-
{
89-
type: 'object',
90-
name: 'callout',
91-
title: 'Callout',
92-
components: {
93-
preview: CalloutPreview,
94-
},
95-
fields: [
96-
{
97-
type: 'string',
98-
name: 'title',
99-
title: 'Title',
100-
},
101-
{
102-
type: 'string',
103-
name: 'tone',
104-
title: 'Tone',
105-
options: {
106-
list: [
107-
{value: 'default', title: 'Default'},
108-
{value: 'primary', title: 'Primary'},
109-
{value: 'positive', title: 'Positive'},
110-
{value: 'caution', title: 'Caution'},
111-
{value: 'critical', title: 'Critical'},
112-
],
113-
},
114-
},
115-
],
116-
preview: {
117-
select: {
118-
title: 'title',
119-
tone: 'tone',
120-
},
121-
},
122-
},
123-
myStringType,
124-
],
126+
of: bodyMembers,
125127
}),
126128
)
127129
}

packages/@sanity/schema/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"watch": "pkg-utils watch"
5858
},
5959
"dependencies": {
60-
"@sanity/descriptors": "^1.1.1",
60+
"@sanity/descriptors": "^1.2.0",
6161
"@sanity/generate-help-url": "^3.0.0",
6262
"@sanity/types": "workspace:*",
6363
"arrify": "^2.0.1",

packages/@sanity/schema/src/descriptors/convert.ts

Lines changed: 103 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {isEqual, isObject} from 'lodash'
1919
import {Rule} from '../legacy/Rule'
2020
import {OWN_PROPS_NAME} from '../legacy/types/constants'
2121
import {
22+
type ArrayElement,
2223
type ArrayTypeDef,
2324
type CommonTypeDef,
2425
type CoreTypeDef,
@@ -62,10 +63,42 @@ export class DescriptorConverter {
6263
let value = this.cache.get(schema)
6364
if (value) return value
6465

65-
const builder = new SetBuilder()
66-
for (const name of schema.getLocalTypeNames()) {
67-
const typeDef = convertTypeDef(schema.get(name)!, {})
68-
builder.addObject('sanity.schema.namedType', {name, typeDef})
66+
const options: Options = {
67+
fields: new Map(),
68+
duplicateFields: new Map(),
69+
arrayElements: new Map(),
70+
duplicateArrayElements: new Map(),
71+
}
72+
73+
const namedTypes = schema.getLocalTypeNames().map((name) => {
74+
const typeDef = convertTypeDef(schema.get(name)!, name, options)
75+
return {name, typeDef}
76+
})
77+
78+
const rewriteMap = new Map<EncodableObject, EncodableObject>()
79+
80+
// First we populate the rewrite map with the duplications:
81+
for (const [fieldDef, key] of options.duplicateFields.entries()) {
82+
rewriteMap.set(fieldDef, {__type: 'hoisted', key})
83+
}
84+
85+
for (const [arrayElem, key] of options.duplicateArrayElements.entries()) {
86+
rewriteMap.set(arrayElem, {__type: 'hoisted', key})
87+
}
88+
89+
const builder = new SetBuilder({rewriteMap})
90+
91+
// Now we can build the de-duplicated objects:
92+
for (const [fieldDef, key] of options.duplicateFields.entries()) {
93+
builder.addObject('sanity.schema.hoisted', {key, value: {...fieldDef}})
94+
}
95+
96+
for (const [arrayElem, key] of options.duplicateArrayElements.entries()) {
97+
builder.addObject('sanity.schema.hoisted', {key, value: {...arrayElem}})
98+
}
99+
100+
for (const namedType of namedTypes) {
101+
builder.addObject('sanity.schema.namedType', namedType)
69102
}
70103

71104
if (schema.parent) {
@@ -78,21 +111,35 @@ export class DescriptorConverter {
78111
}
79112
}
80113

81-
function convertCommonTypeDef(schemaType: SchemaType, opts: Options): CommonTypeDef {
114+
function convertCommonTypeDef(schemaType: SchemaType, path: string, opts: Options): CommonTypeDef {
82115
// Note that OWN_PROPS_NAME is only set on subtypes, not the core types.
83116
// We might consider setting OWN_PROPS_NAME on _all_ types to avoid this branch.
84117
const ownProps = OWN_PROPS_NAME in schemaType ? (schemaType as any)[OWN_PROPS_NAME] : schemaType
85118

86119
let fields: ObjectField[] | undefined
87120
if (Array.isArray(ownProps.fields)) {
88-
fields = (ownProps.fields as ObjectSchemaType['fields']).map(
89-
({name, group, fieldset, type}) => ({
121+
fields = (ownProps.fields as ObjectSchemaType['fields']).map((field) => {
122+
const fieldPath = `${path}.${field.name}`
123+
const value = opts.fields.get(field)
124+
if (value) {
125+
// We've seen it before. Mark it as duplicate.
126+
const otherPath = opts.duplicateFields.get(value)
127+
// We always keep the _smallest_ path around.
128+
if (!otherPath || isLessCanonicalName(fieldPath, otherPath))
129+
opts.duplicateFields.set(value, fieldPath)
130+
return value
131+
}
132+
133+
const {name, group, fieldset, type} = field
134+
const converted: ObjectField = {
90135
name,
91-
typeDef: convertTypeDef(type, opts),
136+
typeDef: convertTypeDef(type, fieldPath, opts),
92137
groups: arrayifyString(group),
93138
fieldset,
94-
}),
95-
)
139+
}
140+
opts.fields.set(field, converted)
141+
return converted
142+
})
96143
}
97144

98145
let fieldsets: ObjectFieldset[] | undefined
@@ -157,15 +204,28 @@ function convertCommonTypeDef(schemaType: SchemaType, opts: Options): CommonType
157204
}
158205
}
159206

160-
/**
161-
* Options used when converting the schema.
162-
*
163-
* We know we need this in order to handle validations.
164-
**/
165-
export type Options = Record<never, never>
207+
type Options = {
208+
/** Mapping of fields to descriptor value. Used for de-duping. */
209+
fields: Map<object, ObjectField>
210+
211+
/**
212+
* Once a field has been seen twice it's inserted into this map.
213+
* The value here is the canonical name.
214+
**/
215+
duplicateFields: Map<ObjectField, string>
216+
217+
/** Mapping of array element to descriptor value. Used for de-duping. */
218+
arrayElements: Map<object, ArrayElement>
219+
220+
/**
221+
* Once an array element has been seen twice it's inserted into this map.
222+
* The value here is the canonical name.
223+
**/
224+
duplicateArrayElements: Map<ArrayElement, string>
225+
}
166226

167-
export function convertTypeDef(schemaType: SchemaType, opts: Options): TypeDef {
168-
const common = convertCommonTypeDef(schemaType, opts)
227+
export function convertTypeDef(schemaType: SchemaType, path: string, opts: Options): TypeDef {
228+
const common = convertCommonTypeDef(schemaType, path, opts)
169229

170230
if (!schemaType.type) {
171231
return {
@@ -183,10 +243,24 @@ export function convertTypeDef(schemaType: SchemaType, opts: Options): TypeDef {
183243
case 'array': {
184244
return {
185245
extends: 'array',
186-
of: (schemaType as ArraySchemaType).of.map((ofType) => ({
187-
name: ofType.name,
188-
typeDef: convertTypeDef(ofType, opts),
189-
})),
246+
of: (schemaType as ArraySchemaType).of.map((ofType, idx) => {
247+
const itemPath = `${path}.${ofType.name}`
248+
const value = opts.arrayElements.get(ofType)
249+
if (value) {
250+
// We've seen it before. Mark it as duplicate.
251+
const otherPath = opts.duplicateArrayElements.get(value)
252+
// We always keep the _smallest_ path around.
253+
if (!otherPath || isLessCanonicalName(itemPath, otherPath))
254+
opts.duplicateArrayElements.set(value, itemPath)
255+
return value
256+
}
257+
const converted: ArrayElement = {
258+
name: ofType.name,
259+
typeDef: convertTypeDef(ofType, `${path}.${ofType.name}`, opts),
260+
}
261+
opts.arrayElements.set(ofType, converted)
262+
return converted
263+
}),
190264
...common,
191265
} satisfies ArrayTypeDef
192266
}
@@ -727,3 +801,10 @@ function maybeOrderingBy(val: unknown): ObjectOrderingBy | undefined {
727801

728802
return {field, direction}
729803
}
804+
805+
/**
806+
* Checks if `a` is smaller than `b` for determining a canonical name.
807+
*/
808+
function isLessCanonicalName(a: string, b: string): boolean {
809+
return a.length < b.length || (a.length === b.length && a < b)
810+
}

packages/@sanity/schema/src/descriptors/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface CommonTypeDef extends EncodableObject {
1818
title?: string
1919
description?: string | JSXMarker
2020

21-
fields?: ObjectField[]
21+
fields?: Array<ObjectField | HoistedMarker>
2222
groups?: ObjectGroup[]
2323
fieldsets?: ObjectFieldset[]
2424

@@ -68,6 +68,9 @@ export type UnknownMarker = {__type: 'unknown'}
6868
/** Denotes a number which we've turned into a string for serialization. */
6969
export type NumberMarker = {__type: 'number'; value: string}
7070

71+
/** Denotes a value which has been hoisted out into its own descriptor. */
72+
export type HoistedMarker = {__type: 'hoisted'; key: string}
73+
7174
/**
7275
* Denotes an object. This is only used when we see an object with "__type" and
7376
* want to avoid it being interpreted as a marker.
@@ -91,7 +94,7 @@ export interface SubtypeDef extends CommonTypeDef {
9194

9295
export interface ArrayTypeDef extends SubtypeDef {
9396
extends: 'array'
94-
of: ArrayElement[]
97+
of: Array<ArrayElement | HoistedMarker>
9598
}
9699

97100
export type ArrayElement = {

packages/sanity/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@
280280
"@repo/test-config": "workspace:*",
281281
"@repo/tsconfig": "workspace:*",
282282
"@sanity/codegen": "workspace:*",
283-
"@sanity/descriptors": "^1.1.1",
283+
"@sanity/descriptors": "^1.2.0",
284284
"@sanity/eslint-config-i18n": "catalog:",
285285
"@sanity/generate-help-url": "^3.0.0",
286286
"@sanity/pkg-utils": "catalog:",

packages/sanity/src/_internal/cli/actions/schema/metafile.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,10 @@ export function generateMetafile(schema: SeralizedSchemaDebug): Metafile {
6868
processType(fakePath, entry)
6969
}
7070

71+
for (const [name, entry] of Object.entries(schema.hoisted)) {
72+
const fakePath = `hoisted/${name}`
73+
processType(fakePath, entry)
74+
}
75+
7176
return {outputs: {root: output}, inputs}
7277
}

0 commit comments

Comments
 (0)