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>
156 lines
5.3 KiB
TypeScript
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 }
|
|
}
|