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 1a33f1fe1..67461e167 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 75d7cbb29..0d06a2116 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 02c1d3db3..68f4daf65 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 4f6605e60..b0a8bf0b9 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 5a544a9f3..7b93ff377 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 987c4a204..acfc2aaef 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 e1496ca63..4d7e57b11 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 65edb6ede..1f5bc2825 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 1effc7eb0..fbcb1df13 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 78570d667..6c21780e5 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 564cbe82b..a44fd868b 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 ed2501903..954937599 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 0dea39644..99a857808 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 e3fc493cf..2a685e6ea 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 5a9f38e45..143df342e 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 da801b937..4afbc3c91 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 305ad5b28..6cce6d753 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 90c4e0f98..3938e354d 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 75754cb18..c0b80a670 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 1a75eff97..43610cb40 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 98be6bee3..c7f135a26 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 3d3ec40a9..dbe86849f 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 bb5746bda..0542d55b9 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 85a897f2d..449c1e137 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 f49af6fe6..b8b67af5f 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 5b7e9b6bf..b8be36c96 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 afa9aaa53..637b5efe4 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 f27695f36..3a3ffba6b 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 681e5c18e..9fc43b203 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 000000000..d6d9da067 --- /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 bf447d951..ed662e202 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 f3fc737df..987b81995 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 2f43cbab6..e91e60e8b 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 3398d1b8f..49bb09b55 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 1ed66a3c2..c9d8b51e9 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 8d208a400..8e2cd9f5e 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 14550604f..54183c26f 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 596cb061d..d44b812ec 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 72b04f150..dd00cd42e 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 c9793d25c..e2b64e3bc 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"],