diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts index ac5a233533..cccfccef54 100644 --- a/test/form-state/e2e.spec.ts +++ b/test/form-state/e2e.spec.ts @@ -4,6 +4,7 @@ import type { FormState } from 'payload' import { expect, test } from '@playwright/test' import { addBlock } from 'helpers/e2e/addBlock.js' +import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible.js' import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js' import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js' import * as path from 'path' @@ -154,7 +155,7 @@ test.describe('Form State', () => { // The `array` itself SHOULD have a `lastRenderedPath` because it was rendered on initial load await assertRequestBody<{ args: { formState: FormState } }[]>(page, { - action: await page.locator('#field-array .array-field__add-row').click(), + action: async () => await page.locator('#field-array .array-field__add-row').click(), url: postsUrl.create, expect: (body) => Boolean( @@ -172,7 +173,7 @@ test.describe('Form State', () => { // The `array` itself SHOULD still have a `lastRenderedPath` // The rich text field in the first row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the first request await assertRequestBody<{ args: { formState: FormState } }[]>(page, { - action: await page.locator('#field-array .array-field__add-row').click(), + action: async () => await page.locator('#field-array .array-field__add-row').click(), url: postsUrl.create, expect: (body) => Boolean( @@ -193,7 +194,7 @@ test.describe('Form State', () => { // The rich text field in the first row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the first request // The rich text field in the second row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the second request await assertRequestBody<{ args: { formState: FormState } }[]>(page, { - action: await page.locator('#field-array .array-field__add-row').click(), + action: async () => await page.locator('#field-array .array-field__add-row').click(), url: postsUrl.create, expect: (body) => Boolean( @@ -241,6 +242,77 @@ test.describe('Form State', () => { await cdpSession.detach() }) + test('optimistic rows should not disappear between pending network requests', async () => { + await page.goto(postsUrl.create) + const field = page.locator('#field-title') + await field.fill('Test') + + // only throttle test after initial load to avoid timeouts + const cdpSession = await throttleTest({ + page, + context, + delay: 'Slow 3G', + }) + + let requestCount = 0 + + // increment the response count for form state requests + page.on('request', (request) => { + if (request.url() === postsUrl.create && request.method() === 'POST') { + requestCount++ + } + }) + + // Add the first row and expect an optimistic loading state + await page.locator('#field-array .array-field__add-row').click() + await expect(page.locator('#field-array #array-row-0')).toBeVisible() + // use waitForSelector because the shimmer effect is not always visible + // eslint-disable-next-line playwright/no-wait-for-selector + await page.waitForSelector('#field-array #array-row-0 .shimmer-effect') + + // Wait for the first request to be sent + await page.waitForRequest((request) => request.url() === postsUrl.create) + + // Before the first request comes back, add the second row and expect an optimistic loading state + await page.locator('#field-array .array-field__add-row').click() + await expect(page.locator('#field-array #array-row-1')).toBeVisible() + // use waitForSelector because the shimmer effect is not always visible + // eslint-disable-next-line playwright/no-wait-for-selector + await page.waitForSelector('#field-array #array-row-0 .shimmer-effect') + + // At this point there should have been a single request sent for the first row + expect(requestCount).toBe(1) + + // Wait for the first request to finish + await page.waitForResponse( + (response) => + response.url() === postsUrl.create && + response.status() === 200 && + response.headers()['content-type'] === 'text/x-component', + ) + + // block the second request from executing to ensure the form remains in stale state + await page.route(postsUrl.create, async (route) => { + if (route.request().method() === 'POST' && route.request().url() === postsUrl.create) { + await route.abort() + return + } + + await route.continue() + }) + + await assertElementStaysVisible(page, '#field-array #array-row-1') + + await cdpSession.send('Network.emulateNetworkConditions', { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + + await cdpSession.detach() + }) + test('should not cause nested custom components to disappear when adding a row then editing a field', async () => { await page.goto(postsUrl.create) const field = page.locator('#field-title') diff --git a/test/form-state/int.spec.ts b/test/form-state/int.spec.ts index 1deb4ff902..1ffb189d75 100644 --- a/test/form-state/int.spec.ts +++ b/test/form-state/int.spec.ts @@ -1,4 +1,4 @@ -import type { Payload, User } from 'payload' +import type { FormState, Payload, User } from 'payload' import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' import path from 'path' @@ -10,6 +10,8 @@ import type { NextRESTClient } from '../helpers/NextRESTClient.js' import { devUser } from '../credentials.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { postsSlug } from './collections/Posts/index.js' +// eslint-disable-next-line payload/no-relative-monorepo-imports +import { mergeServerFormState } from '../../packages/ui/src/forms/Form/mergeServerFormState.js' let payload: Payload let token: string @@ -217,4 +219,53 @@ describe('Form State', () => { expect(stateWithTitle?.['array.1.richText']).toHaveProperty('customComponents') expect(stateWithTitle?.['array.1.richText']?.customComponents?.Field).toBeDefined() }) + + it('should merge array rows without losing current state', () => { + const currentState: FormState = { + array: { + rows: [ + { + id: '1', + }, + { + id: '2', + isLoading: true, + }, + ], + }, + } + + const incomingState: FormState = { + array: { + rows: [ + { + id: '1', + lastRenderedPath: 'array.0.text', + }, + ], + }, + } + + const { newState } = mergeServerFormState({ + existingState: currentState, + incomingState, + }) + + expect(newState).toStrictEqual({ + array: { + passesCondition: true, + valid: true, + rows: [ + { + id: '1', + lastRenderedPath: 'array.0.text', + }, + { + id: '2', + isLoading: true, + }, + ], + }, + }) + }) }) diff --git a/test/helpers/e2e/assertElementStaysVisible.ts b/test/helpers/e2e/assertElementStaysVisible.ts new file mode 100644 index 0000000000..ba7f6ef68e --- /dev/null +++ b/test/helpers/e2e/assertElementStaysVisible.ts @@ -0,0 +1,26 @@ +import type { Page } from 'playwright' + +export async function assertElementStaysVisible( + page: Page, + selector: string, + durationMs: number = 3000, + pollIntervalMs: number = 250, +): Promise { + const start = Date.now() + + // Ensure it appears at least once first + await page.waitForSelector(selector, { state: 'visible' }) + + // Start polling to confirm it stays visible + while (Date.now() - start < durationMs) { + const isVisible = await page.isVisible(selector) + + if (!isVisible) { + throw new Error(`Element "${selector}" disappeared during the visibility duration.`) + } + + await new Promise((res) => setTimeout(res, pollIntervalMs)) + } + + console.log(`Element "${selector}" remained visible for ${durationMs}ms.`) +} diff --git a/test/helpers/e2e/assertRequestBody.ts b/test/helpers/e2e/assertRequestBody.ts index a2e46c3c07..3cd2eadde0 100644 --- a/test/helpers/e2e/assertRequestBody.ts +++ b/test/helpers/e2e/assertRequestBody.ts @@ -21,7 +21,7 @@ import { expect } from '@playwright/test' export const assertRequestBody = async ( page: Page, options: { - action: Promise | void + action: () => Promise | void expect?: (requestBody: T) => boolean | Promise requestMethod?: string url: string @@ -34,7 +34,7 @@ export const assertRequestBody = async ( (request.method() === options.requestMethod || 'POST'), ), ), - await options.action, + await options.action(), ]) const requestBody = request.postData() diff --git a/tsconfig.base.json b/tsconfig.base.json index c9793d25c6..e2b64e3bc8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], + "@payload-config": ["./test/form-state/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],