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](https://github.com/payloadcms/payload/pull/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.
This commit is contained in:
Jacob Fletcher
2025-03-25 20:40:16 -04:00
committed by GitHub
parent 35e6cfbdfc
commit 10ac9893ad
5 changed files with 115 additions and 32 deletions

View File

@@ -130,6 +130,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
queueRef.current = []
setBackgroundProcessing(true)
try {
await latestAction()
} finally {

View File

@@ -720,15 +720,12 @@ export const Form: React.FC<FormProps> = (props) => {
const classes = [className, baseClass].filter(Boolean).join(' ')
const executeOnChange = useEffectEvent(async (submitted: boolean, signal: AbortSignal) => {
const executeOnChange = useEffectEvent((submitted: boolean) => {
queueTask(async () => {
if (Array.isArray(onChange)) {
let revalidatedFormState: FormState = contextRef.current.fields
for (const onChangeFn of onChange) {
if (signal.aborted) {
return
}
// Edit view default onChange is in packages/ui/src/views/Edit/index.tsx. This onChange usually sends a form state request
revalidatedFormState = await onChangeFn({
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
@@ -745,7 +742,7 @@ export const Form: React.FC<FormProps> = (props) => {
incomingState: revalidatedFormState,
})
if (changed && !signal.aborted) {
if (changed) {
prevFields.current = newState
dispatchFields({
@@ -756,17 +753,18 @@ export const Form: React.FC<FormProps> = (props) => {
}
}
})
})
useDebouncedEffect(
() => {
if ((isFirstRenderRef.current || !dequal(fields, prevFields.current)) && modified) {
queueTask(async (signal) => executeOnChange(submitted, signal))
executeOnChange(submitted)
}
prevFields.current = fields
isFirstRenderRef.current = false
},
[modified, submitted, fields, queueTask],
[modified, submitted, fields],
250,
)

View File

@@ -1,5 +1,7 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
@@ -64,5 +66,16 @@ export const PostsCollection: CollectionConfig = {
},
],
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
},
],
},
],
}

View File

@@ -17,7 +17,7 @@ import {
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -179,6 +179,50 @@ test.describe('Form State', () => {
await cdpSession.detach()
})
test('sequentially queued tasks not cause nested custom components to disappear', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await field.fill('Test')
const cdpSession = await throttleTest({
page,
context,
delay: 'Slow 3G',
})
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-title').fill('Title 2')
// eslint-disable-next-line playwright/no-wait-for-selector
await page.waitForSelector('#field-array #array-row-0 .field-type.rich-text-lexical', {
timeout: TEST_TIMEOUT,
})
await expect(
page.locator('#field-array #array-row-0 .field-type.rich-text-lexical'),
).toBeVisible()
},
{
allowedNumberOfRequests: 2,
timeout: 10000,
},
)
await cdpSession.send('Network.emulateNetworkConditions', {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
await cdpSession.detach()
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {

View File

@@ -54,6 +54,7 @@ export type SupportedTimezones =
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
@@ -140,6 +141,26 @@ export interface Post {
}
)[]
| null;
array?:
| {
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -243,6 +264,12 @@ export interface PostsSelect<T extends boolean = true> {
blockName?: T;
};
};
array?:
| T
| {
richText?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}