test: optimistic form state rows (#12055)

Adds tests for #11961.
This commit is contained in:
Jacob Fletcher
2025-04-08 22:56:24 -04:00
committed by GitHub
parent 97e2e77ff4
commit bd557a97d5
5 changed files with 156 additions and 7 deletions

View File

@@ -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')

View File

@@ -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,
},
],
},
})
})
})

View File

@@ -0,0 +1,26 @@
import type { Page } from 'playwright'
export async function assertElementStaysVisible(
page: Page,
selector: string,
durationMs: number = 3000,
pollIntervalMs: number = 250,
): Promise<void> {
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.`)
}

View File

@@ -21,7 +21,7 @@ import { expect } from '@playwright/test'
export const assertRequestBody = async <T>(
page: Page,
options: {
action: Promise<void> | void
action: () => Promise<void> | void
expect?: (requestBody: T) => boolean | Promise<boolean>
requestMethod?: string
url: string
@@ -34,7 +34,7 @@ export const assertRequestBody = async <T>(
(request.method() === options.requestMethod || 'POST'),
),
),
await options.action,
await options.action(),
])
const requestBody = request.postData()

View File

@@ -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"],