fix(ui): optimistic rows disappear while form state requests are pending (#11961)

When manipulating array and blocks rows on slow networks, rows can
sometimes disappear and then reappear as requests in the queue arrive.

Consider this scenario:

1. You add a row to form state: this pushes the row in local state
optimistically then triggers a long-running form state request
containing a single row
2. You add another row to form state: this pushes a second row into
local state optimistically then triggers another long-running form state
request containing two rows
3. The first form state request returns with a single row in the
response and replaces local state (which contained two rows)
4. AT THIS MOMENT IN TIME, THE SECOND ROW DISAPPEARS
5. The second form state request returns with two rows in the response
and replaces local state
6. THE UI IS NO LONGER STALE AND BOTH ROWS APPEAR AS EXPECTED

The same issue applies when deleting, moving, and duplicating rows.
Local state becomes out of sync with the form state response and is
ultimately overridden.

The issue is that when we merge the result from form state, we do not
traverse the rows themselves, and instead take the rows in their
entirety. This means that we lose local row state. Instead, we need to
compare the results with what is saved to local state and intelligently
merge them.
This commit is contained in:
Jacob Fletcher
2025-04-03 12:23:14 -04:00
committed by GitHub
parent 018bdad247
commit 8880d705e3
2 changed files with 23 additions and 3 deletions

View File

@@ -1,8 +1,7 @@
'use client'
import type { FieldState } from 'payload'
import type { FieldState, FormState } from 'payload'
import { dequal } from 'dequal/lite' // lite: no need for Map and Set support
import { type FormState } from 'payload'
import { mergeErrorPaths } from './mergeErrorPaths.js'
@@ -34,7 +33,6 @@ export const mergeServerFormState = ({
'valid',
'errorMessage',
'errorPaths',
'rows',
'customComponents',
'requiresRender',
]
@@ -77,6 +75,26 @@ export const mergeServerFormState = ({
}
}
/**
* Need to intelligently merge the rows array to ensure no rows are lost or added while the request was pending
* For example, the server response could come back with a row which has been deleted on the client
* Loop over the incoming rows, if it exists in client side form state, merge in any new properties from the server
*/
if (Array.isArray(incomingState[path].rows)) {
incomingState[path].rows.forEach((row) => {
const matchedExistingRowIndex = newFieldState.rows.findIndex(
(existingRow) => existingRow.id === row.id,
)
if (matchedExistingRowIndex > -1) {
newFieldState.rows[matchedExistingRowIndex] = {
...newFieldState.rows[matchedExistingRowIndex],
...row,
}
}
})
}
/**
* Handle adding all the remaining props that should be updated in the local form state from the server form state
*/

View File

@@ -316,6 +316,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
acc.rows.push({
id: row.id,
isLoading: false,
})
const previousRows = previousFormState?.[path]?.rows || []
@@ -495,6 +496,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
acc.rowMetadata.push({
id: row.id,
blockType: row.blockType,
isLoading: false,
})
const collapsedRowIDs = preferences?.fields?.[path]?.collapsed