Files
payload/test/form-state/e2e.spec.ts
Jacob Fletcher 373f6d1032 fix(ui): nested fields disappear when manipulating rows in form state (#11906)
Continuation of #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 different from the previous PR in that this issue is caused by
adding _rows_ back-to-back, whereas the previous issue was caused when
adding a single row followed by a change to another field.

Here's a screen recording demonstrating the issue:


https://github.com/user-attachments/assets/5ecfa9ec-b747-49ed-8618-df282e64519d

The problem is that `requiresRender` is never sent in the form state
request for row 2. This is because the [task
queue](https://github.com/payloadcms/payload/pull/11579) processes tasks
within a single `useEffect`. This forces React to batch the results of
these tasks into a single rendering cycle. So if request 1 sets state
that request 2 relies on, request 2 will never use that state since
they'll execute within the same lifecycle.

Here's a play-by-play of the current behavior:

1. The "add row" event is dispatched
    a. This sets `requiresRender: true` in form state
1. A form state request is sent with `requiresRender: true`
1. While that request is processing, another "add row" event is
dispatched
    a. This sets `requiresRender: true` in form state
    b. This adds a form state request into the queue
1. The initial form state request finishes
    a. This sets `requiresRender: false` in form state
1. The next form state request that was queued up in 3b is sent with
`requiresRender: false`
    a. THIS IS EXPECTED, BUT SHOULD ACTUALLY BE `true`!!

To fix this this, we need to ensure that the `requiresRender` property
is persisted into the second request instead of overridden. To do this,
we can add a new `serverPropsToIgnore` to form state which is read when
the processing results from the server. So if `requiresRender` exists in
`serverPropsToIgnore`, we do not merge it. This works because we
actually mutate form state in between requests. So request 2 can read
the results from request 1 without going through an additional rendering
cycle.

Here's a play-by-play of the fix:

1. The "add row" event is dispatched
    a. This sets `requiresRender: true` in form state
b. This adds a task in the queue to mutate form state with
`requiresRender: true`
1. A form state request is sent with `requiresRender: true`
1. While that request is processing, another "add row" event is
dispatched
a. This sets `requiresRender: true` in form state AND
`serverPropsToIgnore: [ "requiresRender" ]`
    c. This adds a form state request into the queue
1. The initial form state request finishes
a. This returns `requiresRender: false` from the form state endpoint BUT
IS IGNORED
1. The next form state request that was queued up in 3c is sent with
`requiresRender: true`
2025-04-01 09:54:22 -04:00

321 lines
10 KiB
TypeScript

import type { BrowserContext, Page } from '@playwright/test'
import type { PayloadTestSDK } from 'helpers/sdk/index.js'
import type { FormState } from 'payload'
import { expect, test } from '@playwright/test'
import { addBlock } from 'helpers/e2e/addBlock.js'
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
import type { Config, Post } from './payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const title = 'Title'
let context: BrowserContext
let payload: PayloadTestSDK<Config>
let serverURL: string
test.describe('Form State', () => {
let page: Page
let postsUrl: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, 'posts')
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
test.beforeEach(async () => {
// await throttleTest({ page, context, delay: 'Fast 3G' })
})
test('should disable fields during initialization', async () => {
await page.goto(postsUrl.create, { waitUntil: 'commit' })
await expect(page.locator('#field-title')).toBeDisabled()
})
test('should disable fields while processing', async () => {
const doc = await createPost()
await page.goto(postsUrl.edit(doc.id))
await page.locator('#field-title').fill(title)
await page.click('#action-save', { delay: 100 })
await expect(page.locator('#field-title')).toBeDisabled()
})
test('should re-enable fields after save', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toBeEnabled()
})
test('should only validate on submit via the `event` argument', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await page.locator('#field-validateUsingEvent').fill('Not allowed')
await saveDocAndAssert(page, '#action-save', 'error')
})
test('should fire a single network request for onChange events when manipulating blocks', async () => {
await page.goto(postsUrl.create)
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
await addBlock({
page,
blockLabel: 'Text',
fieldName: 'blocks',
})
},
{
allowedNumberOfRequests: 1,
},
)
})
test('should not throw fields into an infinite rendering loop', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
let numberOfRenders = 0
page.on('console', (msg) => {
if (msg.type() === 'count' && msg.text().includes('Renders')) {
numberOfRenders++
}
})
const allowedNumberOfRenders = 25
const pollInterval = 200
const maxTime = 5000
let elapsedTime = 0
const intervalId = setInterval(() => {
if (numberOfRenders > allowedNumberOfRenders) {
clearInterval(intervalId)
throw new Error(`Render count exceeded the threshold of ${allowedNumberOfRenders}`)
}
elapsedTime += pollInterval
if (elapsedTime >= maxTime) {
clearInterval(intervalId)
}
}, pollInterval)
await page.waitForTimeout(maxTime)
expect(numberOfRenders).toBeLessThanOrEqual(allowedNumberOfRenders)
})
test('should debounce onChange events', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
// Need to type _faster_ than the debounce rate (250ms)
await field.pressSequentially('Some text to type', { delay: 50 })
},
{
allowedNumberOfRequests: 1,
},
)
})
test('should queue onChange functions', 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',
})
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
await field.fill('')
// Need to type into a _slower_ than the debounce rate (250ms), but _faster_ than the network request
await field.pressSequentially('Some text to type', { delay: 275 })
},
{
allowedNumberOfRequests: 2,
timeout: 10000, // watch network for 10 seconds to allow requests to build up
},
)
await cdpSession.send('Network.emulateNetworkConditions', {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
await cdpSession.detach()
})
test('should not cause nested custom fields to disappear when queuing form state (1)', 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',
})
// Add a row and immediately type into another field
// Test that the rich text field within the row does not disappear
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
// Ensure `requiresRender` is `true` is set for the first request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: page.locator('#field-array .array-field__add-row').click(),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === true,
})
// Ensure `requiresRender` is `false` for the second request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: page.locator('#field-title').fill('Title 2'),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === false,
})
// use `waitForSelector` to ensure the element doesn't appear and then disappear
// 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()
})
test('should not cause nested custom fields to disappear when queuing form state (2)', 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',
})
// Add two rows quickly
// Test that the rich text fields within the rows do not disappear
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
// Ensure `requiresRender` is `true` is set for the first request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: page.locator('#field-array .array-field__add-row').click(),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === true,
})
// Ensure `requiresRender` is `true` is set for the second request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: page.locator('#field-array .array-field__add-row').click(),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === true,
})
// use `waitForSelector` to ensure the element doesn't appear and then disappear
// 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,
})
// use `waitForSelector` to ensure the element doesn't appear and then disappear
// eslint-disable-next-line playwright/no-wait-for-selector
await page.waitForSelector('#field-array #array-row-1 .field-type.rich-text-lexical', {
timeout: TEST_TIMEOUT,
})
await expect(
page.locator('#field-array #array-row-0 .field-type.rich-text-lexical'),
).toBeVisible()
await expect(
page.locator('#field-array #array-row-1 .field-type.rich-text-lexical'),
).toBeVisible()
},
{
allowedNumberOfRequests: 2,
timeout: 10000,
},
)
// Ensure `requiresRender` is `false` for the third request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: page.locator('#field-title').fill('Title 2'),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === false,
})
await cdpSession.send('Network.emulateNetworkConditions', {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
await cdpSession.detach()
})
})
async function createPost(overrides?: Partial<Post>): Promise<Post> {
return payload.create({
collection: 'posts',
data: {
title: 'Post Title',
...overrides,
},
}) as unknown as Promise<Post>
}