Skip to content

Commit 10ac989

Browse files
authored
fix(ui): nested custom components sometimes disappear when queued in form state (#11867)
When rendering custom fields nested within arrays or blocks, such as the Lexical rich text editor which is treated as a custom field, these fields will sometimes disappear when form state requests are invoked sequentially. This is especially reproducible on slow networks. This is because form state invocations are placed into a [task queue](#11579) which aborts the currently running tasks when a new one arrives. By doing this, local form state is never dispatched, and the second task in the queue becomes stale. The fix is to _not_ abort the currently running task. This will trigger a complete rendering cycle, and when the second task is invoked, local state will be up to date. Fixes #11340, #11425, and #11824.
1 parent 35e6cfb commit 10ac989

File tree

5 files changed

+113
-30
lines changed

5 files changed

+113
-30
lines changed

packages/ui/src/elements/Autosave/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
130130
queueRef.current = []
131131

132132
setBackgroundProcessing(true)
133+
133134
try {
134135
await latestAction()
135136
} finally {

packages/ui/src/forms/Form/index.tsx

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -720,53 +720,51 @@ export const Form: React.FC<FormProps> = (props) => {
720720

721721
const classes = [className, baseClass].filter(Boolean).join(' ')
722722

723-
const executeOnChange = useEffectEvent(async (submitted: boolean, signal: AbortSignal) => {
724-
if (Array.isArray(onChange)) {
725-
let revalidatedFormState: FormState = contextRef.current.fields
723+
const executeOnChange = useEffectEvent((submitted: boolean) => {
724+
queueTask(async () => {
725+
if (Array.isArray(onChange)) {
726+
let revalidatedFormState: FormState = contextRef.current.fields
727+
728+
for (const onChangeFn of onChange) {
729+
// Edit view default onChange is in packages/ui/src/views/Edit/index.tsx. This onChange usually sends a form state request
730+
revalidatedFormState = await onChangeFn({
731+
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
732+
submitted,
733+
})
734+
}
726735

727-
for (const onChangeFn of onChange) {
728-
if (signal.aborted) {
736+
if (!revalidatedFormState) {
729737
return
730738
}
731739

732-
// Edit view default onChange is in packages/ui/src/views/Edit/index.tsx. This onChange usually sends a form state request
733-
revalidatedFormState = await onChangeFn({
734-
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
735-
submitted,
740+
const { changed, newState } = mergeServerFormState({
741+
existingState: contextRef.current.fields || {},
742+
incomingState: revalidatedFormState,
736743
})
737-
}
738-
739-
if (!revalidatedFormState) {
740-
return
741-
}
742744

743-
const { changed, newState } = mergeServerFormState({
744-
existingState: contextRef.current.fields || {},
745-
incomingState: revalidatedFormState,
746-
})
747-
748-
if (changed && !signal.aborted) {
749-
prevFields.current = newState
745+
if (changed) {
746+
prevFields.current = newState
750747

751-
dispatchFields({
752-
type: 'REPLACE_STATE',
753-
optimize: false,
754-
state: newState,
755-
})
748+
dispatchFields({
749+
type: 'REPLACE_STATE',
750+
optimize: false,
751+
state: newState,
752+
})
753+
}
756754
}
757-
}
755+
})
758756
})
759757

760758
useDebouncedEffect(
761759
() => {
762760
if ((isFirstRenderRef.current || !dequal(fields, prevFields.current)) && modified) {
763-
queueTask(async (signal) => executeOnChange(submitted, signal))
761+
executeOnChange(submitted)
764762
}
765763

766764
prevFields.current = fields
767765
isFirstRenderRef.current = false
768766
},
769-
[modified, submitted, fields, queueTask],
767+
[modified, submitted, fields],
770768
250,
771769
)
772770

test/form-state/collections/Posts/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { CollectionConfig } from 'payload'
22

3+
import { lexicalEditor } from '@payloadcms/richtext-lexical'
4+
35
export const postsSlug = 'posts'
46

57
export const PostsCollection: CollectionConfig = {
@@ -64,5 +66,16 @@ export const PostsCollection: CollectionConfig = {
6466
},
6567
],
6668
},
69+
{
70+
name: 'array',
71+
type: 'array',
72+
fields: [
73+
{
74+
name: 'richText',
75+
type: 'richText',
76+
editor: lexicalEditor(),
77+
},
78+
],
79+
},
6780
],
6881
}

test/form-state/e2e.spec.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '../helpers.js'
1818
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
1919
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
20-
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
20+
import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
2121

2222
const filename = fileURLToPath(import.meta.url)
2323
const dirname = path.dirname(filename)
@@ -179,6 +179,50 @@ test.describe('Form State', () => {
179179

180180
await cdpSession.detach()
181181
})
182+
183+
test('sequentially queued tasks not cause nested custom components to disappear', async () => {
184+
await page.goto(postsUrl.create)
185+
const field = page.locator('#field-title')
186+
await field.fill('Test')
187+
188+
const cdpSession = await throttleTest({
189+
page,
190+
context,
191+
delay: 'Slow 3G',
192+
})
193+
194+
await assertNetworkRequests(
195+
page,
196+
postsUrl.create,
197+
async () => {
198+
await page.locator('#field-array .array-field__add-row').click()
199+
200+
await page.locator('#field-title').fill('Title 2')
201+
202+
// eslint-disable-next-line playwright/no-wait-for-selector
203+
await page.waitForSelector('#field-array #array-row-0 .field-type.rich-text-lexical', {
204+
timeout: TEST_TIMEOUT,
205+
})
206+
207+
await expect(
208+
page.locator('#field-array #array-row-0 .field-type.rich-text-lexical'),
209+
).toBeVisible()
210+
},
211+
{
212+
allowedNumberOfRequests: 2,
213+
timeout: 10000,
214+
},
215+
)
216+
217+
await cdpSession.send('Network.emulateNetworkConditions', {
218+
offline: false,
219+
latency: 0,
220+
downloadThroughput: -1,
221+
uploadThroughput: -1,
222+
})
223+
224+
await cdpSession.detach()
225+
})
182226
})
183227

184228
async function createPost(overrides?: Partial<Post>): Promise<Post> {

test/form-state/payload-types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export type SupportedTimezones =
5454
| 'Asia/Singapore'
5555
| 'Asia/Tokyo'
5656
| 'Asia/Seoul'
57+
| 'Australia/Brisbane'
5758
| 'Australia/Sydney'
5859
| 'Pacific/Guam'
5960
| 'Pacific/Noumea'
@@ -140,6 +141,26 @@ export interface Post {
140141
}
141142
)[]
142143
| null;
144+
array?:
145+
| {
146+
richText?: {
147+
root: {
148+
type: string;
149+
children: {
150+
type: string;
151+
version: number;
152+
[k: string]: unknown;
153+
}[];
154+
direction: ('ltr' | 'rtl') | null;
155+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
156+
indent: number;
157+
version: number;
158+
};
159+
[k: string]: unknown;
160+
} | null;
161+
id?: string | null;
162+
}[]
163+
| null;
143164
updatedAt: string;
144165
createdAt: string;
145166
}
@@ -243,6 +264,12 @@ export interface PostsSelect<T extends boolean = true> {
243264
blockName?: T;
244265
};
245266
};
267+
array?:
268+
| T
269+
| {
270+
richText?: T;
271+
id?: T;
272+
};
246273
updatedAt?: T;
247274
createdAt?: T;
248275
}

0 commit comments

Comments
 (0)