Files
payload/packages/ui/src/forms/Form/mergeServerFormState.ts
Jacob Fletcher e87521a376 perf(ui): significantly optimize form state component rendering, up to 96% smaller and 75% faster (#11946)
Significantly optimizes the component rendering strategy within the form
state endpoint by precisely rendering only the fields that require it.
This cuts down on server processing and network response sizes when
invoking form state requests **that manipulate array and block rows
which contain server components**, such as rich text fields, custom row
labels, etc. (results listed below).

Here's a breakdown of the issue:

Previously, when manipulating array and block fields, _all_ rows would
render any server components that might exist within them, including
rich text fields. This means that subsequent changes to these fields
would potentially _re-render_ those same components even if they don't
require it.

For example, if you have an array field with a rich text field within
it, adding the first row would cause the rich text field to render,
which is expected. However, when you add a second row, the rich text
field within the first row would render again unnecessarily along with
the new row.

This is especially noticeable for fields with many rows, where every
single row processes its server components and returns RSC data. And
this does not only affect nested rich text fields, but any custom
component defined on the field level, as these are handled in the same
way.

The reason this was necessary in the first place was to ensure that the
server components receive the proper data when they are rendered, such
as the row index and the row's data. Changing one of these rows could
cause the server component to receive the wrong data if it was not
freshly rendered.

While this is still a requirement that rows receive up-to-date props, it
is no longer necessary to render everything.

Here's a breakdown of the actual fix:

This change ensures that only the fields that are actually being
manipulated will be rendered, rather than all rows. The existing rows
will remain in memory on the client, while the newly rendered components
will return from the server. For example, if you add a new row to an
array field, only the new row will render its server components.

To do this, we send the path of the field that is being manipulated to
the server. The server can then use this path to determine for itself
which fields have already been rendered and which ones need required
rendering.

## Results

The following results were gathered by booting up the `form-state` test
suite and seeding 100 array rows, each containing a rich text field. To
invoke a form state request, we navigate to a document within the
"posts" collection, then add a new array row to the list. The result is
then saved to the file system for comparison.

| Test Suite | Collection | Number of Rows | Before | After | Percentage
Change |
|------|------|---------|--------|--------|--------|
| `form-state` | `posts` | 101 | 1.9MB / 266ms | 80KB / 70ms | ~96%
smaller / ~75% faster |

---------

Co-authored-by: James <james@trbl.design>
Co-authored-by: Alessio Gravili <alessio@gravili.de>
2025-04-03 12:27:14 -04:00

156 lines
5.3 KiB
TypeScript

'use client'
import type { FieldState, FormState } from 'payload'
import { dequal } from 'dequal/lite' // lite: no need for Map and Set support
import { mergeErrorPaths } from './mergeErrorPaths.js'
type Args = {
acceptValues?: boolean
existingState: FormState
incomingState: FormState
}
/**
* Merges certain properties from the server state into the client state. These do not include values,
* as we do not want to update them on the client like that, which would cause flickering.
*
* We want to use this to update the error state, and other properties that are not user input, as the error state
* is the thing we want to keep in sync with the server (where it's calculated) on the client.
*/
export const mergeServerFormState = ({
acceptValues,
existingState,
incomingState,
}: Args): { changed: boolean; newState: FormState } => {
let changed = false
const newState = {}
if (existingState) {
const serverPropsToAccept: Array<keyof FieldState> = [
'passesCondition',
'valid',
'errorMessage',
'errorPaths',
'customComponents',
]
if (acceptValues) {
serverPropsToAccept.push('value')
serverPropsToAccept.push('initialValue')
}
for (const [path, newFieldState] of Object.entries(existingState)) {
if (!incomingState[path]) {
continue
}
let fieldChanged = false
/**
* Handle error paths
*/
const errorPathsResult = mergeErrorPaths(
newFieldState.errorPaths,
incomingState[path].errorPaths as unknown as string[],
)
if (errorPathsResult.result) {
if (errorPathsResult.changed) {
changed = errorPathsResult.changed
}
newFieldState.errorPaths = errorPathsResult.result
}
/**
* Handle filterOptions
*/
if (incomingState[path]?.filterOptions || newFieldState.filterOptions) {
if (!dequal(incomingState[path]?.filterOptions, newFieldState.filterOptions)) {
changed = true
fieldChanged = true
newFieldState.filterOptions = incomingState[path].filterOptions
}
}
/**
* Need to intelligently merge the rows array to ensure changes to local state are not lost 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
*/
serverPropsToAccept.forEach((propFromServer) => {
if (!dequal(incomingState[path]?.[propFromServer], newFieldState[propFromServer])) {
changed = true
fieldChanged = true
if (newFieldState?.serverPropsToIgnore?.includes(propFromServer)) {
// Remove the ignored prop for the next request
newFieldState.serverPropsToIgnore = newFieldState.serverPropsToIgnore.filter(
(prop) => prop !== propFromServer,
)
// if no keys left, remove the entire object
if (!newFieldState.serverPropsToIgnore.length) {
delete newFieldState.serverPropsToIgnore
}
return
}
if (!(propFromServer in incomingState[path])) {
// Regarding excluding the customComponents prop from being deleted: the incoming state might not have been rendered, as rendering components for every form onchange is expensive.
// Thus, we simply re-use the initial render state
if (propFromServer !== 'customComponents') {
delete newFieldState[propFromServer]
}
} else {
newFieldState[propFromServer as any] = incomingState[path][propFromServer]
}
}
})
if (newFieldState.valid !== false) {
newFieldState.valid = true
}
if (newFieldState.passesCondition !== false) {
newFieldState.passesCondition = true
}
// Conditions don't work if we don't memcopy the new state, as the object references would otherwise be the same
newState[path] = fieldChanged ? { ...newFieldState } : newFieldState
}
// Now loop over values that are part of incoming state but not part of existing state, and add them to the new state.
// This can happen if a new array row was added. In our local state, we simply add out stubbed `array` and `array.[index].id` entries to the local form state.
// However, all other array sub-fields are not added to the local state - those will be added by the server and may be incoming here.
for (const [path, field] of Object.entries(incomingState)) {
if (!existingState[path]) {
changed = true
newState[path] = field
}
}
}
return { changed, newState }
}