Files
payload/packages/ui/src/forms/Form/fieldReducer.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

458 lines
13 KiB
TypeScript

'use client'
import type { FormField, FormState, Row } from 'payload'
import ObjectIdImport from 'bson-objectid'
import { dequal } from 'dequal/lite' // lite: no need for Map and Set support
import { deepCopyObjectSimple, deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared'
import type { FieldAction } from './types.js'
import { flattenRows, separateRows } from './rows.js'
const ObjectId = (ObjectIdImport.default ||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
/**
* Reducer which modifies the form field state (all the current data of the fields in the form). When called using dispatch, it will return a new state object.
*/
export function fieldReducer(state: FormState, action: FieldAction): FormState {
switch (action.type) {
case 'ADD_ROW': {
const { blockType, path, rowIndex: rowIndexFromArgs, subFieldState = {} } = action
const rowIndex =
typeof rowIndexFromArgs === 'number' ? rowIndexFromArgs : state[path]?.rows?.length || 0
const withNewRow = [...(state[path]?.rows || [])]
const newRow: Row = {
id: (subFieldState?.id?.value as string) || new ObjectId().toHexString(),
blockType: blockType || undefined,
collapsed: false,
isLoading: true,
}
withNewRow.splice(rowIndex, 0, newRow)
if (blockType) {
subFieldState.blockType = {
initialValue: blockType,
valid: true,
value: blockType,
}
}
// add new row to array _field state_
const { remainingFields, rows: siblingRows } = separateRows(path, state)
siblingRows.splice(rowIndex, 0, subFieldState)
const newState: FormState = {
...remainingFields,
...flattenRows(path, siblingRows),
[`${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,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
},
}
return newState
}
case 'ADD_SERVER_ERRORS': {
let newState = { ...state }
const errorPaths: { fieldErrorPath: string; parentPath: string }[] = []
action.errors.forEach(({ message, path: fieldPath }) => {
newState[fieldPath] = {
...(newState[fieldPath] || {
initialValue: null,
value: null,
}),
errorMessage: message,
valid: false,
}
const segments = fieldPath.split('.')
if (segments.length > 1) {
errorPaths.push({
fieldErrorPath: fieldPath,
parentPath: segments.slice(0, segments.length - 1).join('.'),
})
}
})
newState = Object.entries(newState).reduce((acc, [path, fieldState]) => {
const fieldErrorPaths = errorPaths.reduce((errorACC, { fieldErrorPath, parentPath }) => {
if (parentPath.startsWith(path)) {
errorACC.push(fieldErrorPath)
}
return errorACC
}, [])
let changed = false
if (fieldErrorPaths.length > 0) {
const newErrorPaths = Array.isArray(fieldState.errorPaths) ? fieldState.errorPaths : []
fieldErrorPaths.forEach((fieldErrorPath) => {
if (!newErrorPaths.includes(fieldErrorPath)) {
newErrorPaths.push(fieldErrorPath)
changed = true
}
})
if (changed) {
acc[path] = {
...fieldState,
errorPaths: newErrorPaths,
}
}
}
if (!changed) {
acc[path] = fieldState
}
return acc
}, {})
return newState
}
case 'DUPLICATE_ROW': {
const { path, rowIndex } = action
const { remainingFields, rows } = separateRows(path, state)
const rowsMetadata = [...(state[path].rows || [])]
const duplicateRowMetadata = deepCopyObjectSimple(rowsMetadata[rowIndex])
if (duplicateRowMetadata.id) {
duplicateRowMetadata.id = new ObjectId().toHexString()
}
const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex])
if (duplicateRowState.id) {
duplicateRowState.id.value = new ObjectId().toHexString()
duplicateRowState.id.initialValue = new ObjectId().toHexString()
}
for (const key of Object.keys(duplicateRowState).filter((key) => key.endsWith('.id'))) {
const idState = duplicateRowState[key]
if (idState && typeof idState.value === 'string' && ObjectId.isValid(idState.value)) {
duplicateRowState[key].value = new ObjectId().toHexString()
duplicateRowState[key].initialValue = new ObjectId().toHexString()
}
}
// If there are subfields
if (Object.keys(duplicateRowState).length > 0) {
// Add new object containing subfield names to unflattenedRows array
rows.splice(rowIndex + 1, 0, duplicateRowState)
rowsMetadata.splice(rowIndex + 1, 0, duplicateRowMetadata)
}
const newState = {
...remainingFields,
...flattenRows(path, rows),
[path]: {
...state[path],
disableFormData: true,
requiresRender: true,
rows: rowsMetadata,
value: rows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || ([] as any)),
},
}
return newState
}
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action
// Handle moving rows on the top-level, i.e. `array.0.text` -> `array.1.text`
const { remainingFields, rows: topLevelRows } = separateRows(path, state)
const copyOfMovingRow = topLevelRows[moveFromIndex]
topLevelRows.splice(moveFromIndex, 1)
topLevelRows.splice(moveToIndex, 0, copyOfMovingRow)
// modify array/block internal row state (i.e. collapsed, blockType)
const rowsWithinField = [...(state[path]?.rows || [])]
const copyOfMovingRow2 = { ...rowsWithinField[moveFromIndex] }
rowsWithinField.splice(moveFromIndex, 1)
rowsWithinField.splice(moveToIndex, 0, copyOfMovingRow2)
const newState = {
...remainingFields,
...flattenRows(path, topLevelRows),
[path]: {
...state[path],
requiresRender: true,
rows: rowsWithinField,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || ([] as any)),
},
}
// Do the same for custom components, i.e. `array.customComponents.RowLabels[0]` -> `array.customComponents.RowLabels[1]`
// Do this _after_ initializing `newState` to avoid adding the `customComponents` key to the state if it doesn't exist
if (newState[path]?.customComponents?.RowLabels) {
const customComponents = {
...newState[path].customComponents,
RowLabels: [...newState[path].customComponents.RowLabels],
}
// Ensure the array grows if necessary
if (moveToIndex >= customComponents.RowLabels.length) {
customComponents.RowLabels.length = moveToIndex + 1
}
const copyOfMovingLabel = customComponents.RowLabels[moveFromIndex]
customComponents.RowLabels.splice(moveFromIndex, 1)
customComponents.RowLabels.splice(moveToIndex, 0, copyOfMovingLabel)
newState[path].customComponents = customComponents
}
return newState
}
case 'REMOVE': {
const newState = { ...state }
if (newState[action.path]) {
delete newState[action.path]
}
return newState
}
case 'REMOVE_ROW': {
const { path, rowIndex } = action
const { remainingFields, rows } = separateRows(path, state)
const rowsMetadata = [...(state[path]?.rows || [])]
rows.splice(rowIndex, 1)
rowsMetadata.splice(rowIndex, 1)
const newState: FormState = {
...remainingFields,
[path]: {
...state[path],
disableFormData: rows.length > 0,
requiresRender: true,
rows: rowsMetadata,
value: rows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
},
...flattenRows(path, rows),
}
return newState
}
case 'REPLACE_ROW': {
const { blockType, path, rowIndex: rowIndexArg, subFieldState = {} } = action
const { remainingFields, rows: siblingRows } = separateRows(path, state)
const rowIndex = Math.max(0, Math.min(rowIndexArg, siblingRows?.length - 1 || 0))
const rowsMetadata = [...(state[path]?.rows || [])]
rowsMetadata[rowIndex] = {
id: new ObjectId().toHexString(),
blockType: blockType || undefined,
collapsed: false,
}
if (blockType) {
subFieldState.blockType = {
initialValue: blockType,
valid: true,
value: blockType,
}
}
// replace form _field state_
siblingRows[rowIndex] = subFieldState
const newState: FormState = {
...remainingFields,
...flattenRows(path, siblingRows),
[path]: {
...state[path],
disableFormData: true,
rows: rowsMetadata,
value: siblingRows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
},
}
return newState
}
case 'REPLACE_STATE': {
if (action.optimize !== false) {
// Only update fields that have changed
// by comparing old value / initialValue to new
// ..
// This is a performance enhancement for saving
// large documents with hundreds of fields
const newState: FormState = {}
for (const [path, newField] of Object.entries(action.state)) {
const oldField = state[path]
if (newField.valid !== false) {
newField.valid = true
}
if (newField.passesCondition !== false) {
newField.passesCondition = true
}
if (!dequal(oldField, newField)) {
newState[path] = newField
} else if (oldField) {
newState[path] = oldField
}
}
return newState
}
// 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) {
field.valid = true
}
if (field.passesCondition !== false) {
field.passesCondition = true
}
}
}
// If we're not optimizing, just set the state to the new state
return action.state
}
case 'SET_ALL_ROWS_COLLAPSED': {
const { path, updatedRows } = action
return {
...state,
[path]: {
...state[path],
rows: updatedRows,
},
}
}
case 'SET_ROW_COLLAPSED': {
const { path, updatedRows } = action
const newState = {
...state,
[path]: {
...state[path],
rows: updatedRows,
},
}
return newState
}
case 'UPDATE': {
const newField = Object.entries(action).reduce(
(field, [key, value]) => {
if (
[
'disableFormData',
'errorMessage',
'initialValue',
'rows',
'valid',
'validate',
'value',
].includes(key)
) {
return {
...field,
[key]: value,
}
}
return field
},
state?.[action.path] || ({} as FormField),
)
const newState = {
...state,
[action.path]: newField,
}
return newState
}
case 'UPDATE_MANY': {
const newState = { ...state }
Object.entries(action.formState).forEach(([path, field]) => {
newState[path] = field
})
return newState
}
default: {
return state
}
}
}