Skip to content

Commit c05ace2

Browse files
authored
fix(richtext-lexical): strip server-only properties from blocks in lexical client schema map (#15756)
Fixes #15509 When a block used in `BlocksFeature` contains a nested `blocks` field with `blockReferences`, the admin panel crashes with "Functions cannot be passed directly to Client Components". This happens because the richText schema map conversion in `buildClientFieldSchemaMap` treats all entries as `Field` objects via an unsafe `as Field` cast. Block entries (which have `slug` but no `type`) pass through `createClientField` which doesn't understand them, causing `flattenedFields` - containing server-only properties - to be copied to the client. The fix replaces the unsafe cast with type-safe union discrimination on the `FieldSchemaMap` value type (`Block | Field | Tab | { fields }`) using `in` narrowing. Block entries are now routed through `createClientBlocks` which correctly strips server-only properties, while Field and fields-wrapper entries continue through `createClientFields` as before.
1 parent 0a123b5 commit c05ace2

8 files changed

Lines changed: 212 additions & 23 deletions

File tree

packages/payload/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,7 @@ export { slugField, type SlugFieldClientProps } from './fields/baseFields/slug/i
15181518
export { type SlugField } from './fields/baseFields/slug/index.js'
15191519

15201520
export {
1521+
createClientBlocks,
15211522
createClientField,
15221523
createClientFields,
15231524
type ServerOnlyFieldAdminProperties,

packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import type { I18n } from '@payloadcms/translations'
22
import type {
3+
ClientBlock,
34
ClientConfig,
45
ClientField,
56
ClientFieldSchemaMap,
6-
Field,
77
FieldSchemaMap,
88
Payload,
99
TabAsFieldClient,
1010
} from 'payload'
1111

12-
import { createClientFields } from 'payload'
12+
import { createClientBlocks, createClientFields } from 'payload'
1313
import { fieldAffectsData, getFieldPaths, tabHasName } from 'payload/shared'
1414

1515
type Args = {
@@ -143,27 +143,49 @@ export const traverseFields = ({
143143
}
144144
}
145145

146-
// Now loop through them, convert each entry to a client field and add it to the client schema map
146+
// Now loop through them, convert each entry to a client field and add it to the client schema map.
147+
// Schema map values are a union: Block | Field | Tab | { fields: Field[] }.
148+
// Each variant needs different conversion to strip server-only properties.
147149
for (const [path, subField] of richTextFieldSchemaMap.entries()) {
148-
// check if fields is the only key in the subField object
149-
const isFieldsOnly = Object.keys(subField).length === 1 && 'fields' in subField
150+
if ('slug' in subField) {
151+
const clientBlocks = createClientBlocks({
152+
blocks: [subField],
153+
defaultIDType: payload.config.db.defaultIDType,
154+
i18n,
155+
importMap: payload.importMap,
156+
})
157+
158+
clientSchemaMap.set(path, clientBlocks[0] as ClientBlock)
159+
continue
160+
}
150161

151-
const clientFields = createClientFields({
152-
defaultIDType: payload.config.db.defaultIDType,
153-
disableAddingID: true,
154-
fields: isFieldsOnly ? subField.fields : [subField as Field],
155-
i18n,
156-
importMap: payload.importMap,
157-
})
162+
if ('type' in subField) {
163+
const clientFields = createClientFields({
164+
defaultIDType: payload.config.db.defaultIDType,
165+
disableAddingID: true,
166+
fields: [subField],
167+
i18n,
168+
importMap: payload.importMap,
169+
})
170+
171+
clientSchemaMap.set(path, clientFields[0])
172+
continue
173+
}
174+
175+
if ('fields' in subField) {
176+
const clientFields = createClientFields({
177+
defaultIDType: payload.config.db.defaultIDType,
178+
disableAddingID: true,
179+
fields: subField.fields,
180+
i18n,
181+
importMap: payload.importMap,
182+
})
183+
184+
clientSchemaMap.set(path, { fields: clientFields })
185+
continue
186+
}
158187

159-
clientSchemaMap.set(
160-
path,
161-
isFieldsOnly
162-
? {
163-
fields: clientFields,
164-
}
165-
: clientFields[0],
166-
)
188+
subField satisfies never
167189
}
168190
break
169191
}

test/lexical/baseConfig.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import { LexicalLinkFeature } from './collections/LexicalLinkFeature/index.js'
2121
import { LexicalListsFeature } from './collections/LexicalListsFeature/index.js'
2222
import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
2323
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
24+
import {
25+
BlockWithBlockRef,
26+
LexicalNestedBlocks,
27+
NestedBlock,
28+
} from './collections/LexicalNestedBlocks/index.js'
2429
import { LexicalObjectReferenceBugCollection } from './collections/LexicalObjectReferenceBug/index.js'
2530
import { LexicalRelationshipsFields } from './collections/LexicalRelationships/index.js'
2631
import { OnDemandForm } from './collections/OnDemandForm/index.js'
@@ -36,6 +41,7 @@ const dirname = path.dirname(filename)
3641

3742
export const baseConfig: Partial<Config> = {
3843
// ...extend config here
44+
blocks: [NestedBlock, BlockWithBlockRef],
3945
collections: [
4046
LexicalFullyFeatured,
4147
LexicalAutosave,
@@ -53,6 +59,7 @@ export const baseConfig: Partial<Config> = {
5359
LexicalInBlock,
5460
LexicalAccessControl,
5561
LexicalRelationshipsFields,
62+
LexicalNestedBlocks,
5663
RichTextFields,
5764
TextFields,
5865
Uploads,

test/lexical/collections/Lexical/e2e/blocks/e2e.spec.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { reInitializeDB } from '../../../../../__helpers/shared/clearAndSeed/reI
3232
import { initPayloadE2ENoConfig } from '../../../../../__helpers/shared/initPayloadE2ENoConfig.js'
3333
import { RESTClient } from '../../../../../__helpers/shared/rest.js'
3434
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js'
35-
import { lexicalFieldsSlug } from '../../../../slugs.js'
35+
import { lexicalFieldsSlug, lexicalNestedBlocksSlug } from '../../../../slugs.js'
3636
import { lexicalDocData } from '../../data.js'
3737

3838
const filename = fileURLToPath(import.meta.url)
@@ -1295,6 +1295,36 @@ describe('lexicalBlocks', () => {
12951295
).toHaveText('Some Description')
12961296
})
12971297

1298+
test('should render block with nested blocks field using blockReferences without crashing', async () => {
1299+
// https://github.com/payloadcms/payload/issues/15509
1300+
const url = new AdminUrlUtil(serverURL, lexicalNestedBlocksSlug)
1301+
1302+
await page.goto(url.create)
1303+
await waitForFormReady(page)
1304+
1305+
const richTextField = page.locator('.rich-text-lexical').first()
1306+
await expect(richTextField).toBeVisible()
1307+
1308+
const contentEditable = richTextField.locator('[contenteditable="true"]').first()
1309+
await contentEditable.click()
1310+
1311+
await page.keyboard.press('/')
1312+
await page.keyboard.type('blockwithblockref')
1313+
1314+
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
1315+
await expect(slashMenuPopover).toBeVisible()
1316+
1317+
const blockSelectButton = slashMenuPopover.locator('button').first()
1318+
await expect(blockSelectButton).toBeVisible()
1319+
await blockSelectButton.click()
1320+
await expect(slashMenuPopover).toBeHidden()
1321+
1322+
const newBlock = richTextField.locator('.LexicalEditorTheme__block').first()
1323+
await expect(newBlock).toBeVisible()
1324+
1325+
await expect(newBlock.locator('button:has-text("Add Nested Block")')).toBeVisible()
1326+
})
1327+
12981328
test('ensure individual inline blocks in lexical editor within a block have initial state on initial load', async () => {
12991329
await page.goto('http://localhost:3000/admin/collections/LexicalInBlock?limit=10')
13001330

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { Block, BlockSlug, CollectionConfig } from 'payload'
2+
3+
import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
4+
5+
import { lexicalNestedBlocksSlug } from '../../slugs.js'
6+
7+
export const NestedBlock: Block = {
8+
slug: 'nestedBlock',
9+
fields: [
10+
{
11+
name: 'text',
12+
type: 'text',
13+
},
14+
],
15+
}
16+
17+
export const BlockWithBlockRef: Block = {
18+
slug: 'blockWithBlockRef',
19+
fields: [
20+
{
21+
name: 'nestedBlocks',
22+
type: 'blocks',
23+
blockReferences: ['nestedBlock'],
24+
blocks: [],
25+
},
26+
],
27+
}
28+
29+
export const LexicalNestedBlocks: CollectionConfig = {
30+
slug: lexicalNestedBlocksSlug,
31+
access: {
32+
read: () => true,
33+
},
34+
admin: {
35+
useAsTitle: 'title',
36+
},
37+
fields: [
38+
{
39+
name: 'title',
40+
type: 'text',
41+
required: true,
42+
},
43+
{
44+
name: 'richText',
45+
type: 'richText',
46+
editor: lexicalEditor({
47+
features: ({ defaultFeatures }) => [
48+
...defaultFeatures,
49+
BlocksFeature({
50+
blocks: ['blockWithBlockRef' as BlockSlug],
51+
}),
52+
],
53+
}),
54+
},
55+
],
56+
}

test/lexical/config.blockreferences.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import type { BlockSlug } from 'payload'
44

5-
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
65
import { autoDedupeBlocksPlugin } from '../__helpers/shared/autoDedupeBlocksPlugin/index.js'
6+
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
77
import { baseConfig } from './baseConfig.js'
88
import {
99
getLexicalFieldsCollection,

test/lexical/payload-types.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ export interface Config {
8181
auth: {
8282
users: UserAuthOperations;
8383
};
84-
blocks: {};
84+
blocks: {
85+
nestedBlock: NestedBlock;
86+
blockWithBlockRef: BlockWithBlockRef;
87+
};
8588
collections: {
8689
'lexical-fully-featured': LexicalFullyFeatured;
8790
'lexical-autosave': LexicalAutosave;
@@ -96,6 +99,7 @@ export interface Config {
9699
LexicalInBlock: LexicalInBlock;
97100
'lexical-access-control': LexicalAccessControl;
98101
'lexical-relationship-fields': LexicalRelationshipField;
102+
'lexical-nested-blocks': LexicalNestedBlock;
99103
'rich-text-fields': RichTextField;
100104
'text-fields': TextField;
101105
uploads: Upload;
@@ -124,6 +128,7 @@ export interface Config {
124128
LexicalInBlock: LexicalInBlockSelect<false> | LexicalInBlockSelect<true>;
125129
'lexical-access-control': LexicalAccessControlSelect<false> | LexicalAccessControlSelect<true>;
126130
'lexical-relationship-fields': LexicalRelationshipFieldsSelect<false> | LexicalRelationshipFieldsSelect<true>;
131+
'lexical-nested-blocks': LexicalNestedBlocksSelect<false> | LexicalNestedBlocksSelect<true>;
127132
'rich-text-fields': RichTextFieldsSelect<false> | RichTextFieldsSelect<true>;
128133
'text-fields': TextFieldsSelect<false> | TextFieldsSelect<true>;
129134
uploads: UploadsSelect<false> | UploadsSelect<true>;
@@ -172,6 +177,33 @@ export interface UserAuthOperations {
172177
password: string;
173178
};
174179
}
180+
/**
181+
* This interface was referenced by `Config`'s JSON-Schema
182+
* via the `definition` "nestedBlock".
183+
*/
184+
export interface NestedBlock {
185+
text?: string | null;
186+
id?: string | null;
187+
blockName?: string | null;
188+
blockType: 'nestedBlock';
189+
}
190+
/**
191+
* This interface was referenced by `Config`'s JSON-Schema
192+
* via the `definition` "blockWithBlockRef".
193+
*/
194+
export interface BlockWithBlockRef {
195+
nestedBlocks?:
196+
| {
197+
text?: string | null;
198+
id?: string | null;
199+
blockName?: string | null;
200+
blockType: 'nestedBlock';
201+
}[]
202+
| null;
203+
id?: string | null;
204+
blockName?: string | null;
205+
blockType: 'blockWithBlockRef';
206+
}
175207
/**
176208
* This interface was referenced by `Config`'s JSON-Schema
177209
* via the `definition` "lexical-fully-featured".
@@ -706,6 +738,31 @@ export interface LexicalRelationshipField {
706738
createdAt: string;
707739
_status?: ('draft' | 'published') | null;
708740
}
741+
/**
742+
* This interface was referenced by `Config`'s JSON-Schema
743+
* via the `definition` "lexical-nested-blocks".
744+
*/
745+
export interface LexicalNestedBlock {
746+
id: string;
747+
title: string;
748+
richText?: {
749+
root: {
750+
type: string;
751+
children: {
752+
type: any;
753+
version: number;
754+
[k: string]: unknown;
755+
}[];
756+
direction: ('ltr' | 'rtl') | null;
757+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
758+
indent: number;
759+
version: number;
760+
};
761+
[k: string]: unknown;
762+
} | null;
763+
updatedAt: string;
764+
createdAt: string;
765+
}
709766
/**
710767
* This interface was referenced by `Config`'s JSON-Schema
711768
* via the `definition` "rich-text-fields".
@@ -1130,6 +1187,10 @@ export interface PayloadLockedDocument {
11301187
relationTo: 'lexical-relationship-fields';
11311188
value: string | LexicalRelationshipField;
11321189
} | null)
1190+
| ({
1191+
relationTo: 'lexical-nested-blocks';
1192+
value: string | LexicalNestedBlock;
1193+
} | null)
11331194
| ({
11341195
relationTo: 'rich-text-fields';
11351196
value: string | RichTextField;
@@ -1368,6 +1429,16 @@ export interface LexicalRelationshipFieldsSelect<T extends boolean = true> {
13681429
createdAt?: T;
13691430
_status?: T;
13701431
}
1432+
/**
1433+
* This interface was referenced by `Config`'s JSON-Schema
1434+
* via the `definition` "lexical-nested-blocks_select".
1435+
*/
1436+
export interface LexicalNestedBlocksSelect<T extends boolean = true> {
1437+
title?: T;
1438+
richText?: T;
1439+
updatedAt?: T;
1440+
createdAt?: T;
1441+
}
13711442
/**
13721443
* This interface was referenced by `Config`'s JSON-Schema
13731444
* via the `definition` "rich-text-fields_select".

test/lexical/slugs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const uploads2Slug = 'uploads2'
2121

2222
export const arrayFieldsSlug = 'array-fields'
2323

24+
export const lexicalNestedBlocksSlug = 'lexical-nested-blocks'
25+
2426
export const collectionSlugs = [
2527
lexicalFieldsSlug,
2628
lexicalLocalizedFieldsSlug,

0 commit comments

Comments
 (0)