Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/payload/src/fields/config/client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Field } from './types.js'

import { describe, expect, it } from 'vitest'

import { createClientBlocks, createClientFields } from './client.js'

describe('client schema conversion cache', () => {
it('should reuse shared definitions without exposing server properties or merging same-slug blocks', () => {
const shared: Field = { name: 'text', type: 'text', access: { read: () => false } }
const options = { defaultIDType: 'text' as const, i18n: {} as any, importMap: {} }
const blocks = createClientBlocks({
...options,
blocks: [
{ slug: 'example', fields: [shared, shared] },
{ slug: 'example', fields: [{ name: 'other', type: 'number' }] },
],
})
expect(typeof blocks[0]).toBe('object')
const first = blocks[0] as Exclude<(typeof blocks)[number], string>
const second = blocks[1] as Exclude<(typeof blocks)[number], string>
expect(first.fields[0]).toBe(first.fields[1])
expect(first.fields[0]).not.toHaveProperty('access')
expect(first).not.toBe(second)
expect(second.fields[0]).toHaveProperty('name', 'other')
})

it('should scope cached translations to a conversion call', () => {
const field: Field = { name: 'text', type: 'text', label: ({ t }) => t('general:save') }
const convert = (label: string) =>
createClientFields({
fields: [field, field],
defaultIDType: 'text',
importMap: {},
i18n: { t: () => label } as any,
})
const english = convert('Save')
const german = convert('Speichern')
expect(english[0]).toBe(english[1])
expect(english[0].label).toBe('Save')
expect(german[0].label).toBe('Speichern')
expect(english[0]).not.toBe(german[0])
})
})
21 changes: 21 additions & 0 deletions packages/payload/src/fields/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,12 @@ export const createClientBlocks = ({
defaultIDType,
i18n,
importMap,
schemaCache = new WeakMap<object, unknown>(),
}: {
blocks: (Block | string)[]
defaultIDType: Payload['config']['db']['defaultIDType']
i18n: I18nClient
schemaCache?: WeakMap<object, unknown>
importMap: ImportMap
}): (ClientBlock | string)[] | ClientBlock[] => {
const clientBlocks: (ClientBlock | string)[] = []
Expand All @@ -100,6 +102,11 @@ export const createClientBlocks = ({
continue
}

if (schemaCache.has(block)) {
clientBlocks.push(schemaCache.get(block) as ClientBlock)
continue
}

const clientBlock: ClientBlock = {
slug: block.slug,
fields: [],
Expand Down Expand Up @@ -164,8 +171,10 @@ export const createClientBlocks = ({
fields: block.fields,
i18n,
importMap,
schemaCache,
})

schemaCache.set(block, clientBlock)
clientBlocks.push(clientBlock)
}

Expand All @@ -177,12 +186,16 @@ export const createClientField = ({
field: incomingField,
i18n,
importMap,
schemaCache = new WeakMap<object, unknown>(),
}: {
defaultIDType: Payload['config']['db']['defaultIDType']
field: Field
i18n: I18nClient
schemaCache?: WeakMap<object, unknown>
importMap: ImportMap
}): ClientField => {
if (schemaCache.has(incomingField)) return schemaCache.get(incomingField) as ClientField

const clientField: ClientField = {} as ClientField

for (const key in incomingField) {
Expand Down Expand Up @@ -286,6 +299,7 @@ export const createClientField = ({
fields: incomingField.fields,
i18n,
importMap,
schemaCache,
})

break
Expand Down Expand Up @@ -317,6 +331,7 @@ export const createClientField = ({
defaultIDType,
i18n,
importMap,
schemaCache,
})
}

Expand All @@ -326,6 +341,7 @@ export const createClientField = ({
defaultIDType,
i18n,
importMap,
schemaCache,
}) as ClientBlock[]
}

Expand Down Expand Up @@ -423,6 +439,7 @@ export const createClientField = ({
fields: tab.fields,
i18n,
importMap,
schemaCache,
})
} else if (
(key === 'label' || key === 'description') &&
Expand Down Expand Up @@ -473,6 +490,7 @@ export const createClientField = ({
break
}

schemaCache.set(incomingField, clientField)
return clientField
}

Expand All @@ -482,11 +500,13 @@ export const createClientFields = ({
fields,
i18n,
importMap,
schemaCache = new WeakMap<object, unknown>(),
}: {
defaultIDType: Payload['config']['db']['defaultIDType']
disableAddingID?: boolean
fields: Field[]
i18n: I18nClient
schemaCache?: WeakMap<object, unknown>
importMap: ImportMap
}): ClientField[] => {
const clientFields: ClientField[] = []
Expand All @@ -499,6 +519,7 @@ export const createClientFields = ({
field,
i18n,
importMap,
schemaCache,
})

clientFields.push(clientField)
Expand Down
14 changes: 14 additions & 0 deletions packages/richtext-lexical/src/features/blocks/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,20 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
})
}

let blockSchemaMap: FieldSchemaMap | undefined

return {
ClientFeature: '@payloadcms/richtext-lexical/client#BlocksFeatureClient',
clientSchemaMapKeys: [
...blockConfigs.flatMap((block) => [
`lexical_blocks.${block.slug}.fields`,
`lexical_blocks.${block.slug}`,
]),
...inlineBlockConfigs.flatMap((block) => [
`lexical_inline_blocks.${block.slug}.fields`,
`lexical_inline_blocks.${block.slug}`,
]),
],
generatedTypes: {
modifyOutputSchema: ({
collectionIDFieldTypes,
Expand Down Expand Up @@ -143,6 +155,7 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
},
},
generateSchemaMap: ({ config }) => {
if (blockSchemaMap) return blockSchemaMap
/**
* Add sub-fields to the schemaMap. E.g. if you have an array field as part of the block, and it runs addRow, it will request these
* sub-fields from the component map. Thus, we need to put them in the component map here.
Expand Down Expand Up @@ -206,6 +219,7 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
}
}

blockSchemaMap = schemaMap
return schemaMap
},
i18n,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const LinkFeature = createServerFeature<

return {
ClientFeature: '@payloadcms/richtext-lexical/client#LinkFeatureClient',
clientSchemaMapKeys: ['fields'],
clientFeatureProps: {
defaultLinkType,
defaultLinkURL,
Expand Down
2 changes: 2 additions & 0 deletions packages/richtext-lexical/src/features/typesServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ export type ServerFeature<ServerProps, ClientFeatureProps> = {
isRequired: boolean
}) => JSONSchema4
}
/** Relative schema keys consumed by this client feature. Omit to include all descendants. */
clientSchemaMapKeys?: string[]
generateSchemaMap?: (args: {
config: SanitizedConfig
field: RichTextField
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ export const UploadFeature = createServerFeature<

return {
ClientFeature: '@payloadcms/richtext-lexical/client#UploadFeatureClient',
clientSchemaMapKeys: Object.keys(props.collections ?? {}).filter(
(key) => props.collections[key].fields?.length,
),
clientFeatureProps: clientProps,
generateSchemaMap: ({ props }) => {
if (!props?.collections) {
Expand Down
58 changes: 36 additions & 22 deletions packages/richtext-lexical/src/utilities/generateSchemaMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,46 @@ export const getGenerateSchemaMap =
) {
continue
}
const schemas = resolvedFeature.generateSchemaMap({
config,
field,
i18n,
props: resolvedFeature.sanitizedServerFeatureProps,
schemaMap,
schemaPath,
})
const load = () => {
const schemas = resolvedFeature.generateSchemaMap!({
config,
field,
i18n,
props: resolvedFeature.sanitizedServerFeatureProps,
schemaMap,
schemaPath,
})

if (schemas) {
for (const [schemaKey, field] of schemas.entries()) {
if ('fields' in field) {
// generate schema map entries for sub-fields using traverseFields
traverseFields({
config,
fields: field.fields,
i18n,
parentIndexPath: '',
parentSchemaPath: `${schemaPath}.lexical_internal_feature.${featureKey}.${schemaKey}`,
schemaMap,
})
}
if (schemas) {
for (const [schemaKey, field] of schemas.entries()) {
if ('fields' in field) {
// generate schema map entries for sub-fields using traverseFields
traverseFields({
config,
fields: field.fields,
i18n,
parentIndexPath: '',
parentSchemaPath: `${schemaPath}.lexical_internal_feature.${featureKey}.${schemaKey}`,
schemaMap,
})
}

schemaMap.set(`${schemaPath}.lexical_internal_feature.${featureKey}.${schemaKey}`, field)
schemaMap.set(
`${schemaPath}.lexical_internal_feature.${featureKey}.${schemaKey}`,
field,
)
}
}
}
if (
resolvedFeature.clientSchemaMapKeys &&
'defer' in schemaMap &&
typeof schemaMap.defer === 'function'
) {
schemaMap.defer(`${schemaPath}.lexical_internal_feature.${featureKey}`, load)
} else {
load()
}
}

return schemaMap
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { initLexicalFeatures } from './initLexicalFeatures.js'

describe('client feature schema selection', () => {
it.each(['blocks', 'link', 'upload', 'custom'])(
'should preserve descendants for non-opted-in %s features',
(key) => {
const prefix = `pages.body.lexical_internal_feature.${key}`
const fields = [{ name: 'rel', type: 'text' }]
const result = initLexicalFeatures({
schemaPath: 'pages.body',
clientFieldSchemaMap: new Map([
[`${prefix}.fields`, { fields }],
[`${prefix}.fields.rel`, fields[0]],
]),
sanitizedEditorConfig: {
resolvedFeatureMap: new Map([
[key, { key, order: 0, generateSchemaMap: () => new Map() }],
]),
},
} as any)
expect(Object.keys(result.featureClientSchemaMap[key])).toHaveLength(2)
},
)

it('should use declared keys without regenerating schemas or dropping nested definitions', () => {
const prefix = 'pages.body.lexical_internal_feature.blocks'
const fields = [
{ name: 'items', type: 'array', fields: [{ name: 'content', type: 'richText' }] },
]
const result = initLexicalFeatures({
schemaPath: 'pages.body',
clientFieldSchemaMap: new Map([
[`${prefix}.fields`, { fields }],
[`${prefix}.fields.items`, fields[0]],
]),
sanitizedEditorConfig: {
resolvedFeatureMap: new Map([
[
'blocks',
{
key: 'blocks',
order: 0,
clientSchemaMapKeys: ['fields'],
generateSchemaMap: () => {
throw new Error('must not be called again')
},
},
],
]),
},
} as any)
expect(Object.keys(result.featureClientSchemaMap.blocks)).toEqual([`${prefix}.fields`])
expect(result.featureClientSchemaMap.blocks[`${prefix}.fields`]).toBe(fields)
})
})
17 changes: 13 additions & 4 deletions packages/richtext-lexical/src/utilities/initLexicalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,19 @@ export function initLexicalFeatures(args: Args): {

featureClientSchemaMap[featureKey] = {}

// Like args.fieldSchemaMap, we only want to include the sub-fields of the current feature
for (const [key, entry] of args.clientFieldSchemaMap.entries()) {
if (key.startsWith(featureSchemaPath)) {
featureClientSchemaMap[featureKey][key] = 'fields' in entry ? entry.fields : [entry]
if (resolvedFeature.clientSchemaMapKeys) {
for (const key of resolvedFeature.clientSchemaMapKeys) {
const path = `${featureSchemaPath}.${key}`
const entry = args.clientFieldSchemaMap.get(path)
if (entry)
featureClientSchemaMap[featureKey][path] = 'fields' in entry ? entry.fields : [entry]
}
} else {
// Like args.fieldSchemaMap, we only want to include the sub-fields of the current feature
for (const [key, entry] of args.clientFieldSchemaMap.entries()) {
if (key.startsWith(featureSchemaPath)) {
featureClientSchemaMap[featureKey][key] = 'fields' in entry ? entry.fields : [entry]
}
}
}
}
Expand Down
Loading