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>
This commit is contained in:
Jacob Fletcher
2025-04-03 12:27:14 -04:00
committed by GitHub
parent 8880d705e3
commit e87521a376
26 changed files with 585 additions and 295 deletions

View File

@@ -61,7 +61,7 @@ export const Posts: CollectionConfig = {
The following options are available: The following options are available:
| Option | Description | | Option | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). | | `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). | | `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). | | `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |

View File

@@ -13,8 +13,12 @@ export type Data = {
export type Row = { export type Row = {
blockType?: string blockType?: string
collapsed?: boolean collapsed?: boolean
customComponents?: {
RowLabel?: React.ReactNode
}
id: string id: string
isLoading?: boolean isLoading?: boolean
lastRenderedPath?: string
} }
export type FilterOptionsResult = { export type FilterOptionsResult = {
@@ -34,7 +38,6 @@ export type FieldState = {
Error?: React.ReactNode Error?: React.ReactNode
Field?: React.ReactNode Field?: React.ReactNode
Label?: React.ReactNode Label?: React.ReactNode
RowLabels?: React.ReactNode[]
} }
disableFormData?: boolean disableFormData?: boolean
errorMessage?: string errorMessage?: string
@@ -46,8 +49,16 @@ export type FieldState = {
fieldSchema?: Field fieldSchema?: Field
filterOptions?: FilterOptionsResult filterOptions?: FilterOptionsResult
initialValue?: unknown initialValue?: unknown
/**
* The path of the field when its custom components were last rendered.
* This is used to denote if a field has been rendered, and if so,
* what path it was rendered under last.
*
* If this path is undefined, or, if it is different
* from the current path of a given field, the field's components will be re-rendered.
*/
lastRenderedPath?: string
passesCondition?: boolean passesCondition?: boolean
requiresRender?: boolean
rows?: Row[] rows?: Row[]
/** /**
* The `serverPropsToIgnore` obj is used to prevent the various properties from being overridden across form state requests. * The `serverPropsToIgnore` obj is used to prevent the various properties from being overridden across form state requests.
@@ -95,6 +106,13 @@ export type BuildFormStateArgs = {
*/ */
language?: keyof SupportedLanguages language?: keyof SupportedLanguages
locale?: string locale?: string
/**
* If true, will not render RSCs and instead return a simple string in their place.
* This is useful for environments that lack RSC support, such as Jest.
* Form state can still be built, but any server components will be omitted.
* @default false
*/
mockRSCs?: boolean
operation?: 'create' | 'update' operation?: 'create' | 'update'
/* /*
If true, will render field components within their state object If true, will render field components within their state object

View File

@@ -46,7 +46,6 @@ export const uploadValidation = (
const result = await fieldSchemasToFormState({ const result = await fieldSchemasToFormState({
id, id,
collectionSlug: node.relationTo, collectionSlug: node.relationTo,
data: node?.fields ?? {}, data: node?.fields ?? {},
documentData: data, documentData: data,
fields: collection.fields, fields: collection.fields,

View File

@@ -901,12 +901,12 @@ export {
HTMLConverterFeature, HTMLConverterFeature,
type HTMLConverterFeatureProps, type HTMLConverterFeatureProps,
} from './features/converters/lexicalToHtml_deprecated/index.js' } from './features/converters/lexicalToHtml_deprecated/index.js'
export { DebugJsxConverterFeature } from './features/debug/jsxConverter/server/index.js'
export { convertLexicalToMarkdown } from './features/converters/lexicalToMarkdown/index.js' export { convertLexicalToMarkdown } from './features/converters/lexicalToMarkdown/index.js'
export { convertMarkdownToLexical } from './features/converters/markdownToLexical/index.js' export { convertMarkdownToLexical } from './features/converters/markdownToLexical/index.js'
export { getPayloadPopulateFn } from './features/converters/utilities/payloadPopulateFn.js' export { getPayloadPopulateFn } from './features/converters/utilities/payloadPopulateFn.js'
export { getRestPopulateFn } from './features/converters/utilities/restPopulateFn.js' export { getRestPopulateFn } from './features/converters/utilities/restPopulateFn.js'
export { DebugJsxConverterFeature } from './features/debug/jsxConverter/server/index.js'
export { TestRecorderFeature } from './features/debug/testRecorder/server/index.js' export { TestRecorderFeature } from './features/debug/testRecorder/server/index.js'
export { TreeViewFeature } from './features/debug/treeView/server/index.js' export { TreeViewFeature } from './features/debug/treeView/server/index.js'
export { EXPERIMENTAL_TableFeature } from './features/experimental_table/server/index.js' export { EXPERIMENTAL_TableFeature } from './features/experimental_table/server/index.js'

View File

@@ -110,10 +110,10 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
) )
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
errorPaths, errorPaths,
rows: rowsData = [], rows = [],
showError, showError,
valid, valid,
value, value,
@@ -173,12 +173,12 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
(collapsed: boolean) => { (collapsed: boolean) => {
const { collapsedIDs, updatedRows } = toggleAllRows({ const { collapsedIDs, updatedRows } = toggleAllRows({
collapsed, collapsed,
rows: rowsData, rows,
}) })
setDocFieldPreferences(path, { collapsed: collapsedIDs }) setDocFieldPreferences(path, { collapsed: collapsedIDs })
dispatchFields({ type: 'SET_ALL_ROWS_COLLAPSED', path, updatedRows }) dispatchFields({ type: 'SET_ALL_ROWS_COLLAPSED', path, updatedRows })
}, },
[dispatchFields, path, rowsData, setDocFieldPreferences], [dispatchFields, path, rows, setDocFieldPreferences],
) )
const setCollapse = useCallback( const setCollapse = useCallback(
@@ -186,22 +186,22 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
const { collapsedIDs, updatedRows } = extractRowsAndCollapsedIDs({ const { collapsedIDs, updatedRows } = extractRowsAndCollapsedIDs({
collapsed, collapsed,
rowID, rowID,
rows: rowsData, rows,
}) })
dispatchFields({ type: 'SET_ROW_COLLAPSED', path, updatedRows }) dispatchFields({ type: 'SET_ROW_COLLAPSED', path, updatedRows })
setDocFieldPreferences(path, { collapsed: collapsedIDs }) setDocFieldPreferences(path, { collapsed: collapsedIDs })
}, },
[dispatchFields, path, rowsData, setDocFieldPreferences], [dispatchFields, path, rows, setDocFieldPreferences],
) )
const hasMaxRows = maxRows && rowsData.length >= maxRows const hasMaxRows = maxRows && rows.length >= maxRows
const fieldErrorCount = errorPaths.length const fieldErrorCount = errorPaths.length
const fieldHasErrors = submitted && errorPaths.length > 0 const fieldHasErrors = submitted && errorPaths.length > 0
const showRequired = (readOnly || disabled) && rowsData.length === 0 const showRequired = (readOnly || disabled) && rows.length === 0
const showMinRows = rowsData.length < minRows || (required && rowsData.length === 0) const showMinRows = rows.length < minRows || (required && rows.length === 0)
return ( return (
<div <div
@@ -242,7 +242,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
<ErrorPill count={fieldErrorCount} i18n={i18n} withMessage /> <ErrorPill count={fieldErrorCount} i18n={i18n} withMessage />
)} )}
</div> </div>
{rowsData?.length > 0 && ( {rows?.length > 0 && (
<ul className={`${baseClass}__header-actions`}> <ul className={`${baseClass}__header-actions`}>
<li> <li>
<button <button
@@ -272,13 +272,13 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
</header> </header>
<NullifyLocaleField fieldValue={value} localized={localized} path={path} /> <NullifyLocaleField fieldValue={value} localized={localized} path={path} />
{BeforeInput} {BeforeInput}
{(rowsData?.length > 0 || (!valid && (showRequired || showMinRows))) && ( {(rows?.length > 0 || (!valid && (showRequired || showMinRows))) && (
<DraggableSortable <DraggableSortable
className={`${baseClass}__draggable-rows`} className={`${baseClass}__draggable-rows`}
ids={rowsData.map((row) => row.id)} ids={rows.map((row) => row.id)}
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)} onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
> >
{rowsData.map((rowData, i) => { {rows.map((rowData, i) => {
const { id: rowID, isLoading } = rowData const { id: rowID, isLoading } = rowData
const rowPath = `${path}.${i}` const rowPath = `${path}.${i}`
@@ -297,7 +297,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
<ArrayRow <ArrayRow
{...draggableSortableItemProps} {...draggableSortableItemProps}
addRow={addRow} addRow={addRow}
CustomRowLabel={RowLabels?.[i]} CustomRowLabel={rows?.[i]?.customComponents?.RowLabel}
duplicateRow={duplicateRow} duplicateRow={duplicateRow}
errorCount={rowErrorCount} errorCount={rowErrorCount}
fields={fields} fields={fields}
@@ -313,7 +313,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
readOnly={readOnly || disabled} readOnly={readOnly || disabled}
removeRow={removeRow} removeRow={removeRow}
row={rowData} row={rowData}
rowCount={rowsData?.length} rowCount={rows?.length}
rowIndex={i} rowIndex={i}
schemaPath={schemaPath} schemaPath={schemaPath}
setCollapse={setCollapse} setCollapse={setCollapse}

View File

@@ -98,7 +98,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
) )
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
errorPaths, errorPaths,
rows = [], rows = [],
@@ -293,7 +293,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
hasMaxRows={hasMaxRows} hasMaxRows={hasMaxRows}
isLoading={isLoading} isLoading={isLoading}
isSortable={isSortable} isSortable={isSortable}
Label={RowLabels?.[i]} Label={rows?.[i]?.customComponents?.RowLabel}
labels={labels} labels={labels}
moveRow={moveRow} moveRow={moveRow}
parentPath={path} parentPath={path}

View File

@@ -53,24 +53,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[`${path}.${rowIndex}.id`]: { [`${path}.${rowIndex}.id`]: {
initialValue: newRow.id, initialValue: newRow.id,
passesCondition: true, passesCondition: true,
requiresRender: true,
valid: true, valid: true,
value: newRow.id, value: newRow.id,
}, },
[path]: { [path]: {
...state[path], ...state[path],
disableFormData: true, disableFormData: true,
requiresRender: true,
rows: withNewRow, rows: withNewRow,
value: siblingRows.length, value: siblingRows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
}, },
} }
@@ -144,12 +134,16 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
const { remainingFields, rows } = separateRows(path, state) const { remainingFields, rows } = separateRows(path, state)
const rowsMetadata = [...(state[path].rows || [])] const rowsMetadata = [...(state[path].rows || [])]
const duplicateRowMetadata = deepCopyObjectSimple(rowsMetadata[rowIndex]) const duplicateRowMetadata = deepCopyObjectSimpleWithoutReactComponents(
rowsMetadata[rowIndex],
)
if (duplicateRowMetadata.id) { if (duplicateRowMetadata.id) {
duplicateRowMetadata.id = new ObjectId().toHexString() duplicateRowMetadata.id = new ObjectId().toHexString()
} }
const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex]) const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex])
if (duplicateRowState.id) { if (duplicateRowState.id) {
duplicateRowState.id.value = new ObjectId().toHexString() duplicateRowState.id.value = new ObjectId().toHexString()
duplicateRowState.id.initialValue = new ObjectId().toHexString() duplicateRowState.id.initialValue = new ObjectId().toHexString()
@@ -177,17 +171,8 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[path]: { [path]: {
...state[path], ...state[path],
disableFormData: true, disableFormData: true,
requiresRender: true,
rows: rowsMetadata, rows: rowsMetadata,
value: rows.length, value: rows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || ([] as any)),
}, },
} }
@@ -214,41 +199,10 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
...flattenRows(path, topLevelRows), ...flattenRows(path, topLevelRows),
[path]: { [path]: {
...state[path], ...state[path],
requiresRender: true,
rows: rowsWithinField, 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 return newState
} }
@@ -273,17 +227,8 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[path]: { [path]: {
...state[path], ...state[path],
disableFormData: rows.length > 0, disableFormData: rows.length > 0,
requiresRender: true,
rows: rowsMetadata, rows: rowsMetadata,
value: rows.length, value: rows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
}, },
...flattenRows(path, rows), ...flattenRows(path, rows),
} }
@@ -323,14 +268,6 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
disableFormData: true, disableFormData: true,
rows: rowsMetadata, rows: rowsMetadata,
value: siblingRows.length, value: siblingRows.length,
...(state[path]?.requiresRender === true
? {
serverPropsToIgnore: [
...(state[path]?.serverPropsToIgnore || []),
'requiresRender',
],
}
: state[path]?.serverPropsToIgnore || []),
}, },
} }

View File

@@ -596,7 +596,7 @@ export const Form: React.FC<FormProps> = (props) => {
const newRows: unknown[] = getDataByPath(path) || [] const newRows: unknown[] = getDataByPath(path) || []
const rowIndex = rowIndexArg === undefined ? newRows.length : rowIndexArg const rowIndex = rowIndexArg === undefined ? newRows.length : rowIndexArg
// dispatch ADD_ROW that sets requiresRender: true and adds a blank row to local form state. // dispatch ADD_ROW adds a blank row to local form state.
// This performs no form state request, as the debounced onChange effect will do that for us. // This performs no form state request, as the debounced onChange effect will do that for us.
dispatchFields({ dispatchFields({
type: 'ADD_ROW', type: 'ADD_ROW',

View File

@@ -34,7 +34,6 @@ export const mergeServerFormState = ({
'errorMessage', 'errorMessage',
'errorPaths', 'errorPaths',
'customComponents', 'customComponents',
'requiresRender',
] ]
if (acceptValues) { if (acceptValues) {
@@ -76,7 +75,7 @@ export const mergeServerFormState = ({
} }
/** /**
* Need to intelligently merge the rows array to ensure no rows are lost or added while the request was pending * 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 * 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 * Loop over the incoming rows, if it exists in client side form state, merge in any new properties from the server
*/ */

View File

@@ -1,4 +1,5 @@
import type { import type {
BuildFormStateArgs,
ClientFieldSchemaMap, ClientFieldSchemaMap,
Data, Data,
DocumentPreferences, DocumentPreferences,
@@ -9,6 +10,7 @@ import type {
FormState, FormState,
FormStateWithoutComponents, FormStateWithoutComponents,
PayloadRequest, PayloadRequest,
Row,
SanitizedFieldPermissions, SanitizedFieldPermissions,
SanitizedFieldsPermissions, SanitizedFieldsPermissions,
SelectMode, SelectMode,
@@ -68,6 +70,7 @@ export type AddFieldStatePromiseArgs = {
*/ */
includeSchema?: boolean includeSchema?: boolean
indexPath: string indexPath: string
mockRSCs?: BuildFormStateArgs['mockRSCs']
/** /**
* Whether to omit parent fields in the state. @default false * Whether to omit parent fields in the state. @default false
*/ */
@@ -122,6 +125,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fullData, fullData,
includeSchema = false, includeSchema = false,
indexPath, indexPath,
mockRSCs,
omitParents = false, omitParents = false,
operation, operation,
parentPath, parentPath,
@@ -148,12 +152,16 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
) )
} }
const requiresRender = renderAllFields || previousFormState?.[path]?.requiresRender const lastRenderedPath = previousFormState?.[path]?.lastRenderedPath
let fieldPermissions: SanitizedFieldPermissions = true let fieldPermissions: SanitizedFieldPermissions = true
const fieldState: FieldState = {} const fieldState: FieldState = {}
if (lastRenderedPath) {
fieldState.lastRenderedPath = lastRenderedPath
}
if (passesCondition === false) { if (passesCondition === false) {
fieldState.passesCondition = false fieldState.passesCondition = false
} }
@@ -289,6 +297,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
forceFullValue, forceFullValue,
fullData, fullData,
includeSchema, includeSchema,
mockRSCs,
omitParents, omitParents,
operation, operation,
parentIndexPath: '', parentIndexPath: '',
@@ -299,7 +308,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldPermissions === true ? fieldPermissions : fieldPermissions?.fields || {}, fieldPermissions === true ? fieldPermissions : fieldPermissions?.fields || {},
preferences, preferences,
previousFormState, previousFormState,
renderAllFields: requiresRender, renderAllFields,
renderFieldFn, renderFieldFn,
req, req,
select: typeof arraySelect === 'object' ? arraySelect : undefined, select: typeof arraySelect === 'object' ? arraySelect : undefined,
@@ -314,17 +323,25 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
acc.rows = [] acc.rows = []
} }
acc.rows.push({ const previousRows = previousFormState?.[path]?.rows || []
// First, check if `previousFormState` has a matching row
const previousRow: Row = previousRows.find((prevRow) => prevRow.id === row.id)
const newRow: Row = {
id: row.id, id: row.id,
isLoading: false, isLoading: false,
}) }
if (previousRow?.lastRenderedPath) {
newRow.lastRenderedPath = previousRow.lastRenderedPath
}
acc.rows.push(newRow)
const previousRows = previousFormState?.[path]?.rows || []
const collapsedRowIDsFromPrefs = preferences?.fields?.[path]?.collapsed const collapsedRowIDsFromPrefs = preferences?.fields?.[path]?.collapsed
const collapsed = (() => { const collapsed = (() => {
// First, check if `previousFormState` has a matching row
const previousRow = previousRows.find((prevRow) => prevRow.id === row.id)
if (previousRow) { if (previousRow) {
return previousRow.collapsed ?? false return previousRow.collapsed ?? false
} }
@@ -357,8 +374,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.rows = rows fieldState.rows = rows
} }
fieldState.requiresRender = false
// Add values to field state // Add values to field state
if (data[field.name] !== null) { if (data[field.name] !== null) {
fieldState.value = forceFullValue ? arrayValue : arrayValue.length fieldState.value = forceFullValue ? arrayValue : arrayValue.length
@@ -468,6 +483,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
forceFullValue, forceFullValue,
fullData, fullData,
includeSchema, includeSchema,
mockRSCs,
omitParents, omitParents,
operation, operation,
parentIndexPath: '', parentIndexPath: '',
@@ -482,7 +498,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
: parentPermissions?.[field.name]?.blocks?.[block.slug]?.fields || {}, : parentPermissions?.[field.name]?.blocks?.[block.slug]?.fields || {},
preferences, preferences,
previousFormState, previousFormState,
renderAllFields: requiresRender, renderAllFields,
renderFieldFn, renderFieldFn,
req, req,
select: typeof blockSelect === 'object' ? blockSelect : undefined, select: typeof blockSelect === 'object' ? blockSelect : undefined,
@@ -493,11 +509,22 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
}), }),
) )
acc.rowMetadata.push({ const previousRows = previousFormState?.[path]?.rows || []
// First, check if `previousFormState` has a matching row
const previousRow: Row = previousRows.find((prevRow) => prevRow.id === row.id)
const newRow: Row = {
id: row.id, id: row.id,
blockType: row.blockType, blockType: row.blockType,
isLoading: false, isLoading: false,
}) }
if (previousRow?.lastRenderedPath) {
newRow.lastRenderedPath = previousRow.lastRenderedPath
}
acc.rowMetadata.push(newRow)
const collapsedRowIDs = preferences?.fields?.[path]?.collapsed const collapsedRowIDs = preferences?.fields?.[path]?.collapsed
@@ -536,10 +563,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.rows = rowMetadata fieldState.rows = rowMetadata
// Unset requiresRender
// so it will be removed from form state
fieldState.requiresRender = false
// Add field to state // Add field to state
if (!omitParents && (!filter || filter(args))) { if (!omitParents && (!filter || filter(args))) {
state[path] = fieldState state[path] = fieldState
@@ -570,6 +593,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
forceFullValue, forceFullValue,
fullData, fullData,
includeSchema, includeSchema,
mockRSCs,
omitParents, omitParents,
operation, operation,
parentIndexPath: '', parentIndexPath: '',
@@ -709,6 +733,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
await iterateFields({ await iterateFields({
id, id,
mockRSCs,
select, select,
selectMode, selectMode,
// passthrough parent functionality // passthrough parent functionality
@@ -816,6 +841,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
forceFullValue, forceFullValue,
fullData, fullData,
includeSchema, includeSchema,
mockRSCs,
omitParents, omitParents,
operation, operation,
parentIndexPath: isNamedTab ? '' : tabIndexPath, parentIndexPath: isNamedTab ? '' : tabIndexPath,
@@ -844,12 +870,12 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
} }
} }
if (requiresRender && renderFieldFn && !fieldIsHiddenOrDisabled(field)) { if (renderFieldFn && !fieldIsHiddenOrDisabled(field)) {
const fieldState = state[path] const fieldState = state[path]
const fieldConfig = fieldSchemaMap.get(schemaPath) const fieldConfig = fieldSchemaMap.get(schemaPath)
if (!fieldConfig) { if (!fieldConfig && !mockRSCs) {
if (schemaPath.endsWith('.blockType')) { if (schemaPath.endsWith('.blockType')) {
return return
} else { } else {
@@ -873,6 +899,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState, fieldState,
formState: state, formState: state,
indexPath, indexPath,
lastRenderedPath,
mockRSCs,
operation, operation,
parentPath, parentPath,
parentSchemaPath, parentSchemaPath,
@@ -880,6 +908,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
permissions: fieldPermissions, permissions: fieldPermissions,
preferences, preferences,
previousFieldState: previousFormState?.[path], previousFieldState: previousFormState?.[path],
renderAllFields,
req, req,
schemaPath, schemaPath,
siblingData: data, siblingData: data,

View File

@@ -1,4 +1,5 @@
import type { import type {
BuildFormStateArgs,
ClientFieldSchemaMap, ClientFieldSchemaMap,
Data, Data,
DocumentPreferences, DocumentPreferences,
@@ -56,6 +57,7 @@ type Args = {
* the initial block data here, which will be used as `blockData` for the top-level fields, until the first block is encountered. * the initial block data here, which will be used as `blockData` for the top-level fields, until the first block is encountered.
*/ */
initialBlockData?: Data initialBlockData?: Data
mockRSCs?: BuildFormStateArgs['mockRSCs']
operation?: 'create' | 'update' operation?: 'create' | 'update'
permissions: SanitizedFieldsPermissions permissions: SanitizedFieldsPermissions
preferences: DocumentPreferences preferences: DocumentPreferences
@@ -86,6 +88,7 @@ export const fieldSchemasToFormState = async ({
fields, fields,
fieldSchemaMap, fieldSchemaMap,
initialBlockData, initialBlockData,
mockRSCs,
operation, operation,
permissions, permissions,
preferences, preferences,
@@ -139,6 +142,7 @@ export const fieldSchemasToFormState = async ({
fields, fields,
fieldSchemaMap, fieldSchemaMap,
fullData, fullData,
mockRSCs,
operation, operation,
parentIndexPath: '', parentIndexPath: '',
parentPassesCondition: true, parentPassesCondition: true,

View File

@@ -1,4 +1,5 @@
import type { import type {
BuildFormStateArgs,
ClientFieldSchemaMap, ClientFieldSchemaMap,
Data, Data,
DocumentPreferences, DocumentPreferences,
@@ -46,6 +47,7 @@ type Args = {
* Whether the field schema should be included in the state. @default false * Whether the field schema should be included in the state. @default false
*/ */
includeSchema?: boolean includeSchema?: boolean
mockRSCs?: BuildFormStateArgs['mockRSCs']
/** /**
* Whether to omit parent fields in the state. @default false * Whether to omit parent fields in the state. @default false
*/ */
@@ -94,6 +96,7 @@ export const iterateFields = async ({
forceFullValue = false, forceFullValue = false,
fullData, fullData,
includeSchema = false, includeSchema = false,
mockRSCs,
omitParents = false, omitParents = false,
operation, operation,
parentIndexPath, parentIndexPath,
@@ -180,6 +183,7 @@ export const iterateFields = async ({
fullData, fullData,
includeSchema, includeSchema,
indexPath, indexPath,
mockRSCs,
omitParents, omitParents,
operation, operation,
parentIndexPath, parentIndexPath,

View File

@@ -34,16 +34,25 @@ export const renderField: RenderFieldMethod = ({
fieldState, fieldState,
formState, formState,
indexPath, indexPath,
lastRenderedPath,
mockRSCs,
operation, operation,
parentPath, parentPath,
parentSchemaPath, parentSchemaPath,
path, path,
permissions, permissions,
preferences, preferences,
renderAllFields,
req, req,
schemaPath, schemaPath,
siblingData, siblingData,
}) => { }) => {
const requiresRender = renderAllFields || !lastRenderedPath || lastRenderedPath !== path
if (!requiresRender && fieldConfig.type !== 'array' && fieldConfig.type !== 'blocks') {
return
}
const clientField = clientFieldSchemaMap const clientField = clientFieldSchemaMap
? (clientFieldSchemaMap.get(schemaPath) as ClientField) ? (clientFieldSchemaMap.get(schemaPath) as ClientField)
: createClientField({ : createClientField({
@@ -53,10 +62,6 @@ export const renderField: RenderFieldMethod = ({
importMap: req.payload.importMap, importMap: req.payload.importMap,
}) })
if (fieldIsHiddenOrDisabled(clientField)) {
return
}
const clientProps: ClientComponentProps & Partial<FieldPaths> = { const clientProps: ClientComponentProps & Partial<FieldPaths> = {
field: clientField, field: clientField,
path, path,
@@ -97,6 +102,112 @@ export const renderField: RenderFieldMethod = ({
user: req.user, user: req.user,
} }
switch (fieldConfig.type) {
case 'array': {
fieldState?.rows?.forEach((row, rowIndex) => {
const rowLastRenderedPath = row.lastRenderedPath
const rowPath = `${path}.${rowIndex}`
const rowRequiresRender =
renderAllFields || !rowLastRenderedPath || rowLastRenderedPath !== rowPath
if (!rowRequiresRender) {
return
}
row.lastRenderedPath = rowPath
if (fieldConfig.admin?.components && 'RowLabel' in fieldConfig.admin.components) {
if (!row.customComponents) {
row.customComponents = {}
}
row.customComponents.RowLabel = !mockRSCs
? RenderServerComponent({
clientProps,
Component: fieldConfig.admin.components.RowLabel,
importMap: req.payload.importMap,
key: `${rowIndex}`,
serverProps: {
...serverProps,
rowLabel: `${getTranslation(fieldConfig.labels.singular, req.i18n)} ${String(
rowIndex + 1,
).padStart(2, '0')}`,
rowNumber: rowIndex + 1,
},
})
: 'Mock'
}
})
break
}
case 'blocks': {
fieldState?.rows?.forEach((row, rowIndex) => {
const rowLastRenderedPath = row.lastRenderedPath
const rowPath = `${path}.${rowIndex}`
const rowRequiresRender =
renderAllFields || !rowLastRenderedPath || rowLastRenderedPath !== rowPath
if (!rowRequiresRender) {
return
}
row.lastRenderedPath = rowPath
const blockTypeToMatch: string = row.blockType
const blockConfig =
req.payload.blocks[blockTypeToMatch] ??
((fieldConfig.blockReferences ?? fieldConfig.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockTypeToMatch,
) as FlattenedBlock | undefined)
if (blockConfig.admin?.components && 'Label' in blockConfig.admin.components) {
if (!fieldState.rows[rowIndex]?.customComponents) {
fieldState.rows[rowIndex].customComponents = {}
}
fieldState.rows[rowIndex].customComponents.RowLabel = !mockRSCs
? RenderServerComponent({
clientProps,
Component: blockConfig.admin.components.Label,
importMap: req.payload.importMap,
key: `${rowIndex}`,
serverProps: {
...serverProps,
blockType: row.blockType,
rowLabel: `${getTranslation(blockConfig.labels.singular, req.i18n)} ${String(
rowIndex + 1,
).padStart(2, '0')}`,
rowNumber: rowIndex + 1,
},
})
: 'Mock'
}
})
break
}
}
if (!requiresRender) {
return
}
/**
* Set the lastRenderedPath equal to the new path of the field
*/
fieldState.lastRenderedPath = path
if (fieldIsHiddenOrDisabled(clientField)) {
return
}
/** /**
* Only create the `customComponents` object if needed. * Only create the `customComponents` object if needed.
* This will prevent unnecessary data from being transferred to the client. * This will prevent unnecessary data from being transferred to the client.
@@ -114,70 +225,6 @@ export const renderField: RenderFieldMethod = ({
} }
switch (fieldConfig.type) { switch (fieldConfig.type) {
case 'array': {
fieldState?.rows?.forEach((row, rowIndex) => {
if (fieldConfig.admin?.components && 'RowLabel' in fieldConfig.admin.components) {
if (!fieldState.customComponents.RowLabels) {
fieldState.customComponents.RowLabels = []
}
fieldState.customComponents.RowLabels[rowIndex] = RenderServerComponent({
clientProps,
Component: fieldConfig.admin.components.RowLabel,
importMap: req.payload.importMap,
key: `${rowIndex}`,
serverProps: {
...serverProps,
rowLabel: `${getTranslation(fieldConfig.labels.singular, req.i18n)} ${String(
rowIndex + 1,
).padStart(2, '0')}`,
rowNumber: rowIndex + 1,
},
})
}
})
break
}
case 'blocks': {
fieldState?.rows?.forEach((row, rowIndex) => {
const blockTypeToMatch: string = row.blockType
const blockConfig =
req.payload.blocks[blockTypeToMatch] ??
((fieldConfig.blockReferences ?? fieldConfig.blocks).find(
(block) => typeof block !== 'string' && block.slug === blockTypeToMatch,
) as FlattenedBlock | undefined)
if (blockConfig.admin?.components && 'Label' in blockConfig.admin.components) {
if (!fieldState.customComponents) {
fieldState.customComponents = {}
}
if (!fieldState.customComponents.RowLabels) {
fieldState.customComponents.RowLabels = []
}
fieldState.customComponents.RowLabels[rowIndex] = RenderServerComponent({
clientProps,
Component: blockConfig.admin.components.Label,
importMap: req.payload.importMap,
key: `${rowIndex}`,
serverProps: {
...serverProps,
blockType: row.blockType,
rowLabel: `${getTranslation(blockConfig.labels.singular, req.i18n)} ${String(
rowIndex + 1,
).padStart(2, '0')}`,
rowNumber: rowIndex + 1,
},
})
}
})
break
}
case 'richText': { case 'richText': {
if (!fieldConfig?.editor) { if (!fieldConfig?.editor) {
throw new MissingEditorProp(fieldConfig) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor throw new MissingEditorProp(fieldConfig) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
@@ -195,7 +242,7 @@ export const renderField: RenderFieldMethod = ({
fieldConfig.admin.components = {} fieldConfig.admin.components = {}
} }
fieldState.customComponents.Field = ( fieldState.customComponents.Field = !mockRSCs ? (
<WatchCondition path={path}> <WatchCondition path={path}>
{RenderServerComponent({ {RenderServerComponent({
clientProps, clientProps,
@@ -204,6 +251,8 @@ export const renderField: RenderFieldMethod = ({
serverProps, serverProps,
})} })}
</WatchCondition> </WatchCondition>
) : (
'Mock'
) )
break break
@@ -219,13 +268,15 @@ export const renderField: RenderFieldMethod = ({
const Component = fieldConfig.admin.components[key] const Component = fieldConfig.admin.components[key]
fieldState.customComponents[key] = RenderServerComponent({ fieldState.customComponents[key] = !mockRSCs
? RenderServerComponent({
clientProps, clientProps,
Component, Component,
importMap: req.payload.importMap, importMap: req.payload.importMap,
key: `field.admin.components.${key}`, key: `field.admin.components.${key}`,
serverProps, serverProps,
}) })
: 'Mock'
} }
} }
break break
@@ -241,7 +292,7 @@ export const renderField: RenderFieldMethod = ({
'description' in fieldConfig.admin && 'description' in fieldConfig.admin &&
typeof fieldConfig.admin?.description === 'function' typeof fieldConfig.admin?.description === 'function'
) { ) {
fieldState.customComponents.Description = ( fieldState.customComponents.Description = !mockRSCs ? (
<FieldDescription <FieldDescription
description={fieldConfig.admin?.description({ description={fieldConfig.admin?.description({
i18n: req.i18n, i18n: req.i18n,
@@ -249,62 +300,74 @@ export const renderField: RenderFieldMethod = ({
})} })}
path={path} path={path}
/> />
) : (
'Mock'
) )
} }
if (fieldConfig.admin?.components) { if (fieldConfig.admin?.components) {
if ('afterInput' in fieldConfig.admin.components) { if ('afterInput' in fieldConfig.admin.components) {
fieldState.customComponents.AfterInput = RenderServerComponent({ fieldState.customComponents.AfterInput = !mockRSCs
? RenderServerComponent({
clientProps, clientProps,
Component: fieldConfig.admin.components.afterInput, Component: fieldConfig.admin.components.afterInput,
importMap: req.payload.importMap, importMap: req.payload.importMap,
key: 'field.admin.components.afterInput', key: 'field.admin.components.afterInput',
serverProps, serverProps,
}) })
: 'Mock'
} }
if ('beforeInput' in fieldConfig.admin.components) { if ('beforeInput' in fieldConfig.admin.components) {
fieldState.customComponents.BeforeInput = RenderServerComponent({ fieldState.customComponents.BeforeInput = !mockRSCs
? RenderServerComponent({
clientProps, clientProps,
Component: fieldConfig.admin.components.beforeInput, Component: fieldConfig.admin.components.beforeInput,
importMap: req.payload.importMap, importMap: req.payload.importMap,
key: 'field.admin.components.beforeInput', key: 'field.admin.components.beforeInput',
serverProps, serverProps,
}) })
: 'Mock'
} }
if ('Description' in fieldConfig.admin.components) { if ('Description' in fieldConfig.admin.components) {
fieldState.customComponents.Description = RenderServerComponent({ fieldState.customComponents.Description = !mockRSCs
? RenderServerComponent({
clientProps, clientProps,
Component: fieldConfig.admin.components.Description, Component: fieldConfig.admin.components.Description,
importMap: req.payload.importMap, importMap: req.payload.importMap,
key: 'field.admin.components.Description', key: 'field.admin.components.Description',
serverProps, serverProps,
}) })
: 'Mock'
} }
if ('Error' in fieldConfig.admin.components) { if ('Error' in fieldConfig.admin.components) {
fieldState.customComponents.Error = RenderServerComponent({ fieldState.customComponents.Error = !mockRSCs
? RenderServerComponent({
clientProps, clientProps,
Component: fieldConfig.admin.components.Error, Component: fieldConfig.admin.components.Error,
importMap: req.payload.importMap, importMap: req.payload.importMap,
key: 'field.admin.components.Error', key: 'field.admin.components.Error',
serverProps, serverProps,
}) })
: 'Mock'
} }
if ('Label' in fieldConfig.admin.components) { if ('Label' in fieldConfig.admin.components) {
fieldState.customComponents.Label = RenderServerComponent({ fieldState.customComponents.Label = !mockRSCs
? RenderServerComponent({
clientProps, clientProps,
Component: fieldConfig.admin.components.Label, Component: fieldConfig.admin.components.Label,
importMap: req.payload.importMap, importMap: req.payload.importMap,
key: 'field.admin.components.Label', key: 'field.admin.components.Label',
serverProps, serverProps,
}) })
: 'Mock'
} }
if ('Field' in fieldConfig.admin.components) { if ('Field' in fieldConfig.admin.components) {
fieldState.customComponents.Field = ( fieldState.customComponents.Field = !mockRSCs ? (
<WatchCondition path={path}> <WatchCondition path={path}>
{RenderServerComponent({ {RenderServerComponent({
clientProps, clientProps,
@@ -314,6 +377,8 @@ export const renderField: RenderFieldMethod = ({
serverProps, serverProps,
})} })}
</WatchCondition> </WatchCondition>
) : (
'Mock'
) )
} }
} }

View File

@@ -21,6 +21,8 @@ export type RenderFieldArgs = {
formState: FormState formState: FormState
id?: number | string id?: number | string
indexPath: string indexPath: string
lastRenderedPath: string
mockRSCs?: boolean
operation: Operation operation: Operation
parentPath: string parentPath: string
parentSchemaPath: string parentSchemaPath: string
@@ -28,6 +30,7 @@ export type RenderFieldArgs = {
permissions: SanitizedFieldPermissions permissions: SanitizedFieldPermissions
preferences: DocumentPreferences preferences: DocumentPreferences
previousFieldState: FieldState previousFieldState: FieldState
renderAllFields: boolean
req: PayloadRequest req: PayloadRequest
schemaPath: string schemaPath: string
siblingData: Data siblingData: Data

View File

@@ -41,8 +41,8 @@ export type IListQueryContext = {
data: PaginatedDocs data: PaginatedDocs
defaultLimit?: number defaultLimit?: number
defaultSort?: Sort defaultSort?: Sort
orderableFieldName?: string
modified: boolean modified: boolean
orderableFieldName?: string
query: ListQuery query: ListQuery
refineListData: (args: ListQuery, setModified?: boolean) => Promise<void> refineListData: (args: ListQuery, setModified?: boolean) => Promise<void>
setModified: (modified: boolean) => void setModified: (modified: boolean) => void

View File

@@ -107,6 +107,7 @@ export const buildFormState = async (
globalSlug, globalSlug,
initialBlockData, initialBlockData,
initialBlockFormState, initialBlockFormState,
mockRSCs,
operation, operation,
renderAllFields, renderAllFields,
req, req,
@@ -205,6 +206,7 @@ export const buildFormState = async (
fields, fields,
fieldSchemaMap: schemaMap, fieldSchemaMap: schemaMap,
initialBlockData: blockData, initialBlockData: blockData,
mockRSCs,
operation, operation,
permissions: docPermissions?.fields || {}, permissions: docPermissions?.fields || {},
preferences: docPreferences || { fields: {} }, preferences: docPreferences || { fields: {} },

View File

@@ -1,4 +1,4 @@
import type { MigrateDownArgs, MigrateUpArgs} from '@payloadcms/db-sqlite'; import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-sqlite'
import { sql } from '@payloadcms/db-sqlite' import { sql } from '@payloadcms/db-sqlite'

View File

@@ -0,0 +1,5 @@
import React from 'react'
export const ArrayRowLabel = () => {
return <p>This is a custom component</p>
}

View File

@@ -69,6 +69,11 @@ export const PostsCollection: CollectionConfig = {
{ {
name: 'array', name: 'array',
type: 'array', type: 'array',
admin: {
components: {
RowLabel: './collections/Posts/ArrayRowLabel.js#ArrayRowLabel',
},
},
fields: [ fields: [
{ {
name: 'richText', name: 'richText',

View File

@@ -20,6 +20,7 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' import { TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -36,7 +37,7 @@ test.describe('Form State', () => {
test.beforeAll(async ({ browser }, testInfo) => { test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
postsUrl = new AdminUrlUtil(serverURL, 'posts') postsUrl = new AdminUrlUtil(serverURL, postsSlug)
context = await browser.newContext() context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -146,6 +147,64 @@ test.describe('Form State', () => {
) )
}) })
test('should send `lastRenderedPath` only when necessary', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await field.fill('Test')
// The `array` itself SHOULD have a `lastRenderedPath` because it was rendered on initial load
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: await page.locator('#field-array .array-field__add-row').click(),
url: postsUrl.create,
expect: (body) =>
Boolean(
body?.[0]?.args?.formState?.['array'] && body[0].args.formState['array'].lastRenderedPath,
),
})
await page.waitForResponse(
(response) =>
response.url() === postsUrl.create &&
response.status() === 200 &&
response.headers()['content-type'] === 'text/x-component',
)
// The `array` itself SHOULD still have a `lastRenderedPath`
// The rich text field in the first row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the first request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: await page.locator('#field-array .array-field__add-row').click(),
url: postsUrl.create,
expect: (body) =>
Boolean(
body?.[0]?.args?.formState?.['array'] &&
body[0].args.formState['array'].lastRenderedPath &&
body[0].args.formState['array.0.richText']?.lastRenderedPath,
),
})
await page.waitForResponse(
(response) =>
response.url() === postsUrl.create &&
response.status() === 200 &&
response.headers()['content-type'] === 'text/x-component',
)
// The `array` itself SHOULD still have a `lastRenderedPath`
// The rich text field in the first row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the first request
// The rich text field in the second row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the second request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: await page.locator('#field-array .array-field__add-row').click(),
url: postsUrl.create,
expect: (body) =>
Boolean(
body?.[0]?.args?.formState?.['array'] &&
body[0].args.formState['array'].lastRenderedPath &&
body[0].args.formState['array.0.richText']?.lastRenderedPath &&
body[0].args.formState['array.1.richText']?.lastRenderedPath,
),
})
})
test('should queue onChange functions', async () => { test('should queue onChange functions', async () => {
await page.goto(postsUrl.create) await page.goto(postsUrl.create)
const field = page.locator('#field-title') const field = page.locator('#field-title')
@@ -182,7 +241,7 @@ test.describe('Form State', () => {
await cdpSession.detach() await cdpSession.detach()
}) })
test('should not cause nested custom fields to disappear when queuing form state (1)', async () => { test('should not cause nested custom components to disappear when adding a row then editing a field', async () => {
await page.goto(postsUrl.create) await page.goto(postsUrl.create)
const field = page.locator('#field-title') const field = page.locator('#field-title')
await field.fill('Test') await field.fill('Test')
@@ -193,23 +252,12 @@ test.describe('Form State', () => {
delay: 'Slow 3G', delay: 'Slow 3G',
}) })
// Add a row and immediately type into another field
// Test that the rich text field within the row does not disappear
await assertNetworkRequests( await assertNetworkRequests(
page, page,
postsUrl.create, postsUrl.create,
async () => { async () => {
// Ensure `requiresRender` is `true` is set for the first request await page.locator('#field-array .array-field__add-row').click()
await assertRequestBody<{ args: { formState: FormState } }[]>(page, { await page.locator('#field-title').fill('Test 2')
action: page.locator('#field-array .array-field__add-row').click(),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === true,
})
// Ensure `requiresRender` is `false` for the second request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: page.locator('#field-title').fill('Title 2'),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === false,
})
// use `waitForSelector` to ensure the element doesn't appear and then disappear // use `waitForSelector` to ensure the element doesn't appear and then disappear
// eslint-disable-next-line playwright/no-wait-for-selector // eslint-disable-next-line playwright/no-wait-for-selector
@@ -237,7 +285,7 @@ test.describe('Form State', () => {
await cdpSession.detach() await cdpSession.detach()
}) })
test('should not cause nested custom fields to disappear when queuing form state (2)', async () => { test('should not cause nested custom components to disappear when adding rows back-to-back', async () => {
await page.goto(postsUrl.create) await page.goto(postsUrl.create)
const field = page.locator('#field-title') const field = page.locator('#field-title')
await field.fill('Test') await field.fill('Test')
@@ -254,17 +302,8 @@ test.describe('Form State', () => {
page, page,
postsUrl.create, postsUrl.create,
async () => { async () => {
// Ensure `requiresRender` is `true` is set for the first request await page.locator('#field-array .array-field__add-row').click()
await assertRequestBody<{ args: { formState: FormState } }[]>(page, { await page.locator('#field-array .array-field__add-row').click()
action: page.locator('#field-array .array-field__add-row').click(),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === true,
})
// Ensure `requiresRender` is `true` is set for the second request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: page.locator('#field-array .array-field__add-row').click(),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === true,
})
// use `waitForSelector` to ensure the element doesn't appear and then disappear // use `waitForSelector` to ensure the element doesn't appear and then disappear
// eslint-disable-next-line playwright/no-wait-for-selector // eslint-disable-next-line playwright/no-wait-for-selector
@@ -292,12 +331,6 @@ test.describe('Form State', () => {
}, },
) )
// Ensure `requiresRender` is `false` for the third request
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
action: page.locator('#field-title').fill('Title 2'),
expect: (body) => body[0]?.args?.formState?.array?.requiresRender === false,
})
await cdpSession.send('Network.emulateNetworkConditions', { await cdpSession.send('Network.emulateNetworkConditions', {
offline: false, offline: false,
latency: 0, latency: 0,

View File

@@ -21,11 +21,8 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
describe('Form State', () => { describe('Form State', () => {
// --__--__--__--__--__--__--__--__--__
// Boilerplate test setup/teardown
// --__--__--__--__--__--__--__--__--__
beforeAll(async () => { beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname)) ;({ payload, restClient } = await initPayloadInt(dirname, undefined, true))
const data = await restClient const data = await restClient
.POST('/users/login', { .POST('/users/login', {
@@ -57,6 +54,7 @@ describe('Form State', () => {
}) })
const { state } = await buildFormState({ const { state } = await buildFormState({
mockRSCs: true,
id: postData.id, id: postData.id,
collectionSlug: postsSlug, collectionSlug: postsSlug,
data: postData, data: postData,
@@ -95,7 +93,6 @@ describe('Form State', () => {
validateUsingEvent: {}, validateUsingEvent: {},
blocks: { blocks: {
initialValue: 0, initialValue: 0,
requiresRender: false,
rows: [], rows: [],
value: 0, value: 0,
}, },
@@ -113,6 +110,7 @@ describe('Form State', () => {
}) })
const { state } = await buildFormState({ const { state } = await buildFormState({
mockRSCs: true,
id: postData.id, id: postData.id,
collectionSlug: postsSlug, collectionSlug: postsSlug,
data: postData, data: postData,
@@ -134,9 +132,89 @@ describe('Form State', () => {
title: { title: {
value: postData.title, value: postData.title,
initialValue: postData.title, initialValue: postData.title,
lastRenderedPath: 'title',
}, },
}) })
}) })
it.todo('should skip validation if specified') it('should not render custom components when `lastRenderedPath` exists', async () => {
const req = await createLocalReq({ user }, payload)
const { state: stateWithRow } = await buildFormState({
mockRSCs: true,
collectionSlug: postsSlug,
formState: {
array: {
rows: [
{
id: '123',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
// Ensure that row 1 _DOES_ return with rendered components
expect(stateWithRow?.['array.0.richText']?.lastRenderedPath).toStrictEqual('array.0.richText')
expect(stateWithRow?.['array.0.richText']?.customComponents?.Field).toBeDefined()
const { state: stateWithTitle } = await buildFormState({
mockRSCs: true,
collectionSlug: postsSlug,
formState: {
array: {
rows: [
{
id: '123',
},
{
id: '456',
},
],
},
'array.0.id': {
value: '123',
initialValue: '123',
},
'array.0.richText': {
lastRenderedPath: 'array.0.richText',
},
'array.1.id': {
value: '456',
initialValue: '456',
},
},
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
schemaPath: postsSlug,
req,
})
// Ensure that row 1 _DOES NOT_ return with rendered components
expect(stateWithTitle?.['array.0.richText']).toHaveProperty('lastRenderedPath')
expect(stateWithTitle?.['array.0.richText']).not.toHaveProperty('customComponents')
// Ensure that row 2 _DOES_ return with rendered components
expect(stateWithTitle?.['array.1.richText']).toHaveProperty('lastRenderedPath')
expect(stateWithTitle?.['array.1.richText']).toHaveProperty('customComponents')
expect(stateWithTitle?.['array.1.richText']?.customComponents?.Field).toBeDefined()
})
}) })

View File

@@ -2,15 +2,38 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
/**
* A helper function to assert the body of a network request.
* This is useful for reading the body of a request and testing whether it is correct.
* For example, if you have a form that submits data to an API, you can use this function to
* assert that the data being sent is correct.
* @param page The Playwright page
* @param options Options
* @param options.action The action to perform that will trigger the request
* @param options.expect A function to run after the request is made to assert the request body
* @returns The request body
* @example
* const requestBody = await assertRequestBody(page, {
* action: page.click('button'),
* expect: (requestBody) => expect(requestBody.foo).toBe('bar')
* })
*/
export const assertRequestBody = async <T>( export const assertRequestBody = async <T>(
page: Page, page: Page,
options: { options: {
action: Promise<void> | void action: Promise<void> | void
expect?: (requestBody: T) => boolean | Promise<boolean> expect?: (requestBody: T) => boolean | Promise<boolean>
requestMethod?: string
url: string
}, },
): Promise<T | undefined> => { ): Promise<T | undefined> => {
const [request] = await Promise.all([ const [request] = await Promise.all([
page.waitForRequest((request) => request.method() === 'POST'), // Adjust condition as needed page.waitForRequest((request) =>
Boolean(
request.url().startsWith(options.url) &&
(request.method() === options.requestMethod || 'POST'),
),
),
await options.action, await options.action,
]) ])

View File

@@ -0,0 +1,86 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
function parseRSC(rscText: string) {
// Next.js streams use special delimiters like "\n"
const chunks = rscText.split('\n').filter((line) => line.trim() !== '')
// find the chunk starting with '1:', remove the '1:' prefix and parse the rest
const match = chunks.find((chunk) => chunk.startsWith('1:'))
if (match) {
const jsonString = match.slice(2).trim()
if (jsonString) {
try {
return JSON.parse(jsonString)
} catch (err) {
console.error('Failed to parse JSON:', err)
}
}
}
return null
}
/**
* A helper function to assert the response of a network request.
* This is useful for reading the response of a request and testing whether it is correct.
* For example, if you have a form that submits data to an API, you can use this function to
* assert that the data sent back is correct.
* @param page The Playwright page
* @param options Options
* @param options.action The action to perform that will trigger the request
* @param options.expect A function to run after the request is made to assert the response body
* @param options.url The URL to match in the network requests
* @returns The request body
* @example
* const responseBody = await assertResponseBody(page, {
* action: page.click('button'),
* expect: (responseBody) => expect(responseBody.foo).toBe('bar')
* })
*/
export const assertResponseBody = async <T>(
page: Page,
options: {
action: Promise<void> | void
expect?: (requestBody: T) => boolean | Promise<boolean>
requestMethod?: string
responseContentType?: string
url?: string
},
): Promise<T | undefined> => {
const [response] = await Promise.all([
page.waitForResponse((response) =>
Boolean(
response.url().includes(options.url || '') &&
response.status() === 200 &&
response
.headers()
['content-type']?.includes(options.responseContentType || 'application/json'),
),
),
await options.action,
])
if (!response) {
throw new Error('No response received')
}
const responseBody = await response.text()
const responseType = response.headers()['content-type']?.split(';')[0]
let parsedBody: T = undefined as T
if (responseType === 'text/x-component') {
parsedBody = parseRSC(responseBody)
} else if (typeof responseBody === 'string') {
parsedBody = JSON.parse(responseBody) as T
}
if (typeof options.expect === 'function') {
expect(await options.expect(parsedBody)).toBeTruthy()
}
return parsedBody
}

View File

@@ -10,8 +10,10 @@ import { updatePostStep1, updatePostStep2 } from './runners/updatePost.js'
import { clearAndSeedEverything } from './seed.js' import { clearAndSeedEverything } from './seed.js'
import { externalWorkflow } from './workflows/externalWorkflow.js' import { externalWorkflow } from './workflows/externalWorkflow.js'
import { inlineTaskTestWorkflow } from './workflows/inlineTaskTest.js' import { inlineTaskTestWorkflow } from './workflows/inlineTaskTest.js'
import { inlineTaskTestDelayedWorkflow } from './workflows/inlineTaskTestDelayed.js'
import { longRunningWorkflow } from './workflows/longRunning.js' import { longRunningWorkflow } from './workflows/longRunning.js'
import { noRetriesSetWorkflow } from './workflows/noRetriesSet.js' import { noRetriesSetWorkflow } from './workflows/noRetriesSet.js'
import { parallelTaskWorkflow } from './workflows/parallelTaskWorkflow.js'
import { retries0Workflow } from './workflows/retries0.js' import { retries0Workflow } from './workflows/retries0.js'
import { retriesBackoffTestWorkflow } from './workflows/retriesBackoffTest.js' import { retriesBackoffTestWorkflow } from './workflows/retriesBackoffTest.js'
import { retriesRollbackTestWorkflow } from './workflows/retriesRollbackTest.js' import { retriesRollbackTestWorkflow } from './workflows/retriesRollbackTest.js'
@@ -24,8 +26,6 @@ import { updatePostJSONWorkflow } from './workflows/updatePostJSON.js'
import { workflowAndTasksRetriesUndefinedWorkflow } from './workflows/workflowAndTasksRetriesUndefined.js' import { workflowAndTasksRetriesUndefinedWorkflow } from './workflows/workflowAndTasksRetriesUndefined.js'
import { workflowRetries2TasksRetries0Workflow } from './workflows/workflowRetries2TasksRetries0.js' import { workflowRetries2TasksRetries0Workflow } from './workflows/workflowRetries2TasksRetries0.js'
import { workflowRetries2TasksRetriesUndefinedWorkflow } from './workflows/workflowRetries2TasksRetriesUndefined.js' import { workflowRetries2TasksRetriesUndefinedWorkflow } from './workflows/workflowRetries2TasksRetriesUndefined.js'
import { inlineTaskTestDelayedWorkflow } from './workflows/inlineTaskTestDelayed.js'
import { parallelTaskWorkflow } from './workflows/parallelTaskWorkflow.js'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)

View File

@@ -1390,7 +1390,7 @@ describe('Queues', () => {
limit: amount, limit: amount,
depth: 0, depth: 0,
}) })
expect(simpleDocs.docs.length).toBe(amount) expect(simpleDocs.docs).toHaveLength(amount)
// Ensure all docs are created (= all tasks are run once) // Ensure all docs are created (= all tasks are run once)
for (let i = 1; i <= simpleDocs.docs.length; i++) { for (let i = 1; i <= simpleDocs.docs.length; i++) {

View File

@@ -31,7 +31,7 @@
} }
], ],
"paths": { "paths": {
"@payload-config": ["./test/form-state/config.ts"], "@payload-config": ["./test/_community/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],