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`
321 lines
10 KiB
TypeScript
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>
|
|
}
|