From 21599b87f5159b394d6b0b1784ac87e1829f0bce Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Tue, 15 Apr 2025 15:23:51 -0400 Subject: [PATCH] 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. --- .../src/components/FieldsToExport/index.tsx | 3 +- .../src/components/SortBy/index.tsx | 4 +-- .../src/components/WhereField/index.tsx | 1 + .../components/TenantField/index.client.tsx | 4 +-- .../MetaDescriptionComponent.tsx | 17 +++++----- .../fields/MetaImage/MetaImageComponent.tsx | 21 ++++++------ .../fields/MetaTitle/MetaTitleComponent.tsx | 17 ++++++---- packages/richtext-lexical/src/field/Field.tsx | 5 ++- .../richtext-slate/src/field/RichText.tsx | 6 ++-- .../fields/ColumnsField/index.tsx | 3 +- .../QueryPresets/fields/WhereField/index.tsx | 3 +- packages/ui/src/fields/Array/index.tsx | 5 +-- packages/ui/src/fields/Blocks/index.tsx | 5 +-- packages/ui/src/fields/Checkbox/index.tsx | 5 +-- packages/ui/src/fields/Code/index.tsx | 5 +-- packages/ui/src/fields/DateTime/index.tsx | 5 +-- packages/ui/src/fields/Email/index.tsx | 7 ++-- packages/ui/src/fields/Hidden/index.tsx | 7 ++-- packages/ui/src/fields/JSON/index.tsx | 5 +-- packages/ui/src/fields/Join/index.tsx | 5 +-- packages/ui/src/fields/Number/index.tsx | 5 +-- packages/ui/src/fields/Point/index.tsx | 5 +-- packages/ui/src/fields/RadioGroup/index.tsx | 5 +-- packages/ui/src/fields/Relationship/index.tsx | 5 +-- packages/ui/src/fields/Select/index.tsx | 5 +-- packages/ui/src/fields/Tabs/index.tsx | 6 ++-- packages/ui/src/fields/Text/index.tsx | 5 +-- packages/ui/src/fields/Textarea/index.tsx | 5 +-- packages/ui/src/fields/Upload/index.tsx | 5 +-- packages/ui/src/forms/RenderFields/context.ts | 14 ++++++++ packages/ui/src/forms/RenderFields/index.tsx | 28 ++++++++-------- packages/ui/src/forms/useField/index.tsx | 17 ++++++++-- packages/ui/src/forms/useField/types.ts | 23 ++++++++++++- test/field-error-states/e2e.spec.ts | 6 ++-- .../index.tsx | 4 +-- .../collections/UpdatedExternally/index.ts | 6 ++-- test/form-state/collections/Posts/index.ts | 4 +++ test/form-state/e2e.spec.ts | 32 ++++++++++++++++++- test/form-state/payload-types.ts | 2 ++ tsconfig.base.json | 2 +- 40 files changed, 211 insertions(+), 106 deletions(-) create mode 100644 packages/ui/src/forms/RenderFields/context.ts rename test/fields-relationship/{PrePopulateFieldUI => PopulateFieldButton}/index.tsx (91%) diff --git a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx index 1a33f1fe1e..67461e1678 100644 --- a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx +++ b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx @@ -20,8 +20,7 @@ const baseClass = 'fields-to-export' export const FieldsToExport: SelectFieldClientComponent = (props) => { const { id } = useDocumentInfo() - const { path } = props - const { setValue, value } = useField({ path }) + const { setValue, value } = useField() const { value: collectionSlug } = useField({ path: 'collectionSlug' }) const { getEntityConfig } = useConfig() const { collection } = useImportExport() diff --git a/packages/plugin-import-export/src/components/SortBy/index.tsx b/packages/plugin-import-export/src/components/SortBy/index.tsx index 75d7cbb297..0d06a21162 100644 --- a/packages/plugin-import-export/src/components/SortBy/index.tsx +++ b/packages/plugin-import-export/src/components/SortBy/index.tsx @@ -20,12 +20,12 @@ const baseClass = 'sort-by-fields' export const SortBy: SelectFieldClientComponent = (props) => { const { id } = useDocumentInfo() - const { path } = props - const { setValue, value } = useField({ path }) + const { setValue, value } = useField() const { value: collectionSlug } = useField({ path: 'collectionSlug' }) const { query } = useListQuery() const { getEntityConfig } = useConfig() const { collection } = useImportExport() + const [displayedValue, setDisplayedValue] = useState<{ id: string label: ReactNode diff --git a/packages/plugin-import-export/src/components/WhereField/index.tsx b/packages/plugin-import-export/src/components/WhereField/index.tsx index 02c1d3db38..68f4daf653 100644 --- a/packages/plugin-import-export/src/components/WhereField/index.tsx +++ b/packages/plugin-import-export/src/components/WhereField/index.tsx @@ -11,6 +11,7 @@ export const WhereField: React.FC = () => { const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({ path: 'selectionToUse', }) + const { setValue } = useField({ path: 'where' }) const { selectAll, selected } = useSelection() const { query } = useListQuery() diff --git a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx index 4f6605e60b..b0a8bf0b90 100644 --- a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx +++ b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx @@ -16,8 +16,8 @@ type Props = { } & RelationshipFieldClientProps export const TenantField = (args: Props) => { - const { debug, path, unique } = args - const { setValue, value } = useField({ path }) + const { debug, unique } = args + const { setValue, value } = useField() const { options, selectedTenantID, setPreventRefreshOnChange, setTenant } = useTenantSelection() const hasSetValueRef = React.useRef(false) diff --git a/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx b/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx index 5a544a9f30..7b93ff3777 100644 --- a/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx @@ -1,6 +1,6 @@ 'use client' -import type { FieldType, Options } from '@payloadcms/ui' +import type { FieldType } from '@payloadcms/ui' import type { TextareaFieldClientProps } from 'payload' import { @@ -38,7 +38,6 @@ export const MetaDescriptionComponent: React.FC = (props) required, }, hasGenerateDescriptionFn, - path, readOnly, } = props @@ -58,12 +57,14 @@ export const MetaDescriptionComponent: React.FC = (props) const maxLength = maxLengthFromProps || maxLengthDefault const minLength = minLengthFromProps || minLengthDefault - const { customComponents, errorMessage, setValue, showError, value }: FieldType = - useField({ - path, - } as Options) - - const { AfterInput, BeforeInput, Label } = customComponents ?? {} + const { + customComponents: { AfterInput, BeforeInput, Label } = {}, + errorMessage, + path, + setValue, + showError, + value, + }: FieldType = useField() const regenerateDescription = useCallback(async () => { if (!hasGenerateDescriptionFn) { diff --git a/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx b/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx index 987c4a2041..acfc2aaef9 100644 --- a/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx @@ -1,6 +1,6 @@ 'use client' -import type { FieldType, Options } from '@payloadcms/ui' +import type { FieldType } from '@payloadcms/ui' import type { UploadFieldClientProps } from 'payload' import { @@ -30,9 +30,8 @@ export const MetaImageComponent: React.FC = (props) => { const { field: { label, localized, relationTo, required }, hasGenerateImageFn, - path, readOnly, - } = props || {} + } = props const { config: { @@ -42,10 +41,14 @@ export const MetaImageComponent: React.FC = (props) => { getEntityConfig, } = useConfig() - const field: FieldType = useField({ ...props, path } as Options) - const { customComponents } = field - - const { Error, Label } = customComponents ?? {} + const { + customComponents: { Error, Label } = {}, + filterOptions, + path, + setValue, + showError, + value, + }: FieldType = useField() const { t } = useTranslation() @@ -53,8 +56,6 @@ export const MetaImageComponent: React.FC = (props) => { const { getData } = useForm() const docInfo = useDocumentInfo() - const { setValue, showError, value } = field - const regenerateImage = useCallback(async () => { if (!hasGenerateImageFn) { return @@ -174,7 +175,7 @@ export const MetaImageComponent: React.FC = (props) => { api={api} collection={collection} Error={Error} - filterOptions={field.filterOptions} + filterOptions={filterOptions} onChange={(incomingImage) => { if (incomingImage !== null) { if (typeof incomingImage === 'object') { diff --git a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx index e1496ca63d..4d7e57b119 100644 --- a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx @@ -1,6 +1,6 @@ 'use client' -import type { FieldType, Options } from '@payloadcms/ui' +import type { FieldType } from '@payloadcms/ui' import type { TextFieldClientProps } from 'payload' import { @@ -33,9 +33,8 @@ export const MetaTitleComponent: React.FC = (props) => { const { field: { label, maxLength: maxLengthFromProps, minLength: minLengthFromProps, required }, hasGenerateTitleFn, - path, readOnly, - } = props || {} + } = props const { t } = useTranslation() @@ -46,8 +45,14 @@ export const MetaTitleComponent: React.FC = (props) => { }, } = useConfig() - const field: FieldType = useField({ path } as Options) - const { customComponents: { AfterInput, BeforeInput, Label } = {} } = field + const { + customComponents: { AfterInput, BeforeInput, Label } = {}, + errorMessage, + path, + setValue, + showError, + value, + }: FieldType = useField() const locale = useLocale() const { getData } = useForm() @@ -56,8 +61,6 @@ export const MetaTitleComponent: React.FC = (props) => { const minLength = minLengthFromProps || minLengthDefault const maxLength = maxLengthFromProps || maxLengthDefault - const { errorMessage, setValue, showError, value } = field - const regenerateTitle = useCallback(async () => { if (!hasGenerateTitleFn) { return diff --git a/packages/richtext-lexical/src/field/Field.tsx b/packages/richtext-lexical/src/field/Field.tsx index 65edb6ede5..1f5bc28257 100644 --- a/packages/richtext-lexical/src/field/Field.tsx +++ b/packages/richtext-lexical/src/field/Field.tsx @@ -36,7 +36,6 @@ const RichTextComponent: React.FC< editorConfig, field, field: { - name, admin: { className, description, readOnly: readOnlyFromAdmin } = {}, label, localized, @@ -48,7 +47,6 @@ const RichTextComponent: React.FC< } = props const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin - const path = pathFromProps ?? name const editDepth = useEditDepth() @@ -70,11 +68,12 @@ const RichTextComponent: React.FC< customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled: disabledFromField, initialValue, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/richtext-slate/src/field/RichText.tsx b/packages/richtext-slate/src/field/RichText.tsx index 1effc7eb09..fbcb1df135 100644 --- a/packages/richtext-slate/src/field/RichText.tsx +++ b/packages/richtext-slate/src/field/RichText.tsx @@ -28,7 +28,6 @@ import type { LoadedSlateFieldProps } from './types.js' import { defaultRichTextValue } from '../data/defaultValue.js' import { richTextValidate } from '../data/validation.js' import { listTypes } from './elements/listTypes.js' -import './index.scss' import { hotkeys } from './hotkeys.js' import { toggleLeaf } from './leaves/toggle.js' import { withEnterBreakOut } from './plugins/withEnterBreakOut.js' @@ -37,6 +36,7 @@ import { ElementButtonProvider } from './providers/ElementButtonProvider.js' import { ElementProvider } from './providers/ElementProvider.js' import { LeafButtonProvider } from './providers/LeafButtonProvider.js' import { LeafProvider } from './providers/LeafProvider.js' +import './index.scss' const baseClass = 'rich-text' @@ -66,7 +66,6 @@ const RichTextField: React.FC = (props) => { validate = richTextValidate, } = props - const path = pathFromProps ?? name const schemaPath = schemaPathFromProps ?? name const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin @@ -97,11 +96,12 @@ const RichTextField: React.FC = (props) => { customComponents: { Description, Error, Label } = {}, disabled: disabledFromField, initialValue, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx index 78570d6671..6c21780e5f 100644 --- a/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx +++ b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx @@ -11,9 +11,8 @@ import './index.scss' export const QueryPresetsColumnField: JSONFieldClientComponent = ({ field: { label, required }, - path, }) => { - const { value } = useField({ path }) + const { path, value } = useField() return (
diff --git a/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx index 564cbe82b1..a44fd868bb 100644 --- a/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx +++ b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx @@ -88,9 +88,8 @@ const transformWhereToNaturalLanguage = ( export const QueryPresetsWhereField: JSONFieldClientComponent = ({ field: { label, required }, - path, }) => { - const { value } = useField({ path }) + const { path, value } = useField() const { collectionSlug } = useListQuery() const { getEntityConfig } = useConfig() diff --git a/packages/ui/src/fields/Array/index.tsx b/packages/ui/src/fields/Array/index.tsx index ed2501903c..954937599b 100644 --- a/packages/ui/src/fields/Array/index.tsx +++ b/packages/ui/src/fields/Array/index.tsx @@ -46,7 +46,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => { required, }, forceRender = false, - path, + path: pathFromProps, permissions, readOnly, schemaPath: schemaPathFromProps, @@ -113,13 +113,14 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, errorPaths, + path, rows = [], showError, valid, value, } = useField({ hasRows: true, - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Blocks/index.tsx b/packages/ui/src/fields/Blocks/index.tsx index 0dea396448..99a8578083 100644 --- a/packages/ui/src/fields/Blocks/index.tsx +++ b/packages/ui/src/fields/Blocks/index.tsx @@ -48,7 +48,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => { minRows: minRowsProp, required, }, - path, + path: pathFromProps, permissions, readOnly, schemaPath: schemaPathFromProps, @@ -101,13 +101,14 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, errorPaths, + path, rows = [], showError, valid, value, } = useField({ hasRows: true, - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Checkbox/index.tsx b/packages/ui/src/fields/Checkbox/index.tsx index e3fc493cf6..2a685e6ea7 100644 --- a/packages/ui/src/fields/Checkbox/index.tsx +++ b/packages/ui/src/fields/Checkbox/index.tsx @@ -39,7 +39,7 @@ const CheckboxFieldComponent: CheckboxFieldClientComponent = (props) => { } = {} as CheckboxFieldClientProps['field'], onChange: onChangeFromProps, partialChecked, - path, + path: pathFromProps, readOnly, validate, } = props @@ -60,12 +60,13 @@ const CheckboxFieldComponent: CheckboxFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ disableFormData, - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Code/index.tsx b/packages/ui/src/fields/Code/index.tsx index 5a9f38e45b..143df342ef 100644 --- a/packages/ui/src/fields/Code/index.tsx +++ b/packages/ui/src/fields/Code/index.tsx @@ -31,7 +31,7 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => { required, }, onMount, - path, + path: pathFromProps, readOnly, validate, } = props @@ -48,11 +48,12 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/DateTime/index.tsx b/packages/ui/src/fields/DateTime/index.tsx index da801b937a..4afbc3c919 100644 --- a/packages/ui/src/fields/DateTime/index.tsx +++ b/packages/ui/src/fields/DateTime/index.tsx @@ -33,7 +33,7 @@ const DateTimeFieldComponent: DateFieldClientComponent = (props) => { required, timezone, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -59,11 +59,12 @@ const DateTimeFieldComponent: DateFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Email/index.tsx b/packages/ui/src/fields/Email/index.tsx index 305ad5b287..6cce6d753a 100644 --- a/packages/ui/src/fields/Email/index.tsx +++ b/packages/ui/src/fields/Email/index.tsx @@ -16,8 +16,8 @@ import { withCondition } from '../../forms/withCondition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { FieldLabel } from '../FieldLabel/index.js' import { mergeFieldStyles } from '../mergeFieldStyles.js' -import './index.scss' import { fieldBaseClass } from '../shared/index.js' +import './index.scss' const EmailFieldComponent: EmailFieldClientComponent = (props) => { const { @@ -33,7 +33,7 @@ const EmailFieldComponent: EmailFieldClientComponent = (props) => { localized, required, } = {} as EmailFieldClientProps['field'], - path, + path: pathFromProps, readOnly, validate, } = props @@ -52,11 +52,12 @@ const EmailFieldComponent: EmailFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Hidden/index.tsx b/packages/ui/src/fields/Hidden/index.tsx index 90c4e0f98e..3938e354d3 100644 --- a/packages/ui/src/fields/Hidden/index.tsx +++ b/packages/ui/src/fields/Hidden/index.tsx @@ -8,14 +8,15 @@ import { useField } from '../../forms/useField/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. * For example, this sets the `ìd` property of a block in the Blocks field. */ const HiddenFieldComponent: React.FC = (props) => { - const { disableModifyingForm = true, path, value: valueFromProps } = props + const { disableModifyingForm = true, path: pathFromProps, value: valueFromProps } = props - const { setValue, value } = useField({ - path, + const { path, setValue, value } = useField({ + potentiallyStalePath: pathFromProps, }) useEffect(() => { diff --git a/packages/ui/src/fields/JSON/index.tsx b/packages/ui/src/fields/JSON/index.tsx index 75754cb181..c0b80a670a 100644 --- a/packages/ui/src/fields/JSON/index.tsx +++ b/packages/ui/src/fields/JSON/index.tsx @@ -28,7 +28,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => { localized, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -50,11 +50,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, initialValue, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Join/index.tsx b/packages/ui/src/fields/Join/index.tsx index 1a75eff971..43610cb406 100644 --- a/packages/ui/src/fields/Join/index.tsx +++ b/packages/ui/src/fields/Join/index.tsx @@ -131,7 +131,7 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => { on, required, }, - path, + path: pathFromProps, } = props const { id: docID, docConfig } = useDocumentInfo() @@ -140,10 +140,11 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, + path, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, }) const filterOptions: null | Where = useMemo(() => { diff --git a/packages/ui/src/fields/Number/index.tsx b/packages/ui/src/fields/Number/index.tsx index 98be6bee36..c7f135a26a 100644 --- a/packages/ui/src/fields/Number/index.tsx +++ b/packages/ui/src/fields/Number/index.tsx @@ -38,7 +38,7 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => { required, }, onChange: onChangeFromProps, - path, + path: pathFromProps, readOnly, validate, } = props @@ -57,11 +57,12 @@ const NumberFieldComponent: NumberFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Point/index.tsx b/packages/ui/src/fields/Point/index.tsx index 3d3ec40a94..dbe86849fc 100644 --- a/packages/ui/src/fields/Point/index.tsx +++ b/packages/ui/src/fields/Point/index.tsx @@ -26,7 +26,7 @@ export const PointFieldComponent: PointFieldClientComponent = (props) => { localized, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -45,11 +45,12 @@ export const PointFieldComponent: PointFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value = [null, null], } = useField<[number, number]>({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/RadioGroup/index.tsx b/packages/ui/src/fields/RadioGroup/index.tsx index bb5746bda0..0542d55b94 100644 --- a/packages/ui/src/fields/RadioGroup/index.tsx +++ b/packages/ui/src/fields/RadioGroup/index.tsx @@ -34,7 +34,7 @@ const RadioGroupFieldComponent: RadioFieldClientComponent = (props) => { required, } = {} as RadioFieldClientProps['field'], onChange: onChangeFromProps, - path, + path: pathFromProps, readOnly, validate, value: valueFromProps, @@ -54,11 +54,12 @@ const RadioGroupFieldComponent: RadioFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value: valueFromContext, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index 85a897f2dd..449c1e1374 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -64,7 +64,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => relationTo, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -112,11 +112,12 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => disabled, filterOptions, initialValue, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Select/index.tsx b/packages/ui/src/fields/Select/index.tsx index f49af6fe6c..b8b67af5f9 100644 --- a/packages/ui/src/fields/Select/index.tsx +++ b/packages/ui/src/fields/Select/index.tsx @@ -46,7 +46,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => { required, }, onChange: onChangeFromProps, - path, + path: pathFromProps, readOnly, validate, } = props @@ -65,11 +65,12 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Tabs/index.tsx b/packages/ui/src/fields/Tabs/index.tsx index 5b7e9b6bf3..b8be36c96e 100644 --- a/packages/ui/src/fields/Tabs/index.tsx +++ b/packages/ui/src/fields/Tabs/index.tsx @@ -269,15 +269,13 @@ function TabContent({ parentIndexPath, parentPath, parentSchemaPath, - path, permissions, readOnly, }: ActiveTabProps) { const { i18n } = useTranslation() - const { customComponents: { AfterInput, BeforeInput, Description, Field } = {} } = useField({ - path, - }) + const { customComponents: { AfterInput, BeforeInput, Description, Field } = {}, path } = + useField() if (Field) { return Field diff --git a/packages/ui/src/fields/Text/index.tsx b/packages/ui/src/fields/Text/index.tsx index afa9aaa536..637b5efe4d 100644 --- a/packages/ui/src/fields/Text/index.tsx +++ b/packages/ui/src/fields/Text/index.tsx @@ -32,7 +32,7 @@ const TextFieldComponent: TextFieldClientComponent = (props) => { required, }, inputRef, - path, + path: pathFromProps, readOnly, validate, } = props @@ -55,11 +55,12 @@ const TextFieldComponent: TextFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Textarea/index.tsx b/packages/ui/src/fields/Textarea/index.tsx index f27695f369..3a3ffba6be 100644 --- a/packages/ui/src/fields/Textarea/index.tsx +++ b/packages/ui/src/fields/Textarea/index.tsx @@ -29,7 +29,7 @@ const TextareaFieldComponent: TextareaFieldClientComponent = (props) => { minLength, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -61,11 +61,12 @@ const TextareaFieldComponent: TextareaFieldClientComponent = (props) => { const { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/fields/Upload/index.tsx b/packages/ui/src/fields/Upload/index.tsx index 681e5c18e5..9fc43b2037 100644 --- a/packages/ui/src/fields/Upload/index.tsx +++ b/packages/ui/src/fields/Upload/index.tsx @@ -28,7 +28,7 @@ export function UploadComponent(props: UploadFieldClientProps) { relationTo, required, }, - path, + path: pathFromProps, readOnly, validate, } = props @@ -50,11 +50,12 @@ export function UploadComponent(props: UploadFieldClientProps) { customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {}, disabled, filterOptions, + path, setValue, showError, value, } = useField({ - path, + potentiallyStalePath: pathFromProps, validate: memoizedValidate, }) diff --git a/packages/ui/src/forms/RenderFields/context.ts b/packages/ui/src/forms/RenderFields/context.ts new file mode 100644 index 0000000000..d6d9da067d --- /dev/null +++ b/packages/ui/src/forms/RenderFields/context.ts @@ -0,0 +1,14 @@ +import React from 'react' + +export const FieldPathContext = React.createContext(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 +} diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index bf447d9511..ed662e202e 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -7,8 +7,9 @@ import type { RenderFieldsProps } from './types.js' import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js' import { useOperation } from '../../providers/Operation/index.js' -import { RenderField } from './RenderField.js' +import { FieldPathContext } from './context.js' import './index.scss' +import { RenderField } from './RenderField.js' const baseClass = 'render-fields' @@ -90,18 +91,19 @@ export const RenderFields: React.FC = (props) => { }) return ( - + + + ) })} diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index f3fc737df1..987b819957 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -23,14 +23,27 @@ import { useFormProcessing, useFormSubmitted, } from '../Form/context.js' +import { useFieldPath } from '../RenderFields/context.js' /** * Get and set the value of a form field. * * @see https://payloadcms.com/docs/admin/react-hooks#usefield */ -export const useField = (options: Options): FieldType => { - const { disableFormData = false, hasRows, path, validate } = options +export const useField = (options?: Options): FieldType => { + 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 processing = useFormProcessing() diff --git a/packages/ui/src/forms/useField/types.ts b/packages/ui/src/forms/useField/types.ts index 2f43cbab68..e91e60e8bd 100644 --- a/packages/ui/src/forms/useField/types.ts +++ b/packages/ui/src/forms/useField/types.ts @@ -3,7 +3,27 @@ import type { FieldState, FilterOptionsResult, Row, Validate } from 'payload' export type Options = { disableFormData?: 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 } @@ -17,6 +37,7 @@ export type FieldType = { formProcessing: boolean formSubmitted: boolean initialValue?: T + path: string readOnly?: boolean rows?: Row[] setValue: (val: unknown, disableModifyingForm?: boolean) => void diff --git a/test/field-error-states/e2e.spec.ts b/test/field-error-states/e2e.spec.ts index 3398d1b8fd..49bb09b559 100644 --- a/test/field-error-states/e2e.spec.ts +++ b/test/field-error-states/e2e.spec.ts @@ -57,7 +57,10 @@ describe('Field Error States', () => { // add third child array 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__action.array-actions__remove') .click() @@ -68,6 +71,7 @@ describe('Field Error States', () => { '#parentArray-row-0 > .collapsible > .collapsible__toggle-wrap .array-field__row-error-pill', { state: 'hidden', timeout: 500 }, ) + expect(errorPill).toBeNull() }) @@ -77,13 +81,11 @@ describe('Field Error States', () => { await saveDocAndAssert(page, '#action-save-draft') }) - // eslint-disable-next-line playwright/expect-expect test('should validate drafts when enabled', async () => { await page.goto(validateDraftsOn.create) 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 () => { await page.goto(validateDraftsOnAutosave.create) await page.locator('#field-title').fill('valid') diff --git a/test/fields-relationship/PrePopulateFieldUI/index.tsx b/test/fields-relationship/PopulateFieldButton/index.tsx similarity index 91% rename from test/fields-relationship/PrePopulateFieldUI/index.tsx rename to test/fields-relationship/PopulateFieldButton/index.tsx index 1ed66a3c2a..c9d8b51e9d 100644 --- a/test/fields-relationship/PrePopulateFieldUI/index.tsx +++ b/test/fields-relationship/PopulateFieldButton/index.tsx @@ -4,12 +4,12 @@ import * as React from 'react' import { collection1Slug } from '../slugs.js' -export const PrePopulateFieldUI: React.FC<{ +export const PopulateFieldButton: React.FC<{ hasMany?: boolean hasMultipleRelations?: boolean path?: string targetFieldPath: string -}> = ({ hasMany = true, hasMultipleRelations = false, path, targetFieldPath }) => { +}> = ({ hasMany = true, hasMultipleRelations = false, targetFieldPath }) => { const { setValue } = useField({ path: targetFieldPath }) const addDefaults = React.useCallback(() => { diff --git a/test/fields-relationship/collections/UpdatedExternally/index.ts b/test/fields-relationship/collections/UpdatedExternally/index.ts index 8d208a4000..8e2cd9f5e5 100644 --- a/test/fields-relationship/collections/UpdatedExternally/index.ts +++ b/test/fields-relationship/collections/UpdatedExternally/index.ts @@ -19,7 +19,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = { admin: { components: { Field: { - path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', + path: '/PopulateFieldButton/index.js#PopulateFieldButton', clientProps: { hasMany: false, hasMultipleRelations: false, @@ -50,7 +50,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = { admin: { components: { Field: { - path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', + path: '/PopulateFieldButton/index.js#PopulateFieldButton', clientProps: { hasMultipleRelations: false, targetFieldPath: 'relationHasMany', @@ -80,7 +80,7 @@ export const RelationshipUpdatedExternally: CollectionConfig = { admin: { components: { Field: { - path: '/PrePopulateFieldUI/index.js#PrePopulateFieldUI', + path: '/PopulateFieldButton/index.js#PopulateFieldButton', clientProps: { hasMultipleRelations: true, targetFieldPath: 'relationToManyHasMany', diff --git a/test/form-state/collections/Posts/index.ts b/test/form-state/collections/Posts/index.ts index 14550604fd..54183c26f7 100644 --- a/test/form-state/collections/Posts/index.ts +++ b/test/form-state/collections/Posts/index.ts @@ -83,6 +83,10 @@ export const PostsCollection: CollectionConfig = { }, }, }, + { + name: 'defaultTextField', + type: 'text', + }, ], }, ], diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts index 596cb061d0..d44b812ec8 100644 --- a/test/form-state/e2e.spec.ts +++ b/test/form-state/e2e.spec.ts @@ -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.locator('#field-array .array-field__add-row').click() await expect( diff --git a/test/form-state/payload-types.ts b/test/form-state/payload-types.ts index 72b04f150d..dd00cd42ef 100644 --- a/test/form-state/payload-types.ts +++ b/test/form-state/payload-types.ts @@ -144,6 +144,7 @@ export interface Post { array?: | { customTextField?: string | null; + defaultTextField?: string | null; id?: string | null; }[] | null; @@ -254,6 +255,7 @@ export interface PostsSelect { | T | { customTextField?: T; + defaultTextField?: T; id?: T; }; updatedAt?: T; diff --git a/tsconfig.base.json b/tsconfig.base.json index c9793d25c6..e2b64e3bc8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], + "@payload-config": ["./test/form-state/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"],