fix(ui): stale paths on custom components within rows (#11973)

When server rendering custom components within form state, those
components receive a path that is correct at render time, but
potentially stale after manipulating array and blocks rows. This causes
the field to briefly render incorrect values while the form state
request is in flight.

The reason for this is that paths are passed as a prop statically into
those components. Then when we manipulate rows, form state is modified,
potentially changing field paths. The component's `path` prop, however,
hasn't changed. This means it temporarily points to the wrong field in
form state, rendering the data of another row until the server responds
with a freshly rendered component.

This is not an issue with default Payload fields as they are rendered on
the client and can be passed dynamic props.

This is only an issue within custom server components, including rich
text fields which are treated as custom components. Since they are
rendered on the server and passed to the client, props are inaccessible
after render.

The fix for this is to provide paths dynamically through context. This
way as we make changes to form state, there is a mechanism in which
server components can receive the updated path without waiting on its
props to update.
This commit is contained in:
Jacob Fletcher
2025-04-15 15:23:51 -04:00
committed by GitHub
parent e90ff72b37
commit 21599b87f5
40 changed files with 211 additions and 106 deletions

View File

@@ -57,7 +57,10 @@ describe('Field Error States', () => {
// add third child array
await page.locator('#parentArray-row-0 .collapsible__content .array-field__add-row').click()
// remove the row
await page.locator('#parentArray-0-childArray-row-2 .array-actions__button').click()
await page
.locator('#parentArray-0-childArray-row-2 .array-actions__action.array-actions__remove')
.click()
@@ -68,6 +71,7 @@ describe('Field Error States', () => {
'#parentArray-row-0 > .collapsible > .collapsible__toggle-wrap .array-field__row-error-pill',
{ state: 'hidden', timeout: 500 },
)
expect(errorPill).toBeNull()
})
@@ -77,13 +81,11 @@ describe('Field Error States', () => {
await saveDocAndAssert(page, '#action-save-draft')
})
// eslint-disable-next-line playwright/expect-expect
test('should validate drafts when enabled', async () => {
await page.goto(validateDraftsOn.create)
await saveDocAndAssert(page, '#action-save-draft', 'error')
})
// eslint-disable-next-line playwright/expect-expect
test('should show validation errors when validate and autosave are enabled', async () => {
await page.goto(validateDraftsOnAutosave.create)
await page.locator('#field-title').fill('valid')

View File

@@ -4,12 +4,12 @@ import * as React from 'react'
import { collection1Slug } from '../slugs.js'
export const PrePopulateFieldUI: React.FC<{
export const PopulateFieldButton: React.FC<{
hasMany?: boolean
hasMultipleRelations?: boolean
path?: string
targetFieldPath: string
}> = ({ hasMany = true, hasMultipleRelations = false, path, targetFieldPath }) => {
}> = ({ hasMany = true, hasMultipleRelations = false, targetFieldPath }) => {
const { setValue } = useField({ path: targetFieldPath })
const addDefaults = React.useCallback(() => {

View File

@@ -19,7 +19,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: {
hasMany: false,
hasMultipleRelations: false,
@@ -50,7 +50,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: {
hasMultipleRelations: false,
targetFieldPath: 'relationHasMany',
@@ -80,7 +80,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: {
components: {
Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI',
path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: {
hasMultipleRelations: true,
targetFieldPath: 'relationToManyHasMany',

View File

@@ -83,6 +83,10 @@ export const PostsCollection: CollectionConfig = {
},
},
},
{
name: 'defaultTextField',
type: 'text',
},
],
},
],

View File

@@ -213,7 +213,37 @@ test.describe('Form State', () => {
})
})
test('new rows should contain default values', async () => {
test('should not render stale values for server components while form state is in flight', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-array #array-row-0 #field-array__0__customTextField').fill('1')
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-array #array-row-1 #field-array__1__customTextField').fill('2')
// block the next form state request from firing to ensure the field remains in stale state
await page.route(postsUrl.create, async (route) => {
if (route.request().method() === 'POST' && route.request().url() === postsUrl.create) {
await route.abort()
}
await route.continue()
})
// remove the first row
await page.locator('#field-array #array-row-0 .array-actions__button').click()
await page
.locator('#field-array #array-row-0 .array-actions__action.array-actions__remove')
.click()
await expect(
page.locator('#field-array #array-row-0 #field-array__0__customTextField'),
).toHaveValue('2')
})
test('should queue onChange functions', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-array .array-field__add-row').click()
await expect(

View File

@@ -144,6 +144,7 @@ export interface Post {
array?:
| {
customTextField?: string | null;
defaultTextField?: string | null;
id?: string | null;
}[]
| null;
@@ -254,6 +255,7 @@ export interface PostsSelect<T extends boolean = true> {
| T
| {
customTextField?: T;
defaultTextField?: T;
id?: T;
};
updatedAt?: T;