fix(ui): stale paths on custom components within rows (#11973)

When server rendering custom components within form state, those
components receive a path that is correct at render time, but
potentially stale after manipulating array and blocks rows. This causes
the field to briefly render incorrect values while the form state
request is in flight.

The reason for this is that paths are passed as a prop statically into
those components. Then when we manipulate rows, form state is modified,
potentially changing field paths. The component's `path` prop, however,
hasn't changed. This means it temporarily points to the wrong field in
form state, rendering the data of another row until the server responds
with a freshly rendered component.

This is not an issue with default Payload fields as they are rendered on
the client and can be passed dynamic props.

This is only an issue within custom server components, including rich
text fields which are treated as custom components. Since they are
rendered on the server and passed to the client, props are inaccessible
after render.

The fix for this is to provide paths dynamically through context. This
way as we make changes to form state, there is a mechanism in which
server components can receive the updated path without waiting on its
props to update.
This commit is contained in:
Jacob Fletcher
2025-04-15 15:23:51 -04:00
committed by GitHub
parent e90ff72b37
commit 21599b87f5
40 changed files with 211 additions and 106 deletions

View File

@@ -20,8 +20,7 @@ const baseClass = 'fields-to-export'
export const FieldsToExport: SelectFieldClientComponent = (props) => { export const FieldsToExport: SelectFieldClientComponent = (props) => {
const { id } = useDocumentInfo() const { id } = useDocumentInfo()
const { path } = props const { setValue, value } = useField<string[]>()
const { setValue, value } = useField<string[]>({ path })
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' }) const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
const { getEntityConfig } = useConfig() const { getEntityConfig } = useConfig()
const { collection } = useImportExport() const { collection } = useImportExport()

View File

@@ -20,12 +20,12 @@ const baseClass = 'sort-by-fields'
export const SortBy: SelectFieldClientComponent = (props) => { export const SortBy: SelectFieldClientComponent = (props) => {
const { id } = useDocumentInfo() const { id } = useDocumentInfo()
const { path } = props const { setValue, value } = useField<string>()
const { setValue, value } = useField<string>({ path })
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' }) const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
const { query } = useListQuery() const { query } = useListQuery()
const { getEntityConfig } = useConfig() const { getEntityConfig } = useConfig()
const { collection } = useImportExport() const { collection } = useImportExport()
const [displayedValue, setDisplayedValue] = useState<{ const [displayedValue, setDisplayedValue] = useState<{
id: string id: string
label: ReactNode label: ReactNode

View File

@@ -11,6 +11,7 @@ export const WhereField: React.FC = () => {
const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({ const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({
path: 'selectionToUse', path: 'selectionToUse',
}) })
const { setValue } = useField({ path: 'where' }) const { setValue } = useField({ path: 'where' })
const { selectAll, selected } = useSelection() const { selectAll, selected } = useSelection()
const { query } = useListQuery() const { query } = useListQuery()

View File

@@ -16,8 +16,8 @@ type Props = {
} & RelationshipFieldClientProps } & RelationshipFieldClientProps
export const TenantField = (args: Props) => { export const TenantField = (args: Props) => {
const { debug, path, unique } = args const { debug, unique } = args
const { setValue, value } = useField<number | string>({ path }) const { setValue, value } = useField<number | string>()
const { options, selectedTenantID, setPreventRefreshOnChange, setTenant } = useTenantSelection() const { options, selectedTenantID, setPreventRefreshOnChange, setTenant } = useTenantSelection()
const hasSetValueRef = React.useRef(false) const hasSetValueRef = React.useRef(false)

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { FieldType, Options } from '@payloadcms/ui' import type { FieldType } from '@payloadcms/ui'
import type { TextareaFieldClientProps } from 'payload' import type { TextareaFieldClientProps } from 'payload'
import { import {
@@ -38,7 +38,6 @@ export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props)
required, required,
}, },
hasGenerateDescriptionFn, hasGenerateDescriptionFn,
path,
readOnly, readOnly,
} = props } = props
@@ -58,12 +57,14 @@ export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props)
const maxLength = maxLengthFromProps || maxLengthDefault const maxLength = maxLengthFromProps || maxLengthDefault
const minLength = minLengthFromProps || minLengthDefault const minLength = minLengthFromProps || minLengthDefault
const { customComponents, errorMessage, setValue, showError, value }: FieldType<string> = const {
useField({ customComponents: { AfterInput, BeforeInput, Label } = {},
path, errorMessage,
} as Options) path,
setValue,
const { AfterInput, BeforeInput, Label } = customComponents ?? {} showError,
value,
}: FieldType<string> = useField()
const regenerateDescription = useCallback(async () => { const regenerateDescription = useCallback(async () => {
if (!hasGenerateDescriptionFn) { if (!hasGenerateDescriptionFn) {

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { FieldType, Options } from '@payloadcms/ui' import type { FieldType } from '@payloadcms/ui'
import type { UploadFieldClientProps } from 'payload' import type { UploadFieldClientProps } from 'payload'
import { import {
@@ -30,9 +30,8 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
const { const {
field: { label, localized, relationTo, required }, field: { label, localized, relationTo, required },
hasGenerateImageFn, hasGenerateImageFn,
path,
readOnly, readOnly,
} = props || {} } = props
const { const {
config: { config: {
@@ -42,10 +41,14 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
getEntityConfig, getEntityConfig,
} = useConfig() } = useConfig()
const field: FieldType<string> = useField({ ...props, path } as Options) const {
const { customComponents } = field customComponents: { Error, Label } = {},
filterOptions,
const { Error, Label } = customComponents ?? {} path,
setValue,
showError,
value,
}: FieldType<string> = useField()
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>() const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
@@ -53,8 +56,6 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
const { getData } = useForm() const { getData } = useForm()
const docInfo = useDocumentInfo() const docInfo = useDocumentInfo()
const { setValue, showError, value } = field
const regenerateImage = useCallback(async () => { const regenerateImage = useCallback(async () => {
if (!hasGenerateImageFn) { if (!hasGenerateImageFn) {
return return
@@ -174,7 +175,7 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
api={api} api={api}
collection={collection} collection={collection}
Error={Error} Error={Error}
filterOptions={field.filterOptions} filterOptions={filterOptions}
onChange={(incomingImage) => { onChange={(incomingImage) => {
if (incomingImage !== null) { if (incomingImage !== null) {
if (typeof incomingImage === 'object') { if (typeof incomingImage === 'object') {

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { FieldType, Options } from '@payloadcms/ui' import type { FieldType } from '@payloadcms/ui'
import type { TextFieldClientProps } from 'payload' import type { TextFieldClientProps } from 'payload'
import { import {
@@ -33,9 +33,8 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
const { const {
field: { label, maxLength: maxLengthFromProps, minLength: minLengthFromProps, required }, field: { label, maxLength: maxLengthFromProps, minLength: minLengthFromProps, required },
hasGenerateTitleFn, hasGenerateTitleFn,
path,
readOnly, readOnly,
} = props || {} } = props
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>() const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
@@ -46,8 +45,14 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
}, },
} = useConfig() } = useConfig()
const field: FieldType<string> = useField({ path } as Options) const {
const { customComponents: { AfterInput, BeforeInput, Label } = {} } = field customComponents: { AfterInput, BeforeInput, Label } = {},
errorMessage,
path,
setValue,
showError,
value,
}: FieldType<string> = useField()
const locale = useLocale() const locale = useLocale()
const { getData } = useForm() const { getData } = useForm()
@@ -56,8 +61,6 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
const minLength = minLengthFromProps || minLengthDefault const minLength = minLengthFromProps || minLengthDefault
const maxLength = maxLengthFromProps || maxLengthDefault const maxLength = maxLengthFromProps || maxLengthDefault
const { errorMessage, setValue, showError, value } = field
const regenerateTitle = useCallback(async () => { const regenerateTitle = useCallback(async () => {
if (!hasGenerateTitleFn) { if (!hasGenerateTitleFn) {
return return

View File

@@ -36,7 +36,6 @@ const RichTextComponent: React.FC<
editorConfig, editorConfig,
field, field,
field: { field: {
name,
admin: { className, description, readOnly: readOnlyFromAdmin } = {}, admin: { className, description, readOnly: readOnlyFromAdmin } = {},
label, label,
localized, localized,
@@ -48,7 +47,6 @@ const RichTextComponent: React.FC<
} = props } = props
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
const path = pathFromProps ?? name
const editDepth = useEditDepth() const editDepth = useEditDepth()
@@ -70,11 +68,12 @@ const RichTextComponent: React.FC<
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled: disabledFromField, disabled: disabledFromField,
initialValue, initialValue,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField<SerializedEditorState>({ } = useField<SerializedEditorState>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -28,7 +28,6 @@ import type { LoadedSlateFieldProps } from './types.js'
import { defaultRichTextValue } from '../data/defaultValue.js' import { defaultRichTextValue } from '../data/defaultValue.js'
import { richTextValidate } from '../data/validation.js' import { richTextValidate } from '../data/validation.js'
import { listTypes } from './elements/listTypes.js' import { listTypes } from './elements/listTypes.js'
import './index.scss'
import { hotkeys } from './hotkeys.js' import { hotkeys } from './hotkeys.js'
import { toggleLeaf } from './leaves/toggle.js' import { toggleLeaf } from './leaves/toggle.js'
import { withEnterBreakOut } from './plugins/withEnterBreakOut.js' import { withEnterBreakOut } from './plugins/withEnterBreakOut.js'
@@ -37,6 +36,7 @@ import { ElementButtonProvider } from './providers/ElementButtonProvider.js'
import { ElementProvider } from './providers/ElementProvider.js' import { ElementProvider } from './providers/ElementProvider.js'
import { LeafButtonProvider } from './providers/LeafButtonProvider.js' import { LeafButtonProvider } from './providers/LeafButtonProvider.js'
import { LeafProvider } from './providers/LeafProvider.js' import { LeafProvider } from './providers/LeafProvider.js'
import './index.scss'
const baseClass = 'rich-text' const baseClass = 'rich-text'
@@ -66,7 +66,6 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
validate = richTextValidate, validate = richTextValidate,
} = props } = props
const path = pathFromProps ?? name
const schemaPath = schemaPathFromProps ?? name const schemaPath = schemaPathFromProps ?? name
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
@@ -97,11 +96,12 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
customComponents: { Description, Error, Label } = {}, customComponents: { Description, Error, Label } = {},
disabled: disabledFromField, disabled: disabledFromField,
initialValue, initialValue,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField({ } = useField({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -11,9 +11,8 @@ import './index.scss'
export const QueryPresetsColumnField: JSONFieldClientComponent = ({ export const QueryPresetsColumnField: JSONFieldClientComponent = ({
field: { label, required }, field: { label, required },
path,
}) => { }) => {
const { value } = useField({ path }) const { path, value } = useField()
return ( return (
<div className="field-type query-preset-columns-field"> <div className="field-type query-preset-columns-field">

View File

@@ -88,9 +88,8 @@ const transformWhereToNaturalLanguage = (
export const QueryPresetsWhereField: JSONFieldClientComponent = ({ export const QueryPresetsWhereField: JSONFieldClientComponent = ({
field: { label, required }, field: { label, required },
path,
}) => { }) => {
const { value } = useField({ path }) const { path, value } = useField()
const { collectionSlug } = useListQuery() const { collectionSlug } = useListQuery()
const { getEntityConfig } = useConfig() const { getEntityConfig } = useConfig()

View File

@@ -46,7 +46,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
required, required,
}, },
forceRender = false, forceRender = false,
path, path: pathFromProps,
permissions, permissions,
readOnly, readOnly,
schemaPath: schemaPathFromProps, schemaPath: schemaPathFromProps,
@@ -113,13 +113,14 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
errorPaths, errorPaths,
path,
rows = [], rows = [],
showError, showError,
valid, valid,
value, value,
} = useField<number>({ } = useField<number>({
hasRows: true, hasRows: true,
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -48,7 +48,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
minRows: minRowsProp, minRows: minRowsProp,
required, required,
}, },
path, path: pathFromProps,
permissions, permissions,
readOnly, readOnly,
schemaPath: schemaPathFromProps, schemaPath: schemaPathFromProps,
@@ -101,13 +101,14 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
errorPaths, errorPaths,
path,
rows = [], rows = [],
showError, showError,
valid, valid,
value, value,
} = useField<number>({ } = useField<number>({
hasRows: true, hasRows: true,
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -39,7 +39,7 @@ const CheckboxFieldComponent: CheckboxFieldClientComponent = (props) => {
} = {} as CheckboxFieldClientProps['field'], } = {} as CheckboxFieldClientProps['field'],
onChange: onChangeFromProps, onChange: onChangeFromProps,
partialChecked, partialChecked,
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -60,12 +60,13 @@ const CheckboxFieldComponent: CheckboxFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField({ } = useField({
disableFormData, disableFormData,
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -31,7 +31,7 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
required, required,
}, },
onMount, onMount,
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -48,11 +48,12 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField({ } = useField({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -33,7 +33,7 @@ const DateTimeFieldComponent: DateFieldClientComponent = (props) => {
required, required,
timezone, timezone,
}, },
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -59,11 +59,12 @@ const DateTimeFieldComponent: DateFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField<string>({ } = useField<string>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -16,8 +16,8 @@ import { withCondition } from '../../forms/withCondition/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { FieldLabel } from '../FieldLabel/index.js' import { FieldLabel } from '../FieldLabel/index.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js' import { mergeFieldStyles } from '../mergeFieldStyles.js'
import './index.scss'
import { fieldBaseClass } from '../shared/index.js' import { fieldBaseClass } from '../shared/index.js'
import './index.scss'
const EmailFieldComponent: EmailFieldClientComponent = (props) => { const EmailFieldComponent: EmailFieldClientComponent = (props) => {
const { const {
@@ -33,7 +33,7 @@ const EmailFieldComponent: EmailFieldClientComponent = (props) => {
localized, localized,
required, required,
} = {} as EmailFieldClientProps['field'], } = {} as EmailFieldClientProps['field'],
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -52,11 +52,12 @@ const EmailFieldComponent: EmailFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField({ } = useField({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -8,14 +8,15 @@ import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js' import { withCondition } from '../../forms/withCondition/index.js'
/** /**
* Renders an input with `type="hidden"`.
* This is mainly used to save a value on the form that is not visible to the user. * This is mainly used to save a value on the form that is not visible to the user.
* For example, this sets the `ìd` property of a block in the Blocks field. * For example, this sets the `ìd` property of a block in the Blocks field.
*/ */
const HiddenFieldComponent: React.FC<HiddenFieldProps> = (props) => { const HiddenFieldComponent: React.FC<HiddenFieldProps> = (props) => {
const { disableModifyingForm = true, path, value: valueFromProps } = props const { disableModifyingForm = true, path: pathFromProps, value: valueFromProps } = props
const { setValue, value } = useField({ const { path, setValue, value } = useField({
path, potentiallyStalePath: pathFromProps,
}) })
useEffect(() => { useEffect(() => {

View File

@@ -28,7 +28,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
localized, localized,
required, required,
}, },
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -50,11 +50,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
initialValue, initialValue,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField<string>({ } = useField<string>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -131,7 +131,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
on, on,
required, required,
}, },
path, path: pathFromProps,
} = props } = props
const { id: docID, docConfig } = useDocumentInfo() const { id: docID, docConfig } = useDocumentInfo()
@@ -140,10 +140,11 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
path,
showError, showError,
value, value,
} = useField<PaginatedDocs>({ } = useField<PaginatedDocs>({
path, potentiallyStalePath: pathFromProps,
}) })
const filterOptions: null | Where = useMemo(() => { const filterOptions: null | Where = useMemo(() => {

View File

@@ -38,7 +38,7 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => {
required, required,
}, },
onChange: onChangeFromProps, onChange: onChangeFromProps,
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -57,11 +57,12 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField<number | number[]>({ } = useField<number | number[]>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -26,7 +26,7 @@ export const PointFieldComponent: PointFieldClientComponent = (props) => {
localized, localized,
required, required,
}, },
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -45,11 +45,12 @@ export const PointFieldComponent: PointFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value = [null, null], value = [null, null],
} = useField<[number, number]>({ } = useField<[number, number]>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -34,7 +34,7 @@ const RadioGroupFieldComponent: RadioFieldClientComponent = (props) => {
required, required,
} = {} as RadioFieldClientProps['field'], } = {} as RadioFieldClientProps['field'],
onChange: onChangeFromProps, onChange: onChangeFromProps,
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
value: valueFromProps, value: valueFromProps,
@@ -54,11 +54,12 @@ const RadioGroupFieldComponent: RadioFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value: valueFromContext, value: valueFromContext,
} = useField<string>({ } = useField<string>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -64,7 +64,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
relationTo, relationTo,
required, required,
}, },
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -112,11 +112,12 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
disabled, disabled,
filterOptions, filterOptions,
initialValue, initialValue,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField<Value | Value[]>({ } = useField<Value | Value[]>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -46,7 +46,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => {
required, required,
}, },
onChange: onChangeFromProps, onChange: onChangeFromProps,
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -65,11 +65,12 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField({ } = useField({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -269,15 +269,13 @@ function TabContent({
parentIndexPath, parentIndexPath,
parentPath, parentPath,
parentSchemaPath, parentSchemaPath,
path,
permissions, permissions,
readOnly, readOnly,
}: ActiveTabProps) { }: ActiveTabProps) {
const { i18n } = useTranslation() const { i18n } = useTranslation()
const { customComponents: { AfterInput, BeforeInput, Description, Field } = {} } = useField({ const { customComponents: { AfterInput, BeforeInput, Description, Field } = {}, path } =
path, useField()
})
if (Field) { if (Field) {
return Field return Field

View File

@@ -32,7 +32,7 @@ const TextFieldComponent: TextFieldClientComponent = (props) => {
required, required,
}, },
inputRef, inputRef,
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -55,11 +55,12 @@ const TextFieldComponent: TextFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField({ } = useField({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -29,7 +29,7 @@ const TextareaFieldComponent: TextareaFieldClientComponent = (props) => {
minLength, minLength,
required, required,
}, },
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -61,11 +61,12 @@ const TextareaFieldComponent: TextareaFieldClientComponent = (props) => {
const { const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField<string>({ } = useField<string>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -28,7 +28,7 @@ export function UploadComponent(props: UploadFieldClientProps) {
relationTo, relationTo,
required, required,
}, },
path, path: pathFromProps,
readOnly, readOnly,
validate, validate,
} = props } = props
@@ -50,11 +50,12 @@ export function UploadComponent(props: UploadFieldClientProps) {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled, disabled,
filterOptions, filterOptions,
path,
setValue, setValue,
showError, showError,
value, value,
} = useField<string | string[]>({ } = useField<string | string[]>({
path, potentiallyStalePath: pathFromProps,
validate: memoizedValidate, validate: memoizedValidate,
}) })

View File

@@ -0,0 +1,14 @@
import React from 'react'
export const FieldPathContext = React.createContext<string>(undefined)
export const useFieldPath = () => {
const context = React.useContext(FieldPathContext)
if (!context) {
// swallow the error, not all fields are wrapped in a FieldPathContext
return undefined
}
return context
}

View File

@@ -7,8 +7,9 @@ import type { RenderFieldsProps } from './types.js'
import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js' import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js'
import { useOperation } from '../../providers/Operation/index.js' import { useOperation } from '../../providers/Operation/index.js'
import { RenderField } from './RenderField.js' import { FieldPathContext } from './context.js'
import './index.scss' import './index.scss'
import { RenderField } from './RenderField.js'
const baseClass = 'render-fields' const baseClass = 'render-fields'
@@ -90,18 +91,19 @@ export const RenderFields: React.FC<RenderFieldsProps> = (props) => {
}) })
return ( return (
<RenderField <FieldPathContext key={`${path}-${i}`} value={path}>
clientFieldConfig={field} <RenderField
forceRender={forceRender} clientFieldConfig={field}
indexPath={indexPath} forceRender={forceRender}
key={`${path}-${i}`} indexPath={indexPath}
parentPath={parentPath} parentPath={parentPath}
parentSchemaPath={parentSchemaPath} parentSchemaPath={parentSchemaPath}
path={path} path={path}
permissions={fieldPermissions} permissions={fieldPermissions}
readOnly={isReadOnly} readOnly={isReadOnly}
schemaPath={schemaPath} schemaPath={schemaPath}
/> />
</FieldPathContext>
) )
})} })}
</RenderIfInViewport> </RenderIfInViewport>

View File

@@ -23,14 +23,27 @@ import {
useFormProcessing, useFormProcessing,
useFormSubmitted, useFormSubmitted,
} from '../Form/context.js' } from '../Form/context.js'
import { useFieldPath } from '../RenderFields/context.js'
/** /**
* Get and set the value of a form field. * Get and set the value of a form field.
* *
* @see https://payloadcms.com/docs/admin/react-hooks#usefield * @see https://payloadcms.com/docs/admin/react-hooks#usefield
*/ */
export const useField = <TValue,>(options: Options): FieldType<TValue> => { export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
const { disableFormData = false, hasRows, path, validate } = options const {
disableFormData = false,
hasRows,
path: pathFromOptions,
potentiallyStalePath,
validate,
} = options || {}
const pathFromContext = useFieldPath()
// This is a workaround for stale props given to server rendered components.
// See the notes in the `potentiallyStalePath` type definition for more details.
const path = pathFromOptions || pathFromContext || potentiallyStalePath
const submitted = useFormSubmitted() const submitted = useFormSubmitted()
const processing = useFormProcessing() const processing = useFormProcessing()

View File

@@ -3,7 +3,27 @@ import type { FieldState, FilterOptionsResult, Row, Validate } from 'payload'
export type Options = { export type Options = {
disableFormData?: boolean disableFormData?: boolean
hasRows?: boolean hasRows?: boolean
path: string /**
* If `path` is provided to this hook, it will be used outright. This is useful when calling this hook directly within a custom component.
* Otherwise, the field will attempt to get the path from the `FieldPathContext` via the `useFieldPath` hook.
* If still not found, the `potentiallyStalePath` arg will be used. See the note below about why this is important.
*/
path?: string
/**
* Custom server components receive a static `path` prop at render-time, leading to temporarily stale paths when re-ordering rows in form state.
* This is because when manipulating rows, field paths change in form state, but the prop remains the same until the component is re-rendered on the server.
* This causes the component to temporarily point to the wrong field in form state until the server responds with a freshly rendered component.
* To prevent this, fields are wrapped with a `FieldPathContext` which is guaranteed to be up-to-date.
* The `path` prop that Payload's default fields receive, then, are sent into this hook as the `potentiallyStalePath` arg.
* This ensures that:
* 1. Custom components that use this hook directly will still respect the `path` prop as top priority.
* 2. Custom server components that blindly spread their props into default Payload fields still prefer the dynamic path from context.
* 3. Components that render default Payload fields directly do not require a `FieldPathProvider`, e.g. the email field in the account view.
*/
potentiallyStalePath?: string
/**
* Client-side validation function fired when the form is submitted.
*/
validate?: Validate validate?: Validate
} }
@@ -17,6 +37,7 @@ export type FieldType<T> = {
formProcessing: boolean formProcessing: boolean
formSubmitted: boolean formSubmitted: boolean
initialValue?: T initialValue?: T
path: string
readOnly?: boolean readOnly?: boolean
rows?: Row[] rows?: Row[]
setValue: (val: unknown, disableModifyingForm?: boolean) => void setValue: (val: unknown, disableModifyingForm?: boolean) => void

View File

@@ -57,7 +57,10 @@ describe('Field Error States', () => {
// add third child array // add third child array
await page.locator('#parentArray-row-0 .collapsible__content .array-field__add-row').click() await page.locator('#parentArray-row-0 .collapsible__content .array-field__add-row').click()
// remove the row
await page.locator('#parentArray-0-childArray-row-2 .array-actions__button').click() await page.locator('#parentArray-0-childArray-row-2 .array-actions__button').click()
await page await page
.locator('#parentArray-0-childArray-row-2 .array-actions__action.array-actions__remove') .locator('#parentArray-0-childArray-row-2 .array-actions__action.array-actions__remove')
.click() .click()
@@ -68,6 +71,7 @@ describe('Field Error States', () => {
'#parentArray-row-0 > .collapsible > .collapsible__toggle-wrap .array-field__row-error-pill', '#parentArray-row-0 > .collapsible > .collapsible__toggle-wrap .array-field__row-error-pill',
{ state: 'hidden', timeout: 500 }, { state: 'hidden', timeout: 500 },
) )
expect(errorPill).toBeNull() expect(errorPill).toBeNull()
}) })
@@ -77,13 +81,11 @@ describe('Field Error States', () => {
await saveDocAndAssert(page, '#action-save-draft') await saveDocAndAssert(page, '#action-save-draft')
}) })
// eslint-disable-next-line playwright/expect-expect
test('should validate drafts when enabled', async () => { test('should validate drafts when enabled', async () => {
await page.goto(validateDraftsOn.create) await page.goto(validateDraftsOn.create)
await saveDocAndAssert(page, '#action-save-draft', 'error') await saveDocAndAssert(page, '#action-save-draft', 'error')
}) })
// eslint-disable-next-line playwright/expect-expect
test('should show validation errors when validate and autosave are enabled', async () => { test('should show validation errors when validate and autosave are enabled', async () => {
await page.goto(validateDraftsOnAutosave.create) await page.goto(validateDraftsOnAutosave.create)
await page.locator('#field-title').fill('valid') await page.locator('#field-title').fill('valid')

View File

@@ -4,12 +4,12 @@ import * as React from 'react'
import { collection1Slug } from '../slugs.js' import { collection1Slug } from '../slugs.js'
export const PrePopulateFieldUI: React.FC<{ export const PopulateFieldButton: React.FC<{
hasMany?: boolean hasMany?: boolean
hasMultipleRelations?: boolean hasMultipleRelations?: boolean
path?: string path?: string
targetFieldPath: string targetFieldPath: string
}> = ({ hasMany = true, hasMultipleRelations = false, path, targetFieldPath }) => { }> = ({ hasMany = true, hasMultipleRelations = false, targetFieldPath }) => {
const { setValue } = useField({ path: targetFieldPath }) const { setValue } = useField({ path: targetFieldPath })
const addDefaults = React.useCallback(() => { const addDefaults = React.useCallback(() => {

View File

@@ -19,7 +19,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: { admin: {
components: { components: {
Field: { Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: { clientProps: {
hasMany: false, hasMany: false,
hasMultipleRelations: false, hasMultipleRelations: false,
@@ -50,7 +50,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: { admin: {
components: { components: {
Field: { Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: { clientProps: {
hasMultipleRelations: false, hasMultipleRelations: false,
targetFieldPath: 'relationHasMany', targetFieldPath: 'relationHasMany',
@@ -80,7 +80,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = {
admin: { admin: {
components: { components: {
Field: { Field: {
path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', path: '/PopulateFieldButton/index.js#PopulateFieldButton',
clientProps: { clientProps: {
hasMultipleRelations: true, hasMultipleRelations: true,
targetFieldPath: 'relationToManyHasMany', targetFieldPath: 'relationToManyHasMany',

View File

@@ -83,6 +83,10 @@ export const PostsCollection: CollectionConfig = {
}, },
}, },
}, },
{
name: 'defaultTextField',
type: 'text',
},
], ],
}, },
], ],

View File

@@ -213,7 +213,37 @@ test.describe('Form State', () => {
}) })
}) })
test('new rows should contain default values', async () => { test('should not render stale values for server components while form state is in flight', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-array #array-row-0 #field-array__0__customTextField').fill('1')
await page.locator('#field-array .array-field__add-row').click()
await page.locator('#field-array #array-row-1 #field-array__1__customTextField').fill('2')
// block the next form state request from firing to ensure the field remains in stale state
await page.route(postsUrl.create, async (route) => {
if (route.request().method() === 'POST' && route.request().url() === postsUrl.create) {
await route.abort()
}
await route.continue()
})
// remove the first row
await page.locator('#field-array #array-row-0 .array-actions__button').click()
await page
.locator('#field-array #array-row-0 .array-actions__action.array-actions__remove')
.click()
await expect(
page.locator('#field-array #array-row-0 #field-array__0__customTextField'),
).toHaveValue('2')
})
test('should queue onChange functions', async () => {
await page.goto(postsUrl.create) await page.goto(postsUrl.create)
await page.locator('#field-array .array-field__add-row').click() await page.locator('#field-array .array-field__add-row').click()
await expect( await expect(

View File

@@ -144,6 +144,7 @@ export interface Post {
array?: array?:
| { | {
customTextField?: string | null; customTextField?: string | null;
defaultTextField?: string | null;
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
@@ -254,6 +255,7 @@ export interface PostsSelect<T extends boolean = true> {
| T | T
| { | {
customTextField?: T; customTextField?: T;
defaultTextField?: T;
id?: T; id?: T;
}; };
updatedAt?: T; updatedAt?: T;

View File

@@ -31,7 +31,7 @@
} }
], ],
"paths": { "paths": {
"@payload-config": ["./test/_community/config.ts"], "@payload-config": ["./test/form-state/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"],