Compare commits

...

5 Commits

Author SHA1 Message Date
Jacob Fletcher
10c5a37411 moves tests to int 2025-04-01 09:45:26 -04:00
Jacob Fletcher
b2bd7f2779 further splits tests 2025-04-01 09:45:26 -04:00
Jacob Fletcher
8ee6332a86 test: reads response body and checks for unnecessary re-rendering 2025-04-01 09:45:26 -04:00
Jacob Fletcher
a93bda1040 adds tests 2025-04-01 09:45:26 -04:00
James
106fb0f2de fix(ui): loss of custom components with sequential form state requests 2025-03-31 17:05:23 -04:00
15 changed files with 1194 additions and 123 deletions

View File

@@ -46,8 +46,16 @@ export type FieldState = {
fieldSchema?: Field
filterOptions?: FilterOptionsResult
initialValue?: unknown
/**
* The path of the field when its custom components were last rendered.
* This is used to denote if a field has been rendered, and if so,
* what path it was rendered under last.
*
* If this path is undefined, or, if it is different
* from the current path of a given field, the field's components will be re-rendered.
*/
lastRenderedPath?: string
passesCondition?: boolean
requiresRender?: boolean
rows?: Row[]
valid?: boolean
validate?: Validate

View File

@@ -0,0 +1,140 @@
{
"lockedState": {
"isLocked": true,
"lastEditedAt": "2025-03-31T21:31:01.419Z",
"user": {
"createdAt": "2025-03-31T21:30:11.215Z",
"updatedAt": "2025-03-31T21:30:11.215Z",
"email": "dev@payloadcms.com",
"id": "67eb09635d15fc77b1da4823",
"loginAttempts": 0
}
},
"state": {
"title": { "value": "example post", "initialValue": "example post" },
"renderTracker": {},
"validateUsingEvent": {},
"array.0.id": {
"value": "67eb09903a4c5f0cce051442",
"initialValue": "67eb09903a4c5f0cce051442",
"lastRenderedPath": "array.0.id"
},
"array.0.richText": {},
"array.1.id": {
"value": "67eb09923a4c5f0cce051444",
"initialValue": "67eb09923a4c5f0cce051444"
},
"array.1.richText": {},
"array.2.id": {
"value": "67eb0a7f3a4c5f0cce051446",
"initialValue": "67eb0a7f3a4c5f0cce051446",
"lastRenderedPath": "array.2.id"
},
"array.2.richText": {
"lastRenderedPath": "array.2.richText",
"customComponents": {
"Field": [
"$",
"$L2",
null,
{ "path": "array.2.richText", "children": "$L3" },
null,
[
[
"renderField",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx",
156,
128
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
584,
9
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"arrayValue.reduce.promises",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
125,
107
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
112,
59
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"fieldSchemasToFormState",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/index.tsx",
36,
79
],
[
"async buildFormState",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
130,
29
],
[
"async buildFormStateHandler",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
55,
21
],
[
"async Server.<anonymous>",
"file:///Users/jacobfletcher/dev/payload/payload/test/dev.ts",
1,
2848
]
],
0
]
}
},
"updatedAt": {
"value": "2025-03-31T21:30:11.221Z",
"initialValue": "2025-03-31T21:30:11.221Z"
},
"createdAt": {
"value": "2025-03-31T21:30:11.221Z",
"initialValue": "2025-03-31T21:30:11.221Z"
},
"blocks": { "value": 0, "initialValue": 0, "rows": [] },
"array": {
"rows": [
{ "id": "67eb09903a4c5f0cce051442" },
{ "id": "67eb09923a4c5f0cce051444" },
{ "id": "67eb0a7f3a4c5f0cce051446" }
],
"value": 3,
"initialValue": 3,
"disableFormData": true
}
}
}

View File

@@ -46,7 +46,6 @@ export const uploadValidation = (
const result = await fieldSchemasToFormState({
id,
collectionSlug: node.relationTo,
data: node?.fields ?? {},
documentData: data,
fields: collection.fields,

View File

@@ -53,14 +53,12 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[`${path}.${rowIndex}.id`]: {
initialValue: newRow.id,
passesCondition: true,
requiresRender: true,
valid: true,
value: newRow.id,
},
[path]: {
...state[path],
disableFormData: true,
requiresRender: true,
rows: withNewRow,
value: siblingRows.length,
},
@@ -169,7 +167,6 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[path]: {
...state[path],
disableFormData: true,
requiresRender: true,
rows: rowsMetadata,
value: rows.length,
},
@@ -198,7 +195,6 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
...flattenRows(path, topLevelRows),
[path]: {
...state[path],
requiresRender: true,
rows: rowsWithinField,
},
}
@@ -250,7 +246,6 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[path]: {
...state[path],
disableFormData: rows.length > 0,
requiresRender: true,
rows: rowsMetadata,
value: rows.length,
},

View File

@@ -596,7 +596,7 @@ export const Form: React.FC<FormProps> = (props) => {
const newRows: unknown[] = getDataByPath(path) || []
const rowIndex = rowIndexArg === undefined ? newRows.length : rowIndexArg
// dispatch ADD_ROW that sets requiresRender: true and adds a blank row to local form state.
// dispatch ADD_ROW adds a blank row to local form state.
// This performs no form state request, as the debounced onChange effect will do that for us.
dispatchFields({
type: 'ADD_ROW',

View File

@@ -34,7 +34,7 @@ export const mergeServerFormState = ({
'errorPaths',
'rows',
'customComponents',
'requiresRender',
'lastRenderedPath',
]
if (acceptValues) {

View File

@@ -148,7 +148,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
)
}
const requiresRender = renderAllFields || previousFormState?.[path]?.requiresRender
const lastRenderedPath = previousFormState?.[path]?.lastRenderedPath
const requiresRender = renderAllFields || !lastRenderedPath || lastRenderedPath !== path
let fieldPermissions: SanitizedFieldPermissions = true
@@ -299,7 +300,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldPermissions === true ? fieldPermissions : fieldPermissions?.fields || {},
preferences,
previousFormState,
renderAllFields: requiresRender,
renderAllFields,
renderFieldFn,
req,
select: typeof arraySelect === 'object' ? arraySelect : undefined,
@@ -356,10 +357,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.rows = rows
}
// Unset requiresRender
// so it will be removed from form state
fieldState.requiresRender = false
// Add values to field state
if (data[field.name] !== null) {
fieldState.value = forceFullValue ? arrayValue : arrayValue.length
@@ -483,7 +480,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
: parentPermissions?.[field.name]?.blocks?.[block.slug]?.fields || {},
preferences,
previousFormState,
renderAllFields: requiresRender,
renderAllFields,
renderFieldFn,
req,
select: typeof blockSelect === 'object' ? blockSelect : undefined,
@@ -536,10 +533,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.rows = rowMetadata
// Unset requiresRender
// so it will be removed from form state
fieldState.requiresRender = false
// Add field to state
if (!omitParents && (!filter || filter(args))) {
state[path] = fieldState

View File

@@ -53,6 +53,11 @@ export const renderField: RenderFieldMethod = ({
importMap: req.payload.importMap,
})
/**
* Set the lastRenderedPath equal to the new path of the field
*/
fieldState.lastRenderedPath = path
if (fieldIsHiddenOrDisabled(clientField)) {
return
}

View File

@@ -6,61 +6,6 @@
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji'
| 'America/Monterrey';
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "BlockColumns".
@@ -82,11 +27,6 @@ export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {
ConfigBlockTest: ConfigBlockTest;
localizedTextReference: LocalizedTextReference;
localizedTextReference2: LocalizedTextReference2;
};
collections: {
'lexical-fields': LexicalField;
'lexical-migrate-fields': LexicalMigrateField;
@@ -216,36 +156,6 @@ export interface UserAuthOperations {
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ConfigBlockTest".
*/
export interface ConfigBlockTest {
deduplicatedText?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'ConfigBlockTest';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedTextReference".
*/
export interface LocalizedTextReference {
text?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'localizedTextReference';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedTextReference2".
*/
export interface LocalizedTextReference2 {
text?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'localizedTextReference2';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-fields".
@@ -844,10 +754,10 @@ export interface BlockField {
blockType: 'text';
}[]
| null;
deduplicatedBlocks?: ConfigBlockTest[] | null;
deduplicatedBlocks2?: ConfigBlockTest[] | null;
localizedReferencesLocalizedBlock?: LocalizedTextReference[] | null;
localizedReferences?: LocalizedTextReference2[] | null;
deduplicatedBlocks?: unknown[] | null;
deduplicatedBlocks2?: unknown[] | null;
localizedReferencesLocalizedBlock?: unknown[] | null;
localizedReferences?: unknown[] | null;
/**
* The purpose of this field is to test Block groups.
*/
@@ -1249,16 +1159,166 @@ export interface DateField {
dayAndTime?: string | null;
monthOnly?: string | null;
defaultWithTimezone?: string | null;
defaultWithTimezone_tz?: SupportedTimezones;
defaultWithTimezone_tz?:
| (
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji'
| 'America/Monterrey'
)
| null;
/**
* This date here should be required.
*/
dayAndTimeWithTimezone: string;
dayAndTimeWithTimezone_tz: SupportedTimezones;
dayAndTimeWithTimezone_tz:
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji'
| 'America/Monterrey';
timezoneBlocks?:
| {
dayAndTime?: string | null;
dayAndTime_tz?: SupportedTimezones;
dayAndTime_tz?:
| (
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji'
| 'America/Monterrey'
)
| null;
id?: string | null;
blockName?: string | null;
blockType: 'dateBlock';
@@ -1267,7 +1327,58 @@ export interface DateField {
timezoneArray?:
| {
dayAndTime?: string | null;
dayAndTime_tz?: SupportedTimezones;
dayAndTime_tz?:
| (
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji'
| 'America/Monterrey'
)
| null;
id?: string | null;
}[]
| null;

View File

@@ -1,9 +1,11 @@
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 { assertResponseBody } from 'helpers/e2e/assertResponseBody.js'
import * as path from 'path'
import { fileURLToPath } from 'url'
@@ -180,7 +182,7 @@ test.describe('Form State', () => {
await cdpSession.detach()
})
test('sequentially queued tasks not cause nested custom components to disappear', async () => {
test('should not cause nested custom fields to disappear when adding a row and then editing a field', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await field.fill('Test')
@@ -196,9 +198,9 @@ test.describe('Form State', () => {
postsUrl.create,
async () => {
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-title').fill('Title 2')
// 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,
@@ -223,6 +225,80 @@ test.describe('Form State', () => {
await cdpSession.detach()
})
test('should not cause nested custom fields to disappear when adding rows back-to-back', 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 assertResponseBody<{ state: FormState }>(page, {
action: page.locator('#field-array .array-field__add-row').click(),
url: '/admin/collections/posts/create',
// expect: (body) => body[0]?.args?.formState?.array?.requiresRender === true,
})
// Ensure `requiresRender` is `true` is set for the second request
await assertResponseBody<{ state: FormState }>(page, {
action: page.locator('#field-array .array-field__add-row').click(),
url: '/admin/collections/posts/create',
// 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 assertResponseBody<{ state: FormState }>(page, {
action: page.locator('#field-title').fill('Title 2'),
url: '/admin/collections/posts/create',
// 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> {

View File

@@ -25,7 +25,7 @@ describe('Form State', () => {
// Boilerplate test setup/teardown
// --__--__--__--__--__--__--__--__--__
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname))
;({ payload, restClient } = await initPayloadInt(dirname, undefined, true, false))
const data = await restClient
.POST('/users/login', {
@@ -138,5 +138,163 @@ describe('Form State', () => {
})
})
it.todo('should skip validation if specified')
it('should not unnecessarily re-render custom components when adding a row and then editing a field', async () => {
const req = await createLocalReq({ user }, payload)
const { state: stateWithRow } = await buildFormState({
collectionSlug: postsSlug,
formState: {
array: {
rows: [
{
id: '123',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
// Ensure that row 1 returns with rendered components
expect(stateWithRow['array']?.lastRenderedPath).toStrictEqual('array')
expect(stateWithRow['array.0.richText']?.lastRenderedPath).toStrictEqual('array.0.richText')
expect(stateWithRow['array.0.richText']?.customComponents?.Field).toBeDefined()
const { state: stateWithTitle } = await buildFormState({
collectionSlug: postsSlug,
formState: {
title: {
value: 'Test Post',
initialValue: 'Test Post',
},
array: {
rows: [
{
id: '123',
},
{
id: '456',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
'array.0.richText': {
lastRenderedPath: 'array.0.richText',
},
'array.1.id': {
value: '456',
initialValue: '456',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
// Ensure that row 1 DOES NOT return with rendered components
expect(stateWithTitle['array']?.lastRenderedPath).toStrictEqual('array')
expect(stateWithTitle['array.0.richText']).not.toHaveProperty('lastRenderedPath')
expect(stateWithTitle['array.0.richText']).not.toHaveProperty('customComponents')
})
it('should not unnecessarily re-render custom components when adding rows back-to-back', async () => {
const req = await createLocalReq({ user }, payload)
const { state: stateWith1Row } = await buildFormState({
collectionSlug: postsSlug,
formState: {
array: {
rows: [
{
id: '123',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
// Ensure that row 1 returns rendered components
expect(stateWith1Row['array']?.lastRenderedPath).toStrictEqual('array')
expect(stateWith1Row['array.0.richText']?.lastRenderedPath).toStrictEqual('array.0.richText')
expect(stateWith1Row['array.0.richText']?.customComponents?.Field).toBeDefined()
const { state: stateWith2Rows } = await buildFormState({
collectionSlug: postsSlug,
formState: {
array: {
lastRenderedPath: 'array',
rows: [
{
id: '123',
},
{
id: '456',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
'array.0.richText': {
lastRenderedPath: 'array.0.richText',
},
'array.1.id': {
value: '456',
initialValue: '456',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
// Ensure that row 1 DOES NOT return rendered components
// But row 2 DOES return rendered components
expect(stateWith2Rows['array']?.lastRenderedPath).toStrictEqual('array')
expect(stateWith2Rows['array.0.richText']).not.toHaveProperty('lastRenderedPath')
expect(stateWith2Rows['array.0.richText']).not.toHaveProperty('customComponents')
expect(stateWith2Rows['array.1.richText']?.lastRenderedPath).toStrictEqual('array.1.richText')
expect(stateWith2Rows['array.1.richText']?.customComponents?.Field).toBeDefined()
})
})

466
test/form-state/test2.json Normal file
View File

@@ -0,0 +1,466 @@
{
"lockedState": {
"isLocked": true,
"lastEditedAt": "2025-03-31T21:44:00.115Z",
"user": {
"createdAt": "2025-03-31T21:30:11.215Z",
"updatedAt": "2025-03-31T21:30:11.215Z",
"email": "dev@payloadcms.com",
"id": "67eb09635d15fc77b1da4823",
"loginAttempts": 0,
"collection": "users",
"_strategy": "local-jwt"
}
},
"state": {
"title": {
"value": "example post",
"initialValue": "example post",
"lastRenderedPath": "title"
},
"renderTracker": {
"lastRenderedPath": "renderTracker",
"customComponents": {
"Field": [
"$",
"$L2",
null,
{
"path": "renderTracker",
"children": [
"$",
"$L3",
"field.admin.components.Field",
{
"field": {
"name": "renderTracker",
"type": "text",
"admin": {},
"label": "Render Tracker"
},
"path": "renderTracker",
"permissions": true,
"readOnly": false,
"schemaPath": "posts.renderTracker"
},
null,
[
[
"RenderServerComponent",
"webpack-internal:///(rsc)/./packages/ui/src/elements/RenderServerComponent/index.tsx",
59,
95
],
[
"renderField",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx",
259,
126
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
584,
9
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"fieldSchemasToFormState",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/index.tsx",
36,
79
],
[
"async buildFormState",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
130,
29
],
[
"async buildFormStateHandler",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
55,
21
],
[
"async Server.<anonymous>",
"file:///Users/jacobfletcher/dev/payload/payload/test/dev.ts",
1,
2848
]
],
1
]
},
null,
[
[
"renderField",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx",
257,
128
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
584,
9
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"fieldSchemasToFormState",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/index.tsx",
36,
79
],
[
"async buildFormState",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
130,
29
],
[
"async buildFormStateHandler",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
55,
21
],
[
"async Server.<anonymous>",
"file:///Users/jacobfletcher/dev/payload/payload/test/dev.ts",
1,
2848
]
],
0
]
}
},
"validateUsingEvent": { "lastRenderedPath": "validateUsingEvent" },
"array.0.id": {
"value": "67eb09903a4c5f0cce051442",
"initialValue": "67eb09903a4c5f0cce051442"
},
"array.0.richText": {
"lastRenderedPath": "array.0.richText",
"customComponents": {
"Field": [
"$",
"$L2",
null,
{ "path": "array.0.richText", "children": "$L4" },
null,
[
[
"renderField",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx",
156,
128
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
584,
9
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"arrayValue.reduce.promises",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
125,
107
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
112,
59
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"fieldSchemasToFormState",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/index.tsx",
36,
79
],
[
"async buildFormState",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
130,
29
],
[
"async buildFormStateHandler",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
55,
21
],
[
"async Server.<anonymous>",
"file:///Users/jacobfletcher/dev/payload/payload/test/dev.ts",
1,
2848
]
],
0
]
}
},
"array.1.id": {
"value": "67eb09923a4c5f0cce051444",
"initialValue": "67eb09923a4c5f0cce051444",
"lastRenderedPath": "array.1.id"
},
"array.1.richText": {
"lastRenderedPath": "array.1.richText",
"customComponents": {
"Field": [
"$",
"$L2",
null,
{ "path": "array.1.richText", "children": "$L6" },
null,
[
[
"renderField",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx",
156,
128
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
584,
9
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"arrayValue.reduce.promises",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
125,
107
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
112,
59
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"fieldSchemasToFormState",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/index.tsx",
36,
79
],
[
"async buildFormState",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
130,
29
],
[
"async buildFormStateHandler",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
55,
21
],
[
"async Server.<anonymous>",
"file:///Users/jacobfletcher/dev/payload/payload/test/dev.ts",
1,
2848
]
],
0
]
}
},
"array.2.id": {
"value": "67eb0a7f3a4c5f0cce051446",
"initialValue": "67eb0a7f3a4c5f0cce051446"
},
"array.2.richText": {},
"array.3.id": {
"value": "67eb0c9f3a4c5f0cce051448",
"initialValue": "67eb0c9f3a4c5f0cce051448",
"lastRenderedPath": "array.3.id"
},
"array.3.richText": {
"lastRenderedPath": "array.3.richText",
"customComponents": {
"Field": [
"$",
"$L2",
null,
{ "path": "array.3.richText", "children": "$L8" },
null,
[
[
"renderField",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx",
156,
128
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
584,
9
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"arrayValue.reduce.promises",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
125,
107
],
[
"addFieldStatePromise",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts",
112,
59
],
[
"eval",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
51,
101
],
[
"iterateFields",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts",
15,
12
],
[
"fieldSchemasToFormState",
"webpack-internal:///(rsc)/./packages/ui/src/forms/fieldSchemasToFormState/index.tsx",
36,
79
],
[
"async buildFormState",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
130,
29
],
[
"async buildFormStateHandler",
"webpack-internal:///(rsc)/./packages/ui/src/utilities/buildFormState.ts",
55,
21
],
[
"async Server.<anonymous>",
"file:///Users/jacobfletcher/dev/payload/payload/test/dev.ts",
1,
2848
]
],
0
]
}
},
"updatedAt": {
"value": "2025-03-31T21:30:11.221Z",
"initialValue": "2025-03-31T21:30:11.221Z",
"lastRenderedPath": "updatedAt"
},
"createdAt": {
"value": "2025-03-31T21:30:11.221Z",
"initialValue": "2025-03-31T21:30:11.221Z",
"lastRenderedPath": "createdAt"
},
"blocks": { "value": 0, "initialValue": 0, "rows": [], "lastRenderedPath": "blocks" },
"array": {
"rows": [
{ "id": "67eb09903a4c5f0cce051442" },
{ "id": "67eb09923a4c5f0cce051444" },
{ "id": "67eb0a7f3a4c5f0cce051446" },
{ "id": "67eb0c9f3a4c5f0cce051448" }
],
"value": 4,
"initialValue": 4,
"disableFormData": true,
"lastRenderedPath": "array"
}
}
}

View File

@@ -0,0 +1,44 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
/**
* A helper function to assert the body of a network request.
* This is useful for reading the body of a request and testing whether it is correct.
* For example, if you have a form that submits data to an API, you can use this function to
* assert that the data being sent is correct.
* @param page The Playwright page
* @param options Options
* @param options.action The action to perform that will trigger the request
* @param options.expect A function to run after the request is made to assert the request body
* @returns The request body
* @example
* const requestBody = await assertRequestBody(page, {
* action: page.click('button'),
* expect: (requestBody) => expect(requestBody.foo).toBe('bar')
* })
*/
export const assertRequestBody = async <T>(
page: Page,
options: {
action: Promise<void> | void
expect?: (requestBody: T) => boolean | Promise<boolean>
},
): Promise<T | undefined> => {
const [request] = await Promise.all([
page.waitForRequest((request) => Boolean(request.method())), // Adjust condition as needed
await options.action,
])
const requestBody = request.postData()
if (typeof requestBody === 'string') {
const parsedBody = JSON.parse(requestBody) as T
if (typeof options.expect === 'function') {
expect(await options.expect(parsedBody)).toBeTruthy()
}
return parsedBody
}
}

View File

@@ -0,0 +1,76 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
function parseRSC(rscText: string) {
// Next.js streams use special delimiters like "\n"
const chunks = rscText.split('\n').filter((line) => line.trim() !== '')
// find the chunk starting with '1:', remove the '1:' prefix and parse the rest
const match = chunks.find((chunk) => chunk.startsWith('1:'))
if (match) {
const jsonString = match.slice(2).trim()
if (jsonString) {
try {
return JSON.parse(jsonString)
} catch (err) {
console.error('Failed to parse JSON:', err)
}
}
}
return null
}
/**
* A helper function to assert the response of a network request.
* This is useful for reading the response of a request and testing whether it is correct.
* For example, if you have a form that submits data to an API, you can use this function to
* assert that the data sent back is correct.
* @param page The Playwright page
* @param options Options
* @param options.action The action to perform that will trigger the request
* @param options.expect A function to run after the request is made to assert the response body
* @param options.url The URL to match in the network requests
* @returns The request body
* @example
* const responseBody = await assertResponseBody(page, {
* action: page.click('button'),
* expect: (responseBody) => expect(responseBody.foo).toBe('bar')
* })
*/
export const assertResponseBody = async <T>(
page: Page,
options: {
action: Promise<void> | void
expect?: (requestBody: T) => boolean | Promise<boolean>
url?: string
},
): Promise<T | undefined> => {
const [response] = await Promise.all([
page.waitForResponse((response) => response.url().includes(options.url || '')),
await options.action,
])
if (!response) {
throw new Error('No response received')
}
const responseBody = await response.text()
const responseType = response.headers()['content-type']?.split(';')[0]
let parsedBody: T = undefined as T
if (responseType === 'text/x-component') {
parsedBody = parseRSC(responseBody)
} else if (typeof responseBody === 'string') {
parsedBody = JSON.parse(responseBody) as T
}
if (typeof options.expect === 'function') {
expect(await options.expect(parsedBody)).toBeTruthy()
}
return parsedBody
}

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/query-presets/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"],