Skip to content

Commit 69408f0

Browse files
c-riqAlessioGr
andauthored
fix(richtext-lexical): prevent unnecessary requests for inline blocks (#14522)
Inline blocks now use cached form state from initialLexicalFormState, matching the pattern used by regular blocks (f8e6b65). #14521 --------- Co-authored-by: Alessio Gravili <alessio@gravili.de>
1 parent 4fd8d03 commit 69408f0

File tree

5 files changed

+162
-6
lines changed

5 files changed

+162
-6
lines changed

packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,28 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
8181
const editDepth = useEditDepth()
8282
const firstTimeDrawer = useRef(false)
8383

84-
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(
85-
() => initialLexicalFormState?.[formData.id]?.formState,
86-
)
84+
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(() => {
85+
// Initial form state that was calculated server-side. May have stale values
86+
const cachedFormState = initialLexicalFormState?.[formData.id]?.formState
87+
if (!cachedFormState) {
88+
return false
89+
}
90+
91+
// Merge current formData values into the cached form state
92+
// This ensures that when the component remounts (e.g., due to view changes), we don't lose user edits
93+
return Object.fromEntries(
94+
Object.entries(cachedFormState).map(([fieldName, fieldState]) => [
95+
fieldName,
96+
fieldName in formData
97+
? {
98+
...fieldState,
99+
initialValue: formData[fieldName],
100+
value: formData[fieldName],
101+
}
102+
: fieldState,
103+
]),
104+
)
105+
})
87106

88107
const hasMounted = useRef(false)
89108
const prevCacheBuster = useRef(cacheBuster)

test/helpers/e2e/assertNetworkRequests.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import { expect } from '@playwright/test'
1818
*/
1919
export const assertNetworkRequests = async (
2020
page: Page,
21+
/**
22+
* The URL to match in the network requests. The request URL will need to *include* this URL.
23+
*/
2124
url: string,
2225
action: () => Promise<any>,
2326
{
@@ -26,6 +29,7 @@ export const assertNetworkRequests = async (
2629
timeout = 5000,
2730
minimumNumberOfRequests,
2831
interval = 1000,
32+
requestFilter,
2933
}: {
3034
allowedNumberOfRequests?: number
3135
beforePoll?: () => Promise<any> | void
@@ -35,14 +39,18 @@ export const assertNetworkRequests = async (
3539
* as long as at least this number of requests are made.
3640
*/
3741
minimumNumberOfRequests?: number
42+
/**
43+
* If set, only consider requests that match the filter AND the URL.
44+
*/
45+
requestFilter?: (request: Request) => boolean | Promise<boolean>
3846
timeout?: number
3947
} = {},
4048
): Promise<Array<Request>> => {
4149
const matchedRequests: Request[] = []
4250

4351
// begin tracking network requests
44-
page.on('request', (request) => {
45-
if (request.url().includes(url)) {
52+
page.on('request', async (request) => {
53+
if (request.url().includes(url) && (requestFilter ? await requestFilter(request) : true)) {
4654
matchedRequests.push(request)
4755
}
4856
})

test/lexical/collections/_LexicalFullyFeatured/db/e2e.spec.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import {
2+
buildEditorState,
3+
type DefaultNodeTypes,
4+
type SerializedInlineBlockNode,
5+
} from '@payloadcms/richtext-lexical'
16
import { expect, type Page, test } from '@playwright/test'
27
import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
38
import path from 'path'
49
import { fileURLToPath } from 'url'
510

611
import type { PayloadTestSDK } from '../../../../helpers/sdk/index.js'
7-
import type { Config } from '../../../payload-types.js'
12+
import type { Config, InlineBlockWithSelect } from '../../../payload-types.js'
813

914
import { ensureCompilationIsDone, saveDocAndAssert } from '../../../../helpers.js'
1015
import { AdminUrlUtil } from '../../../../helpers/adminUrlUtil.js'
@@ -173,4 +178,73 @@ describe('Lexical Fully Featured - database', () => {
173178
await expect(lexical.editor.locator('#field-someText')).toHaveValue('Updated text')
174179
})
175180
})
181+
182+
test('ensure inline block initial form state is applied on load for inline blocks with select fields', async ({
183+
page,
184+
}) => {
185+
const doc = await payload.create({
186+
collection: 'lexical-fully-featured',
187+
data: {
188+
richText: buildEditorState<
189+
DefaultNodeTypes | SerializedInlineBlockNode<InlineBlockWithSelect>
190+
>({
191+
nodes: [
192+
{
193+
type: 'inlineBlock',
194+
version: 1,
195+
fields: {
196+
blockType: 'inlineBlockWithSelect',
197+
id: '1',
198+
},
199+
},
200+
{
201+
type: 'inlineBlock',
202+
version: 1,
203+
fields: {
204+
blockType: 'inlineBlockWithSelect',
205+
id: '2',
206+
},
207+
},
208+
{
209+
type: 'inlineBlock',
210+
version: 1,
211+
fields: {
212+
blockType: 'inlineBlockWithSelect',
213+
id: '3',
214+
},
215+
},
216+
],
217+
}),
218+
},
219+
})
220+
221+
/**
222+
* Ensure there are no unnecessary, additional form state requests made, since we already have the form state as part of the initial state.
223+
*/
224+
await assertNetworkRequests(
225+
page,
226+
`/admin/collections/${lexicalFullyFeaturedSlug}`,
227+
async () => {
228+
await page.goto(url.edit(doc.id))
229+
await lexical.editor.first().focus()
230+
},
231+
{
232+
minimumNumberOfRequests: 0,
233+
allowedNumberOfRequests: 0,
234+
requestFilter: (request) => {
235+
// Ensure it's a form state request
236+
if (request.method() === 'POST') {
237+
const requestBody = request.postDataJSON()
238+
239+
return (
240+
Array.isArray(requestBody) &&
241+
requestBody.length > 0 &&
242+
requestBody[0].name === 'form-state'
243+
)
244+
}
245+
return false
246+
},
247+
},
248+
)
249+
})
176250
})

test/lexical/collections/_LexicalFullyFeatured/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ export const LexicalFullyFeatured: CollectionConfig = {
8888
},
8989
],
9090
},
91+
{
92+
slug: 'inlineBlockWithSelect',
93+
interfaceName: 'InlineBlockWithSelect',
94+
fields: [
95+
{
96+
// Having this specific select field here reproduces an issue where the initial state is not applied on load, and every
97+
// inline block will make its own form state request on load.
98+
name: 'styles',
99+
type: 'select',
100+
hasMany: true,
101+
options: [
102+
{ label: 'Option 1', value: 'opt1' },
103+
{ label: 'Option 2', value: 'opt2' },
104+
],
105+
defaultValue: [],
106+
},
107+
],
108+
},
91109
{
92110
slug: 'inlineBlockWithRelationship',
93111
fields: [

test/lexical/payload-types.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export interface Config {
102102
'array-fields': ArrayField;
103103
OnDemandForm: OnDemandForm;
104104
OnDemandOutsideForm: OnDemandOutsideForm;
105+
'payload-kv': PayloadKv;
105106
users: User;
106107
'payload-locked-documents': PayloadLockedDocument;
107108
'payload-preferences': PayloadPreference;
@@ -128,6 +129,7 @@ export interface Config {
128129
'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>;
129130
OnDemandForm: OnDemandFormSelect<false> | OnDemandFormSelect<true>;
130131
OnDemandOutsideForm: OnDemandOutsideFormSelect<false> | OnDemandOutsideFormSelect<true>;
132+
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
131133
users: UsersSelect<false> | UsersSelect<true>;
132134
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
133135
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -995,6 +997,23 @@ export interface OnDemandOutsideForm {
995997
updatedAt: string;
996998
createdAt: string;
997999
}
1000+
/**
1001+
* This interface was referenced by `Config`'s JSON-Schema
1002+
* via the `definition` "payload-kv".
1003+
*/
1004+
export interface PayloadKv {
1005+
id: string;
1006+
key: string;
1007+
data:
1008+
| {
1009+
[k: string]: unknown;
1010+
}
1011+
| unknown[]
1012+
| string
1013+
| number
1014+
| boolean
1015+
| null;
1016+
}
9981017
/**
9991018
* This interface was referenced by `Config`'s JSON-Schema
10001019
* via the `definition` "users".
@@ -1533,6 +1552,14 @@ export interface OnDemandOutsideFormSelect<T extends boolean = true> {
15331552
updatedAt?: T;
15341553
createdAt?: T;
15351554
}
1555+
/**
1556+
* This interface was referenced by `Config`'s JSON-Schema
1557+
* via the `definition` "payload-kv_select".
1558+
*/
1559+
export interface PayloadKvSelect<T extends boolean = true> {
1560+
key?: T;
1561+
data?: T;
1562+
}
15361563
/**
15371564
* This interface was referenced by `Config`'s JSON-Schema
15381565
* via the `definition` "users_select".
@@ -1649,6 +1676,16 @@ export interface TabsWithRichTextSelect<T extends boolean = true> {
16491676
createdAt?: T;
16501677
globalType?: T;
16511678
}
1679+
/**
1680+
* This interface was referenced by `Config`'s JSON-Schema
1681+
* via the `definition` "InlineBlockWithSelect".
1682+
*/
1683+
export interface InlineBlockWithSelect {
1684+
styles?: ('opt1' | 'opt2')[] | null;
1685+
id?: string | null;
1686+
blockName?: string | null;
1687+
blockType: 'inlineBlockWithSelect';
1688+
}
16521689
/**
16531690
* This interface was referenced by `Config`'s JSON-Schema
16541691
* via the `definition` "LexicalBlocksRadioButtonsBlock".

0 commit comments

Comments
 (0)