perf: remove duplicative deep loops during field sanitization (#12402)

Optimizes the field sanitization process by removing duplicative deep
loops over the config. We were previously iterating over all fields of
each collection potentially multiple times in order validate field
configs, check reserved field names, etc. Now, we perform all necessary
sanitization within a single loop.
This commit is contained in:
Jacob Fletcher
2025-05-14 15:25:44 -04:00
committed by GitHub
parent 9779cf7f7d
commit 93d79b9c62
8 changed files with 187 additions and 179 deletions

View File

@@ -1,151 +0,0 @@
// @ts-strict-ignore
import type { Field } from '../../fields/config/types.js'
import type { CollectionConfig } from '../../index.js'
import { ReservedFieldName } from '../../errors/ReservedFieldName.js'
import { fieldAffectsData } from '../../fields/config/types.js'
// Note for future reference: We've slimmed down the reserved field names but left them in here for reference in case it's needed in the future.
/**
* Reserved field names for collections with auth config enabled
*/
const reservedBaseAuthFieldNames = [
/* 'email',
'resetPasswordToken',
'resetPasswordExpiration', */
'salt',
'hash',
]
/**
* Reserved field names for auth collections with verify: true
*/
const reservedVerifyFieldNames = [
/* '_verified', '_verificationToken' */
]
/**
* Reserved field names for auth collections with useApiKey: true
*/
const reservedAPIKeyFieldNames = [
/* 'enableAPIKey', 'apiKeyIndex', 'apiKey' */
]
/**
* Reserved field names for collections with upload config enabled
*/
const reservedBaseUploadFieldNames = [
'file',
/* 'mimeType',
'thumbnailURL',
'width',
'height',
'filesize',
'filename',
'url',
'focalX',
'focalY',
'sizes', */
]
/**
* Reserved field names for collections with versions enabled
*/
const reservedVersionsFieldNames = [
/* '__v', '_status' */
]
/**
* Sanitize fields for collections with auth config enabled.
*
* Should run on top level fields only.
*/
export const sanitizeAuthFields = (fields: Field[], config: CollectionConfig) => {
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
if (fieldAffectsData(field) && field.name) {
if (config.auth && typeof config.auth === 'object' && !config.auth.disableLocalStrategy) {
const auth = config.auth
if (reservedBaseAuthFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
if (auth.verify) {
if (reservedAPIKeyFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
/* if (auth.maxLoginAttempts) {
if (field.name === 'loginAttempts' || field.name === 'lockUntil') {
throw new ReservedFieldName(field, field.name)
}
} */
/* if (auth.loginWithUsername) {
if (field.name === 'username') {
throw new ReservedFieldName(field, field.name)
}
} */
if (auth.verify) {
if (reservedVerifyFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
}
}
// Handle tabs without a name
if (field.type === 'tabs') {
for (let j = 0; j < field.tabs.length; j++) {
const tab = field.tabs[j]
if (!('name' in tab)) {
sanitizeAuthFields(tab.fields, config)
}
}
}
// Handle presentational fields like rows and collapsibles
if (!fieldAffectsData(field) && 'fields' in field && field.fields) {
sanitizeAuthFields(field.fields, config)
}
}
}
/**
* Sanitize fields for collections with upload config enabled.
*
* Should run on top level fields only.
*/
export const sanitizeUploadFields = (fields: Field[], config: CollectionConfig) => {
if (config.upload && typeof config.upload === 'object') {
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
if (fieldAffectsData(field) && field.name) {
if (reservedBaseUploadFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
// Handle tabs without a name
if (field.type === 'tabs') {
for (let j = 0; j < field.tabs.length; j++) {
const tab = field.tabs[j]
if (!('name' in tab)) {
sanitizeUploadFields(tab.fields, config)
}
}
}
// Handle presentational fields like rows and collapsibles
if (!fieldAffectsData(field) && 'fields' in field && field.fields) {
sanitizeUploadFields(field.fields, config)
}
}
}
}

View File

@@ -26,7 +26,6 @@ import {
addDefaultsToCollectionConfig,
addDefaultsToLoginWithUsernameConfig,
} from './defaults.js'
import { sanitizeAuthFields, sanitizeUploadFields } from './reservedFieldNames.js'
import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js'
import { validateUseAsTitle } from './useAsTitle.js'
@@ -43,7 +42,9 @@ export const sanitizeCollection = async (
if (collection._sanitized) {
return collection as SanitizedCollectionConfig
}
collection._sanitized = true
// /////////////////////////////////
// Make copy of collection config
// /////////////////////////////////
@@ -57,7 +58,9 @@ export const sanitizeCollection = async (
const validRelationships = _validRelationships ?? config.collections.map((c) => c.slug) ?? []
const joins: SanitizedJoins = {}
const polymorphicJoins: SanitizedJoin[] = []
sanitized.fields = await sanitizeFields({
collectionConfig: sanitized,
config,
@@ -96,17 +99,21 @@ export const sanitizeCollection = async (
// add default timestamps fields only as needed
let hasUpdatedAt: boolean | null = null
let hasCreatedAt: boolean | null = null
sanitized.fields.some((field) => {
if (fieldAffectsData(field)) {
if (field.name === 'updatedAt') {
hasUpdatedAt = true
}
if (field.name === 'createdAt') {
hasCreatedAt = true
}
}
return hasCreatedAt && hasUpdatedAt
})
if (!hasUpdatedAt) {
sanitized.fields.push({
name: 'updatedAt',
@@ -119,6 +126,7 @@ export const sanitizeCollection = async (
label: ({ t }) => t('general:updatedAt'),
})
}
if (!hasCreatedAt) {
sanitized.fields.push({
name: 'createdAt',
@@ -175,9 +183,6 @@ export const sanitizeCollection = async (
sanitized.upload = {}
}
// sanitize fields for reserved names
sanitizeUploadFields(sanitized.fields, sanitized)
sanitized.upload.cacheTags = sanitized.upload?.cacheTags ?? true
sanitized.upload.bulkUpload = sanitized.upload?.bulkUpload ?? true
sanitized.upload.staticDir = sanitized.upload.staticDir || sanitized.slug
@@ -195,9 +200,6 @@ export const sanitizeCollection = async (
}
if (sanitized.auth) {
// sanitize fields for reserved names
sanitizeAuthFields(sanitized.fields, sanitized)
sanitized.auth = addDefaultsToAuthConfig(
typeof sanitized.auth === 'boolean' ? {} : sanitized.auth,
)

View File

@@ -1,7 +1,7 @@
import type { CollectionConfig } from '../../index.js'
import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js'
import { fieldAffectsData, fieldIsVirtual } from '../../fields/config/types.js'
import { fieldAffectsData } from '../../fields/config/types.js'
import flattenFields from '../../utilities/flattenTopLevelFields.js'
/**

View File

@@ -58,6 +58,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
// add default user collection if none provided
if (!sanitizedConfig?.admin?.user) {
const firstCollectionWithAuth = sanitizedConfig.collections.find(({ auth }) => Boolean(auth))
if (firstCollectionWithAuth) {
sanitizedConfig.admin.user = firstCollectionWithAuth.slug
} else {
@@ -69,6 +70,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
const userCollection = sanitizedConfig.collections.find(
({ slug }) => slug === sanitizedConfig.admin.user,
)
if (!userCollection || !userCollection.auth) {
throw new InvalidConfiguration(
`${sanitizedConfig.admin.user} is not a valid admin user collection`,

View File

@@ -2,7 +2,7 @@ import type { Config } from '../../config/types.js'
import type { CollectionConfig, Field } from '../../index.js'
import { ReservedFieldName } from '../../errors/index.js'
import { sanitizeCollection } from './sanitize.js'
import { sanitizeCollection } from '../../collections/config/sanitize.js'
describe('reservedFieldNames - collections -', () => {
const config = {
@@ -25,6 +25,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
@@ -53,6 +54,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
@@ -93,6 +95,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
@@ -121,6 +124,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
@@ -149,6 +153,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error

View File

@@ -0,0 +1,48 @@
/**
* Reserved field names for collections with auth config enabled
*/
export const reservedBaseAuthFieldNames = [
/* 'email',
'resetPasswordToken',
'resetPasswordExpiration', */
'salt',
'hash',
]
/**
* Reserved field names for auth collections with verify: true
*/
export const reservedVerifyFieldNames = [
/* '_verified', '_verificationToken' */
]
/**
* Reserved field names for auth collections with useApiKey: true
*/
export const reservedAPIKeyFieldNames = [
/* 'enableAPIKey', 'apiKeyIndex', 'apiKey' */
]
/**
* Reserved field names for collections with upload config enabled
*/
export const reservedBaseUploadFieldNames = [
'file',
/* 'mimeType',
'thumbnailURL',
'width',
'height',
'filesize',
'filename',
'url',
'focalX',
'focalY',
'sizes', */
]
/**
* Reserved field names for collections with versions enabled
*/
export const reservedVersionsFieldNames = [
/* '__v', '_status' */
]

View File

@@ -11,9 +11,12 @@ import type {
import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors/index.js'
import { sanitizeFields } from './sanitize.js'
import { CollectionConfig } from '../../index.js'
describe('sanitizeFields', () => {
const config = {} as Config
const collectionConfig = {} as CollectionConfig
it('should throw on missing type field', async () => {
const fields: Field[] = [
// @ts-expect-error
@@ -22,14 +25,17 @@ describe('sanitizeFields', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
}).rejects.toThrow(MissingFieldType)
})
it('should throw on invalid field name', async () => {
const fields: Field[] = [
{
@@ -38,9 +44,11 @@ describe('sanitizeFields', () => {
label: 'some.collection',
},
]
await expect(async () => {
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
@@ -55,17 +63,21 @@ describe('sanitizeFields', () => {
type: 'text',
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual('Some Field')
expect(sanitizedField.type).toStrictEqual('text')
})
it('should allow auto-label override', async () => {
const fields: Field[] = [
{
@@ -74,13 +86,16 @@ describe('sanitizeFields', () => {
label: 'Do not label',
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual('Do not label')
expect(sanitizedField.type).toStrictEqual('text')
@@ -95,13 +110,16 @@ describe('sanitizeFields', () => {
label: false,
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('text')
@@ -119,18 +137,22 @@ describe('sanitizeFields', () => {
],
label: false,
}
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields: [arrayField],
validRelationships: [],
})
)[0] as ArrayField
expect(sanitizedField.name).toStrictEqual('items')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('array')
expect(sanitizedField.labels).toBeUndefined()
})
it('should allow label opt-out for blocks', async () => {
const fields: Field[] = [
{
@@ -150,13 +172,16 @@ describe('sanitizeFields', () => {
label: false,
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as BlocksField
expect(sanitizedField.name).toStrictEqual('noLabelBlock')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('blocks')
@@ -177,13 +202,16 @@ describe('sanitizeFields', () => {
],
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as ArrayField
expect(sanitizedField.name).toStrictEqual('items')
expect(sanitizedField.label).toStrictEqual('Items')
expect(sanitizedField.type).toStrictEqual('array')
@@ -203,13 +231,16 @@ describe('sanitizeFields', () => {
],
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as BlocksField
expect(sanitizedField.name).toStrictEqual('specialBlock')
expect(sanitizedField.label).toStrictEqual('Special Block')
expect(sanitizedField.type).toStrictEqual('blocks')
@@ -217,6 +248,7 @@ describe('sanitizeFields', () => {
plural: 'Special Blocks',
singular: 'Special Block',
})
expect((sanitizedField.blocks[0].fields[0] as NumberField).label).toStrictEqual('Test Number')
})
})
@@ -232,8 +264,9 @@ describe('sanitizeFields', () => {
relationTo: 'some-collection',
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).not.toThrow()
})
@@ -247,8 +280,9 @@ describe('sanitizeFields', () => {
relationTo: ['some-collection', 'another-collection'],
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).not.toThrow()
})
@@ -265,6 +299,7 @@ describe('sanitizeFields', () => {
},
],
}
const fields: Field[] = [
{
name: 'layout',
@@ -273,8 +308,9 @@ describe('sanitizeFields', () => {
label: 'Layout Blocks',
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).not.toThrow()
})
@@ -288,8 +324,9 @@ describe('sanitizeFields', () => {
relationTo: 'not-valid',
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
@@ -303,8 +340,9 @@ describe('sanitizeFields', () => {
relationTo: ['some-collection', 'not-valid'],
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
@@ -321,6 +359,7 @@ describe('sanitizeFields', () => {
},
],
}
const fields: Field[] = [
{
name: 'layout',
@@ -329,8 +368,9 @@ describe('sanitizeFields', () => {
label: 'Layout Blocks',
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
@@ -346,19 +386,23 @@ describe('sanitizeFields', () => {
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as CheckboxField
expect(sanitizedField.defaultValue).toStrictEqual(false)
})
it('should return empty field array if no fields', async () => {
const sanitizedFields = await sanitizeFields({
config,
collectionConfig,
fields: [],
validRelationships: [],
})
expect(sanitizedFields).toStrictEqual([])
})
})
@@ -385,9 +429,11 @@ describe('sanitizeFields', () => {
label: false,
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
@@ -416,9 +462,11 @@ describe('sanitizeFields', () => {
label: false,
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})

View File

@@ -17,6 +17,7 @@ import {
MissingEditorProp,
MissingFieldType,
} from '../../errors/index.js'
import { ReservedFieldName } from '../../errors/ReservedFieldName.js'
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
@@ -24,14 +25,24 @@ import { baseTimezoneField } from '../baseFields/timezone/baseField.js'
import { defaultTimezones } from '../baseFields/timezone/defaultTimezones.js'
import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js'
import { validations } from '../validations.js'
import {
reservedAPIKeyFieldNames,
reservedBaseAuthFieldNames,
reservedBaseUploadFieldNames,
reservedVerifyFieldNames,
} from './reservedFieldNames.js'
import { sanitizeJoinField } from './sanitizeJoinField.js'
import { fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js'
import { fieldAffectsData as _fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js'
type Args = {
collectionConfig?: CollectionConfig
config: Config
existingFieldNames?: Set<string>
fields: Field[]
/**
* Used to prevent unnecessary sanitization of fields that are not top-level.
*/
isTopLevelField?: boolean
joinPath?: string
/**
* When not passed in, assume that join are not supported (globals, arrays, blocks)
@@ -39,7 +50,6 @@ type Args = {
joins?: SanitizedJoins
parentIsLocalized: boolean
polymorphicJoins?: SanitizedJoin[]
/**
* If true, a richText field will require an editor property to be set, as the sanitizeFields function will not add it from the payload config if not present.
*
@@ -59,9 +69,11 @@ type Args = {
}
export const sanitizeFields = async ({
collectionConfig,
config,
existingFieldNames = new Set(),
fields,
isTopLevelField = true,
joinPath = '',
joins,
parentIsLocalized,
@@ -80,6 +92,7 @@ export const sanitizeFields = async ({
if ('_sanitized' in field && field._sanitized === true) {
continue
}
if ('_sanitized' in field) {
field._sanitized = true
}
@@ -88,8 +101,39 @@ export const sanitizeFields = async ({
throw new MissingFieldType(field)
}
const fieldAffectsData = _fieldAffectsData(field)
if (isTopLevelField && fieldAffectsData && field.name) {
if (collectionConfig && collectionConfig.upload) {
if (reservedBaseUploadFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
if (
collectionConfig &&
collectionConfig.auth &&
typeof collectionConfig.auth === 'object' &&
!collectionConfig.auth.disableLocalStrategy
) {
if (reservedBaseAuthFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
if (collectionConfig.auth.verify) {
if (reservedAPIKeyFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
if (reservedVerifyFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
}
}
// assert that field names do not contain forbidden characters
if (fieldAffectsData(field) && field.name.includes('.')) {
if (fieldAffectsData && field.name.includes('.')) {
throw new InvalidFieldName(field, field.name)
}
@@ -122,6 +166,7 @@ export const sanitizeFields = async ({
const relationships = Array.isArray(field.relationTo)
? field.relationTo
: [field.relationTo]
relationships.forEach((relationship: string) => {
if (!validRelationships.includes(relationship)) {
throw new InvalidFieldRelationship(field, relationship)
@@ -135,6 +180,7 @@ export const sanitizeFields = async ({
)
field.minRows = field.min
}
if (field.max && !field.maxRows) {
console.warn(
`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`,
@@ -160,7 +206,7 @@ export const sanitizeFields = async ({
field.labels = field.labels || formatLabels(field.name)
}
if (fieldAffectsData(field)) {
if (fieldAffectsData) {
if (existingFieldNames.has(field.name)) {
throw new DuplicateFieldName(field.name)
} else if (!['blockName', 'id'].includes(field.name)) {
@@ -254,9 +300,11 @@ export const sanitizeFields = async ({
block.fields = block.fields.concat(baseBlockFields)
block.labels = !block.labels ? formatLabels(block.slug) : block.labels
block.fields = await sanitizeFields({
collectionConfig,
config,
existingFieldNames: new Set(),
fields: block.fields,
isTopLevelField: false,
parentIsLocalized: parentIsLocalized || field.localized,
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
@@ -267,12 +315,12 @@ export const sanitizeFields = async ({
if ('fields' in field && field.fields) {
field.fields = await sanitizeFields({
collectionConfig,
config,
existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames,
existingFieldNames: fieldAffectsData ? new Set() : existingFieldNames,
fields: field.fields,
joinPath: fieldAffectsData(field)
? `${joinPath ? joinPath + '.' : ''}${field.name}`
: joinPath,
isTopLevelField: isTopLevelField && !fieldAffectsData,
joinPath: fieldAffectsData ? `${joinPath ? joinPath + '.' : ''}${field.name}` : joinPath,
joins,
parentIsLocalized: parentIsLocalized || fieldIsLocalized(field),
polymorphicJoins,
@@ -285,7 +333,10 @@ export const sanitizeFields = async ({
if (field.type === 'tabs') {
for (let j = 0; j < field.tabs.length; j++) {
const tab = field.tabs[j]
if (tabHasName(tab) && typeof tab.label === 'undefined') {
const isNamedTab = tabHasName(tab)
if (isNamedTab && typeof tab.label === 'undefined') {
tab.label = toWords(tab.name)
}
@@ -296,21 +347,24 @@ export const sanitizeFields = async ({
!tab.id
) {
// Always attach a UUID to tabs with a condition so there's no conflicts even if there are duplicate nested names
tab.id = tabHasName(tab) ? `${tab.name}_${uuid()}` : uuid()
tab.id = isNamedTab ? `${tab.name}_${uuid()}` : uuid()
}
tab.fields = await sanitizeFields({
collectionConfig,
config,
existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames,
existingFieldNames: isNamedTab ? new Set() : existingFieldNames,
fields: tab.fields,
joinPath: tabHasName(tab) ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath,
isTopLevelField: isTopLevelField && !isNamedTab,
joinPath: isNamedTab ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath,
joins,
parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized),
parentIsLocalized: parentIsLocalized || (isNamedTab && tab.localized),
polymorphicJoins,
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
validRelationships,
})
field.tabs[j] = tab
}
}