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:
@@ -130,6 +130,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
|
||||
queueRef.current = []
|
||||
|
||||
setBackgroundProcessing(true)
|
||||
|
||||
try {
|
||||
await latestAction()
|
||||
} finally {
|
||||
|
||||
@@ -720,53 +720,51 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
|
||||
const classes = [className, baseClass].filter(Boolean).join(' ')
|
||||
|
||||
const executeOnChange = useEffectEvent(async (submitted: boolean, signal: AbortSignal) => {
|
||||
if (Array.isArray(onChange)) {
|
||||
let revalidatedFormState: FormState = contextRef.current.fields
|
||||
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) {
|
||||
for (const onChangeFn of onChange) {
|
||||
// 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),
|
||||
submitted,
|
||||
})
|
||||
}
|
||||
|
||||
if (!revalidatedFormState) {
|
||||
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),
|
||||
submitted,
|
||||
const { changed, newState } = mergeServerFormState({
|
||||
existingState: contextRef.current.fields || {},
|
||||
incomingState: revalidatedFormState,
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
prevFields.current = newState
|
||||
|
||||
dispatchFields({
|
||||
type: 'REPLACE_STATE',
|
||||
optimize: false,
|
||||
state: newState,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!revalidatedFormState) {
|
||||
return
|
||||
}
|
||||
|
||||
const { changed, newState } = mergeServerFormState({
|
||||
existingState: contextRef.current.fields || {},
|
||||
incomingState: revalidatedFormState,
|
||||
})
|
||||
|
||||
if (changed && !signal.aborted) {
|
||||
prevFields.current = newState
|
||||
|
||||
dispatchFields({
|
||||
type: 'REPLACE_STATE',
|
||||
optimize: false,
|
||||
state: newState,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user