diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx
index 9e804750b..bbea44a36 100644
--- a/docs/configuration/collections.mdx
+++ b/docs/configuration/collections.mdx
@@ -60,31 +60,31 @@ export const Posts: CollectionConfig = {
The following options are available:
-| Option | Description |
-| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `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). |
-| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
-| `custom` | Extension point for adding custom data (e.g. for plugins) |
-| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
-| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
-| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
-| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
-| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
-| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
-| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
-| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
-| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
-| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
-| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
-| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
-| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
-| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
-| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
-| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
-| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
-| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
-| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
+| Option | Description |
+| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `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). |
+| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
+| `custom` | Extension point for adding custom data (e.g. for plugins) |
+| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
+| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
+| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
+| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
+| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
+| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
+| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
+| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
+| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
+| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
+| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
+| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
+| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
+| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
+| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
+| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
+| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
+| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
+| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
_\* An asterisk denotes that a property is required._
diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts
index 419087a6e..88274b70b 100644
--- a/packages/payload/src/admin/forms/Form.ts
+++ b/packages/payload/src/admin/forms/Form.ts
@@ -13,8 +13,12 @@ export type Data = {
export type Row = {
blockType?: string
collapsed?: boolean
+ customComponents?: {
+ RowLabel?: React.ReactNode
+ }
id: string
isLoading?: boolean
+ lastRenderedPath?: string
}
export type FilterOptionsResult = {
@@ -34,7 +38,6 @@ export type FieldState = {
Error?: React.ReactNode
Field?: React.ReactNode
Label?: React.ReactNode
- RowLabels?: React.ReactNode[]
}
disableFormData?: boolean
errorMessage?: string
@@ -46,8 +49,16 @@ export type FieldState = {
fieldSchema?: Field
filterOptions?: FilterOptionsResult
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
- requiresRender?: boolean
rows?: Row[]
/**
* 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
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'
/*
If true, will render field components within their state object
diff --git a/packages/richtext-lexical/src/features/upload/server/validate.ts b/packages/richtext-lexical/src/features/upload/server/validate.ts
index bbbdbca49..ac475d1ac 100644
--- a/packages/richtext-lexical/src/features/upload/server/validate.ts
+++ b/packages/richtext-lexical/src/features/upload/server/validate.ts
@@ -46,7 +46,6 @@ export const uploadValidation = (
const result = await fieldSchemasToFormState({
id,
collectionSlug: node.relationTo,
-
data: node?.fields ?? {},
documentData: data,
fields: collection.fields,
diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts
index 78f6b9555..4e0fd0083 100644
--- a/packages/richtext-lexical/src/index.ts
+++ b/packages/richtext-lexical/src/index.ts
@@ -901,12 +901,12 @@ export {
HTMLConverterFeature,
type HTMLConverterFeatureProps,
} 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 { convertMarkdownToLexical } from './features/converters/markdownToLexical/index.js'
-
export { getPayloadPopulateFn } from './features/converters/utilities/payloadPopulateFn.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 { TreeViewFeature } from './features/debug/treeView/server/index.js'
export { EXPERIMENTAL_TableFeature } from './features/experimental_table/server/index.js'
diff --git a/packages/ui/src/fields/Array/index.tsx b/packages/ui/src/fields/Array/index.tsx
index a230a93c8..5c3730e4c 100644
--- a/packages/ui/src/fields/Array/index.tsx
+++ b/packages/ui/src/fields/Array/index.tsx
@@ -110,10 +110,10 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
)
const {
- customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {},
+ customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
errorPaths,
- rows: rowsData = [],
+ rows = [],
showError,
valid,
value,
@@ -173,12 +173,12 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
(collapsed: boolean) => {
const { collapsedIDs, updatedRows } = toggleAllRows({
collapsed,
- rows: rowsData,
+ rows,
})
setDocFieldPreferences(path, { collapsed: collapsedIDs })
dispatchFields({ type: 'SET_ALL_ROWS_COLLAPSED', path, updatedRows })
},
- [dispatchFields, path, rowsData, setDocFieldPreferences],
+ [dispatchFields, path, rows, setDocFieldPreferences],
)
const setCollapse = useCallback(
@@ -186,22 +186,22 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
const { collapsedIDs, updatedRows } = extractRowsAndCollapsedIDs({
collapsed,
rowID,
- rows: rowsData,
+ rows,
})
dispatchFields({ type: 'SET_ROW_COLLAPSED', path, updatedRows })
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 fieldHasErrors = submitted && errorPaths.length > 0
- const showRequired = (readOnly || disabled) && rowsData.length === 0
- const showMinRows = rowsData.length < minRows || (required && rowsData.length === 0)
+ const showRequired = (readOnly || disabled) && rows.length === 0
+ const showMinRows = rows.length < minRows || (required && rows.length === 0)
return (
{
)}
- {rowsData?.length > 0 && (
+ {rows?.length > 0 && (
{
{BeforeInput}
- {(rowsData?.length > 0 || (!valid && (showRequired || showMinRows))) && (
+ {(rows?.length > 0 || (!valid && (showRequired || showMinRows))) && (
row.id)}
+ ids={rows.map((row) => row.id)}
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
>
- {rowsData.map((rowData, i) => {
+ {rows.map((rowData, i) => {
const { id: rowID, isLoading } = rowData
const rowPath = `${path}.${i}`
@@ -297,7 +297,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
{
readOnly={readOnly || disabled}
removeRow={removeRow}
row={rowData}
- rowCount={rowsData?.length}
+ rowCount={rows?.length}
rowIndex={i}
schemaPath={schemaPath}
setCollapse={setCollapse}
diff --git a/packages/ui/src/fields/Blocks/index.tsx b/packages/ui/src/fields/Blocks/index.tsx
index 10d562742..0dea39644 100644
--- a/packages/ui/src/fields/Blocks/index.tsx
+++ b/packages/ui/src/fields/Blocks/index.tsx
@@ -98,7 +98,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
)
const {
- customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {},
+ customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
errorPaths,
rows = [],
@@ -293,7 +293,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
hasMaxRows={hasMaxRows}
isLoading={isLoading}
isSortable={isSortable}
- Label={RowLabels?.[i]}
+ Label={rows?.[i]?.customComponents?.RowLabel}
labels={labels}
moveRow={moveRow}
parentPath={path}
diff --git a/packages/ui/src/forms/Form/fieldReducer.ts b/packages/ui/src/forms/Form/fieldReducer.ts
index 744463eb4..f426fc8d1 100644
--- a/packages/ui/src/forms/Form/fieldReducer.ts
+++ b/packages/ui/src/forms/Form/fieldReducer.ts
@@ -53,24 +53,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[`${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 || []),
},
}
@@ -144,12 +134,16 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
const { remainingFields, rows } = separateRows(path, state)
const rowsMetadata = [...(state[path].rows || [])]
- const duplicateRowMetadata = deepCopyObjectSimple(rowsMetadata[rowIndex])
+ const duplicateRowMetadata = deepCopyObjectSimpleWithoutReactComponents(
+ 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()
@@ -177,17 +171,8 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[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)),
},
}
@@ -214,41 +199,10 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
...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
}
@@ -273,17 +227,8 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[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),
}
@@ -323,14 +268,6 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
disableFormData: true,
rows: rowsMetadata,
value: siblingRows.length,
- ...(state[path]?.requiresRender === true
- ? {
- serverPropsToIgnore: [
- ...(state[path]?.serverPropsToIgnore || []),
- 'requiresRender',
- ],
- }
- : state[path]?.serverPropsToIgnore || []),
},
}
diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx
index 8e9246a93..dbdf8328c 100644
--- a/packages/ui/src/forms/Form/index.tsx
+++ b/packages/ui/src/forms/Form/index.tsx
@@ -596,7 +596,7 @@ export const Form: React.FC = (props) => {
const newRows: unknown[] = getDataByPath(path) || []
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.
dispatchFields({
type: 'ADD_ROW',
diff --git a/packages/ui/src/forms/Form/mergeServerFormState.ts b/packages/ui/src/forms/Form/mergeServerFormState.ts
index af722f67c..91e9149fa 100644
--- a/packages/ui/src/forms/Form/mergeServerFormState.ts
+++ b/packages/ui/src/forms/Form/mergeServerFormState.ts
@@ -34,7 +34,6 @@ export const mergeServerFormState = ({
'errorMessage',
'errorPaths',
'customComponents',
- 'requiresRender',
]
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
* Loop over the incoming rows, if it exists in client side form state, merge in any new properties from the server
*/
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
index 6677f6a1d..c8eecad95 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
+++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
@@ -1,4 +1,5 @@
import type {
+ BuildFormStateArgs,
ClientFieldSchemaMap,
Data,
DocumentPreferences,
@@ -9,6 +10,7 @@ import type {
FormState,
FormStateWithoutComponents,
PayloadRequest,
+ Row,
SanitizedFieldPermissions,
SanitizedFieldsPermissions,
SelectMode,
@@ -68,6 +70,7 @@ export type AddFieldStatePromiseArgs = {
*/
includeSchema?: boolean
indexPath: string
+ mockRSCs?: BuildFormStateArgs['mockRSCs']
/**
* Whether to omit parent fields in the state. @default false
*/
@@ -122,6 +125,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fullData,
includeSchema = false,
indexPath,
+ mockRSCs,
omitParents = false,
operation,
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
const fieldState: FieldState = {}
+ if (lastRenderedPath) {
+ fieldState.lastRenderedPath = lastRenderedPath
+ }
+
if (passesCondition === false) {
fieldState.passesCondition = false
}
@@ -289,6 +297,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
forceFullValue,
fullData,
includeSchema,
+ mockRSCs,
omitParents,
operation,
parentIndexPath: '',
@@ -299,7 +308,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldPermissions === true ? fieldPermissions : fieldPermissions?.fields || {},
preferences,
previousFormState,
- renderAllFields: requiresRender,
+ renderAllFields,
renderFieldFn,
req,
select: typeof arraySelect === 'object' ? arraySelect : undefined,
@@ -314,17 +323,25 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
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,
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 collapsed = (() => {
- // First, check if `previousFormState` has a matching row
- const previousRow = previousRows.find((prevRow) => prevRow.id === row.id)
if (previousRow) {
return previousRow.collapsed ?? false
}
@@ -357,8 +374,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.rows = rows
}
- fieldState.requiresRender = false
-
// Add values to field state
if (data[field.name] !== null) {
fieldState.value = forceFullValue ? arrayValue : arrayValue.length
@@ -468,6 +483,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
forceFullValue,
fullData,
includeSchema,
+ mockRSCs,
omitParents,
operation,
parentIndexPath: '',
@@ -482,7 +498,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
: parentPermissions?.[field.name]?.blocks?.[block.slug]?.fields || {},
preferences,
previousFormState,
- renderAllFields: requiresRender,
+ renderAllFields,
renderFieldFn,
req,
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,
blockType: row.blockType,
isLoading: false,
- })
+ }
+
+ if (previousRow?.lastRenderedPath) {
+ newRow.lastRenderedPath = previousRow.lastRenderedPath
+ }
+
+ acc.rowMetadata.push(newRow)
const collapsedRowIDs = preferences?.fields?.[path]?.collapsed
@@ -536,10 +563,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.rows = rowMetadata
- // Unset requiresRender
- // so it will be removed from form state
- fieldState.requiresRender = false
-
// Add field to state
if (!omitParents && (!filter || filter(args))) {
state[path] = fieldState
@@ -570,6 +593,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
forceFullValue,
fullData,
includeSchema,
+ mockRSCs,
omitParents,
operation,
parentIndexPath: '',
@@ -709,6 +733,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
await iterateFields({
id,
+ mockRSCs,
select,
selectMode,
// passthrough parent functionality
@@ -816,6 +841,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
forceFullValue,
fullData,
includeSchema,
+ mockRSCs,
omitParents,
operation,
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 fieldConfig = fieldSchemaMap.get(schemaPath)
- if (!fieldConfig) {
+ if (!fieldConfig && !mockRSCs) {
if (schemaPath.endsWith('.blockType')) {
return
} else {
@@ -873,6 +899,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState,
formState: state,
indexPath,
+ lastRenderedPath,
+ mockRSCs,
operation,
parentPath,
parentSchemaPath,
@@ -880,6 +908,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
permissions: fieldPermissions,
preferences,
previousFieldState: previousFormState?.[path],
+ renderAllFields,
req,
schemaPath,
siblingData: data,
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx
index 31f9ef809..5bd41e6f8 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx
+++ b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx
@@ -1,4 +1,5 @@
import type {
+ BuildFormStateArgs,
ClientFieldSchemaMap,
Data,
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.
*/
initialBlockData?: Data
+ mockRSCs?: BuildFormStateArgs['mockRSCs']
operation?: 'create' | 'update'
permissions: SanitizedFieldsPermissions
preferences: DocumentPreferences
@@ -86,6 +88,7 @@ export const fieldSchemasToFormState = async ({
fields,
fieldSchemaMap,
initialBlockData,
+ mockRSCs,
operation,
permissions,
preferences,
@@ -139,6 +142,7 @@ export const fieldSchemasToFormState = async ({
fields,
fieldSchemaMap,
fullData,
+ mockRSCs,
operation,
parentIndexPath: '',
parentPassesCondition: true,
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
index a10a764ed..5601c49b0 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
+++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
@@ -1,4 +1,5 @@
import type {
+ BuildFormStateArgs,
ClientFieldSchemaMap,
Data,
DocumentPreferences,
@@ -46,6 +47,7 @@ type Args = {
* Whether the field schema should be included in the state. @default false
*/
includeSchema?: boolean
+ mockRSCs?: BuildFormStateArgs['mockRSCs']
/**
* Whether to omit parent fields in the state. @default false
*/
@@ -94,6 +96,7 @@ export const iterateFields = async ({
forceFullValue = false,
fullData,
includeSchema = false,
+ mockRSCs,
omitParents = false,
operation,
parentIndexPath,
@@ -180,6 +183,7 @@ export const iterateFields = async ({
fullData,
includeSchema,
indexPath,
+ mockRSCs,
omitParents,
operation,
parentIndexPath,
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx b/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx
index 0c92bc510..72b26715b 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx
+++ b/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx
@@ -34,16 +34,25 @@ export const renderField: RenderFieldMethod = ({
fieldState,
formState,
indexPath,
+ lastRenderedPath,
+ mockRSCs,
operation,
parentPath,
parentSchemaPath,
path,
permissions,
preferences,
+ renderAllFields,
req,
schemaPath,
siblingData,
}) => {
+ const requiresRender = renderAllFields || !lastRenderedPath || lastRenderedPath !== path
+
+ if (!requiresRender && fieldConfig.type !== 'array' && fieldConfig.type !== 'blocks') {
+ return
+ }
+
const clientField = clientFieldSchemaMap
? (clientFieldSchemaMap.get(schemaPath) as ClientField)
: createClientField({
@@ -53,10 +62,6 @@ export const renderField: RenderFieldMethod = ({
importMap: req.payload.importMap,
})
- if (fieldIsHiddenOrDisabled(clientField)) {
- return
- }
-
const clientProps: ClientComponentProps & Partial = {
field: clientField,
path,
@@ -97,6 +102,112 @@ export const renderField: RenderFieldMethod = ({
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.
* This will prevent unnecessary data from being transferred to the client.
@@ -114,70 +225,6 @@ export const renderField: RenderFieldMethod = ({
}
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': {
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
@@ -195,7 +242,7 @@ export const renderField: RenderFieldMethod = ({
fieldConfig.admin.components = {}
}
- fieldState.customComponents.Field = (
+ fieldState.customComponents.Field = !mockRSCs ? (
{RenderServerComponent({
clientProps,
@@ -204,6 +251,8 @@ export const renderField: RenderFieldMethod = ({
serverProps,
})}
+ ) : (
+ 'Mock'
)
break
@@ -219,13 +268,15 @@ export const renderField: RenderFieldMethod = ({
const Component = fieldConfig.admin.components[key]
- fieldState.customComponents[key] = RenderServerComponent({
- clientProps,
- Component,
- importMap: req.payload.importMap,
- key: `field.admin.components.${key}`,
- serverProps,
- })
+ fieldState.customComponents[key] = !mockRSCs
+ ? RenderServerComponent({
+ clientProps,
+ Component,
+ importMap: req.payload.importMap,
+ key: `field.admin.components.${key}`,
+ serverProps,
+ })
+ : 'Mock'
}
}
break
@@ -241,7 +292,7 @@ export const renderField: RenderFieldMethod = ({
'description' in fieldConfig.admin &&
typeof fieldConfig.admin?.description === 'function'
) {
- fieldState.customComponents.Description = (
+ fieldState.customComponents.Description = !mockRSCs ? (
+ ) : (
+ 'Mock'
)
}
if (fieldConfig.admin?.components) {
if ('afterInput' in fieldConfig.admin.components) {
- fieldState.customComponents.AfterInput = RenderServerComponent({
- clientProps,
- Component: fieldConfig.admin.components.afterInput,
- importMap: req.payload.importMap,
- key: 'field.admin.components.afterInput',
- serverProps,
- })
+ fieldState.customComponents.AfterInput = !mockRSCs
+ ? RenderServerComponent({
+ clientProps,
+ Component: fieldConfig.admin.components.afterInput,
+ importMap: req.payload.importMap,
+ key: 'field.admin.components.afterInput',
+ serverProps,
+ })
+ : 'Mock'
}
if ('beforeInput' in fieldConfig.admin.components) {
- fieldState.customComponents.BeforeInput = RenderServerComponent({
- clientProps,
- Component: fieldConfig.admin.components.beforeInput,
- importMap: req.payload.importMap,
- key: 'field.admin.components.beforeInput',
- serverProps,
- })
+ fieldState.customComponents.BeforeInput = !mockRSCs
+ ? RenderServerComponent({
+ clientProps,
+ Component: fieldConfig.admin.components.beforeInput,
+ importMap: req.payload.importMap,
+ key: 'field.admin.components.beforeInput',
+ serverProps,
+ })
+ : 'Mock'
}
if ('Description' in fieldConfig.admin.components) {
- fieldState.customComponents.Description = RenderServerComponent({
- clientProps,
- Component: fieldConfig.admin.components.Description,
- importMap: req.payload.importMap,
- key: 'field.admin.components.Description',
- serverProps,
- })
+ fieldState.customComponents.Description = !mockRSCs
+ ? RenderServerComponent({
+ clientProps,
+ Component: fieldConfig.admin.components.Description,
+ importMap: req.payload.importMap,
+ key: 'field.admin.components.Description',
+ serverProps,
+ })
+ : 'Mock'
}
if ('Error' in fieldConfig.admin.components) {
- fieldState.customComponents.Error = RenderServerComponent({
- clientProps,
- Component: fieldConfig.admin.components.Error,
- importMap: req.payload.importMap,
- key: 'field.admin.components.Error',
- serverProps,
- })
+ fieldState.customComponents.Error = !mockRSCs
+ ? RenderServerComponent({
+ clientProps,
+ Component: fieldConfig.admin.components.Error,
+ importMap: req.payload.importMap,
+ key: 'field.admin.components.Error',
+ serverProps,
+ })
+ : 'Mock'
}
if ('Label' in fieldConfig.admin.components) {
- fieldState.customComponents.Label = RenderServerComponent({
- clientProps,
- Component: fieldConfig.admin.components.Label,
- importMap: req.payload.importMap,
- key: 'field.admin.components.Label',
- serverProps,
- })
+ fieldState.customComponents.Label = !mockRSCs
+ ? RenderServerComponent({
+ clientProps,
+ Component: fieldConfig.admin.components.Label,
+ importMap: req.payload.importMap,
+ key: 'field.admin.components.Label',
+ serverProps,
+ })
+ : 'Mock'
}
if ('Field' in fieldConfig.admin.components) {
- fieldState.customComponents.Field = (
+ fieldState.customComponents.Field = !mockRSCs ? (
{RenderServerComponent({
clientProps,
@@ -314,6 +377,8 @@ export const renderField: RenderFieldMethod = ({
serverProps,
})}
+ ) : (
+ 'Mock'
)
}
}
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/types.ts b/packages/ui/src/forms/fieldSchemasToFormState/types.ts
index 71f44370d..1071dd596 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/types.ts
+++ b/packages/ui/src/forms/fieldSchemasToFormState/types.ts
@@ -21,6 +21,8 @@ export type RenderFieldArgs = {
formState: FormState
id?: number | string
indexPath: string
+ lastRenderedPath: string
+ mockRSCs?: boolean
operation: Operation
parentPath: string
parentSchemaPath: string
@@ -28,6 +30,7 @@ export type RenderFieldArgs = {
permissions: SanitizedFieldPermissions
preferences: DocumentPreferences
previousFieldState: FieldState
+ renderAllFields: boolean
req: PayloadRequest
schemaPath: string
siblingData: Data
diff --git a/packages/ui/src/providers/ListQuery/types.ts b/packages/ui/src/providers/ListQuery/types.ts
index 9ccdb7e51..33c0547c9 100644
--- a/packages/ui/src/providers/ListQuery/types.ts
+++ b/packages/ui/src/providers/ListQuery/types.ts
@@ -41,8 +41,8 @@ export type IListQueryContext = {
data: PaginatedDocs
defaultLimit?: number
defaultSort?: Sort
- orderableFieldName?: string
modified: boolean
+ orderableFieldName?: string
query: ListQuery
refineListData: (args: ListQuery, setModified?: boolean) => Promise
setModified: (modified: boolean) => void
diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts
index 8127be5d3..e92d1ca35 100644
--- a/packages/ui/src/utilities/buildFormState.ts
+++ b/packages/ui/src/utilities/buildFormState.ts
@@ -107,6 +107,7 @@ export const buildFormState = async (
globalSlug,
initialBlockData,
initialBlockFormState,
+ mockRSCs,
operation,
renderAllFields,
req,
@@ -205,6 +206,7 @@ export const buildFormState = async (
fields,
fieldSchemaMap: schemaMap,
initialBlockData: blockData,
+ mockRSCs,
operation,
permissions: docPermissions?.fields || {},
preferences: docPreferences || { fields: {} },
diff --git a/test/database/up-down-migration/migrations/20250328_185055.ts b/test/database/up-down-migration/migrations/20250328_185055.ts
index 02bc61092..3e6ffda86 100644
--- a/test/database/up-down-migration/migrations/20250328_185055.ts
+++ b/test/database/up-down-migration/migrations/20250328_185055.ts
@@ -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'
diff --git a/test/form-state/collections/Posts/ArrayRowLabel.tsx b/test/form-state/collections/Posts/ArrayRowLabel.tsx
new file mode 100644
index 000000000..f0d95f337
--- /dev/null
+++ b/test/form-state/collections/Posts/ArrayRowLabel.tsx
@@ -0,0 +1,5 @@
+import React from 'react'
+
+export const ArrayRowLabel = () => {
+ return This is a custom component
+}
diff --git a/test/form-state/collections/Posts/index.ts b/test/form-state/collections/Posts/index.ts
index c82c47b41..577896b05 100644
--- a/test/form-state/collections/Posts/index.ts
+++ b/test/form-state/collections/Posts/index.ts
@@ -69,6 +69,11 @@ export const PostsCollection: CollectionConfig = {
{
name: 'array',
type: 'array',
+ admin: {
+ components: {
+ RowLabel: './collections/Posts/ArrayRowLabel.js#ArrayRowLabel',
+ },
+ },
fields: [
{
name: 'richText',
diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts
index 7c3ed9c0f..ac5a23353 100644
--- a/test/form-state/e2e.spec.ts
+++ b/test/form-state/e2e.spec.ts
@@ -20,6 +20,7 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.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 dirname = path.dirname(filename)
@@ -36,7 +37,7 @@ test.describe('Form State', () => {
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
- postsUrl = new AdminUrlUtil(serverURL, 'posts')
+ postsUrl = new AdminUrlUtil(serverURL, postsSlug)
context = await browser.newContext()
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 () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
@@ -182,7 +241,7 @@ test.describe('Form State', () => {
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)
const field = page.locator('#field-title')
await field.fill('Test')
@@ -193,23 +252,12 @@ test.describe('Form State', () => {
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(
page,
postsUrl.create,
async () => {
- // Ensure `requiresRender` is `true` is set for the first 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,
- })
-
- // 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,
- })
+ await page.locator('#field-array .array-field__add-row').click()
+ await page.locator('#field-title').fill('Test 2')
// use `waitForSelector` to ensure the element doesn't appear and then disappear
// eslint-disable-next-line playwright/no-wait-for-selector
@@ -237,7 +285,7 @@ test.describe('Form State', () => {
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)
const field = page.locator('#field-title')
await field.fill('Test')
@@ -254,17 +302,8 @@ test.describe('Form State', () => {
page,
postsUrl.create,
async () => {
- // Ensure `requiresRender` is `true` is set for the first 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,
- })
-
- // 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,
- })
+ await page.locator('#field-array .array-field__add-row').click()
+ await page.locator('#field-array .array-field__add-row').click()
// use `waitForSelector` to ensure the element doesn't appear and then disappear
// 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', {
offline: false,
latency: 0,
diff --git a/test/form-state/int.spec.ts b/test/form-state/int.spec.ts
index 532cbc809..1deb4ff90 100644
--- a/test/form-state/int.spec.ts
+++ b/test/form-state/int.spec.ts
@@ -21,11 +21,8 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Form State', () => {
- // --__--__--__--__--__--__--__--__--__
- // Boilerplate test setup/teardown
- // --__--__--__--__--__--__--__--__--__
beforeAll(async () => {
- ;({ payload, restClient } = await initPayloadInt(dirname))
+ ;({ payload, restClient } = await initPayloadInt(dirname, undefined, true))
const data = await restClient
.POST('/users/login', {
@@ -57,6 +54,7 @@ describe('Form State', () => {
})
const { state } = await buildFormState({
+ mockRSCs: true,
id: postData.id,
collectionSlug: postsSlug,
data: postData,
@@ -95,7 +93,6 @@ describe('Form State', () => {
validateUsingEvent: {},
blocks: {
initialValue: 0,
- requiresRender: false,
rows: [],
value: 0,
},
@@ -113,6 +110,7 @@ describe('Form State', () => {
})
const { state } = await buildFormState({
+ mockRSCs: true,
id: postData.id,
collectionSlug: postsSlug,
data: postData,
@@ -134,9 +132,89 @@ describe('Form State', () => {
title: {
value: 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()
+ })
})
diff --git a/test/helpers/e2e/assertRequestBody.ts b/test/helpers/e2e/assertRequestBody.ts
index 987309279..a2e46c3c0 100644
--- a/test/helpers/e2e/assertRequestBody.ts
+++ b/test/helpers/e2e/assertRequestBody.ts
@@ -2,15 +2,38 @@ import type { Page } 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 (
page: Page,
options: {
action: Promise | void
expect?: (requestBody: T) => boolean | Promise
+ requestMethod?: string
+ url: string
},
): Promise => {
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,
])
diff --git a/test/helpers/e2e/assertResponseBody.ts b/test/helpers/e2e/assertResponseBody.ts
new file mode 100644
index 000000000..54e74ed40
--- /dev/null
+++ b/test/helpers/e2e/assertResponseBody.ts
@@ -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 (
+ page: Page,
+ options: {
+ action: Promise | void
+ expect?: (requestBody: T) => boolean | Promise
+ requestMethod?: string
+ responseContentType?: string
+ url?: string
+ },
+): Promise => {
+ 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
+}
diff --git a/test/queues/config.ts b/test/queues/config.ts
index 5bdc9c8c4..28d01a801 100644
--- a/test/queues/config.ts
+++ b/test/queues/config.ts
@@ -10,8 +10,10 @@ import { updatePostStep1, updatePostStep2 } from './runners/updatePost.js'
import { clearAndSeedEverything } from './seed.js'
import { externalWorkflow } from './workflows/externalWorkflow.js'
import { inlineTaskTestWorkflow } from './workflows/inlineTaskTest.js'
+import { inlineTaskTestDelayedWorkflow } from './workflows/inlineTaskTestDelayed.js'
import { longRunningWorkflow } from './workflows/longRunning.js'
import { noRetriesSetWorkflow } from './workflows/noRetriesSet.js'
+import { parallelTaskWorkflow } from './workflows/parallelTaskWorkflow.js'
import { retries0Workflow } from './workflows/retries0.js'
import { retriesBackoffTestWorkflow } from './workflows/retriesBackoffTest.js'
import { retriesRollbackTestWorkflow } from './workflows/retriesRollbackTest.js'
@@ -24,8 +26,6 @@ import { updatePostJSONWorkflow } from './workflows/updatePostJSON.js'
import { workflowAndTasksRetriesUndefinedWorkflow } from './workflows/workflowAndTasksRetriesUndefined.js'
import { workflowRetries2TasksRetries0Workflow } from './workflows/workflowRetries2TasksRetries0.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 dirname = path.dirname(filename)
diff --git a/test/queues/int.spec.ts b/test/queues/int.spec.ts
index 315e6581c..5e22c9b5a 100644
--- a/test/queues/int.spec.ts
+++ b/test/queues/int.spec.ts
@@ -1390,7 +1390,7 @@ describe('Queues', () => {
limit: amount,
depth: 0,
})
- expect(simpleDocs.docs.length).toBe(amount)
+ expect(simpleDocs.docs).toHaveLength(amount)
// Ensure all docs are created (= all tasks are run once)
for (let i = 1; i <= simpleDocs.docs.length; i++) {
diff --git a/tsconfig.base.json b/tsconfig.base.json
index e2b64e3bc..c9793d25c 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -31,7 +31,7 @@
}
],
"paths": {
- "@payload-config": ["./test/form-state/config.ts"],
+ "@payload-config": ["./test/_community/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],