diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index ab1d02b08..73f42f4a8 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -130,6 +130,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) queueRef.current = [] setBackgroundProcessing(true) + try { await latestAction() } finally { diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 309d256f7..5470ea5a4 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -720,53 +720,51 @@ export const Form: React.FC = (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, ) diff --git a/test/form-state/collections/Posts/index.ts b/test/form-state/collections/Posts/index.ts index af136f6a5..c82c47b41 100644 --- a/test/form-state/collections/Posts/index.ts +++ b/test/form-state/collections/Posts/index.ts @@ -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(), + }, + ], + }, ], } diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts index 1c0b54185..72b9ea243 100644 --- a/test/form-state/e2e.spec.ts +++ b/test/form-state/e2e.spec.ts @@ -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): Promise { diff --git a/test/form-state/payload-types.ts b/test/form-state/payload-types.ts index eb31820ce..219762136 100644 --- a/test/form-state/payload-types.ts +++ b/test/form-state/payload-types.ts @@ -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 { blockName?: T; }; }; + array?: + | T + | { + richText?: T; + id?: T; + }; updatedAt?: T; createdAt?: T; }