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`
This commit is contained in:
Jacob Fletcher
2025-04-01 09:54:22 -04:00
committed by GitHub
parent 329cd0b876
commit 373f6d1032
13 changed files with 253 additions and 42 deletions

View File

@@ -63,6 +63,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
requiresRender: true,
rows: withNewRow,
value: siblingRows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
},
}
@@ -172,6 +180,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
requiresRender: true,
rows: rowsMetadata,
value: rows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || ([] as any)),
},
}
@@ -200,6 +216,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
...state[path],
requiresRender: true,
rows: rowsWithinField,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || ([] as any)),
},
}
@@ -218,9 +242,8 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
const copyOfMovingLabel = customComponents.RowLabels[moveFromIndex]
// eslint-disable-next-line @typescript-eslint/no-floating-promises
customComponents.RowLabels.splice(moveFromIndex, 1)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
customComponents.RowLabels.splice(moveToIndex, 0, copyOfMovingLabel)
newState[path].customComponents = customComponents
@@ -253,6 +276,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
requiresRender: true,
rows: rowsMetadata,
value: rows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
},
...flattenRows(path, rows),
}
@@ -292,6 +323,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
disableFormData: true,
rows: rowsMetadata,
value: siblingRows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
},
}
@@ -327,7 +366,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
return newState
}
//TODO: Remove this in 4.0 - this is a temporary fix to prevent a breaking change
// TODO: Remove this in 4.0 - this is a temporary fix to prevent a breaking change
if (action.sanitize) {
for (const field of Object.values(action.state)) {
if (field.valid !== false) {