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:
@@ -20,8 +20,7 @@ const baseClass = 'fields-to-export'
|
||||
|
||||
export const FieldsToExport: SelectFieldClientComponent = (props) => {
|
||||
const { id } = useDocumentInfo()
|
||||
const { path } = props
|
||||
const { setValue, value } = useField<string[]>({ path })
|
||||
const { setValue, value } = useField<string[]>()
|
||||
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
|
||||
const { getEntityConfig } = useConfig()
|
||||
const { collection } = useImportExport()
|
||||
|
||||
@@ -20,12 +20,12 @@ const baseClass = 'sort-by-fields'
|
||||
|
||||
export const SortBy: SelectFieldClientComponent = (props) => {
|
||||
const { id } = useDocumentInfo()
|
||||
const { path } = props
|
||||
const { setValue, value } = useField<string>({ path })
|
||||
const { setValue, value } = useField<string>()
|
||||
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
|
||||
const { query } = useListQuery()
|
||||
const { getEntityConfig } = useConfig()
|
||||
const { collection } = useImportExport()
|
||||
|
||||
const [displayedValue, setDisplayedValue] = useState<{
|
||||
id: string
|
||||
label: ReactNode
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -16,8 +16,8 @@ type Props = {
|
||||
} & RelationshipFieldClientProps
|
||||
|
||||
export const TenantField = (args: Props) => {
|
||||
const { debug, path, unique } = args
|
||||
const { setValue, value } = useField<number | string>({ path })
|
||||
const { debug, unique } = args
|
||||
const { setValue, value } = useField<number | string>()
|
||||
const { options, selectedTenantID, setPreventRefreshOnChange, setTenant } = useTenantSelection()
|
||||
|
||||
const hasSetValueRef = React.useRef(false)
|
||||
|
||||
@@ -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<MetaDescriptionProps> = (props)
|
||||
required,
|
||||
},
|
||||
hasGenerateDescriptionFn,
|
||||
path,
|
||||
readOnly,
|
||||
} = props
|
||||
|
||||
@@ -58,12 +57,14 @@ export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props)
|
||||
const maxLength = maxLengthFromProps || maxLengthDefault
|
||||
const minLength = minLengthFromProps || minLengthDefault
|
||||
|
||||
const { customComponents, errorMessage, setValue, showError, value }: FieldType<string> =
|
||||
useField({
|
||||
path,
|
||||
} as Options)
|
||||
|
||||
const { AfterInput, BeforeInput, Label } = customComponents ?? {}
|
||||
const {
|
||||
customComponents: { AfterInput, BeforeInput, Label } = {},
|
||||
errorMessage,
|
||||
path,
|
||||
setValue,
|
||||
showError,
|
||||
value,
|
||||
}: FieldType<string> = useField()
|
||||
|
||||
const regenerateDescription = useCallback(async () => {
|
||||
if (!hasGenerateDescriptionFn) {
|
||||
|
||||
@@ -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<MetaImageProps> = (props) => {
|
||||
const {
|
||||
field: { label, localized, relationTo, required },
|
||||
hasGenerateImageFn,
|
||||
path,
|
||||
readOnly,
|
||||
} = props || {}
|
||||
} = props
|
||||
|
||||
const {
|
||||
config: {
|
||||
@@ -42,10 +41,14 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
|
||||
getEntityConfig,
|
||||
} = useConfig()
|
||||
|
||||
const field: FieldType<string> = useField({ ...props, path } as Options)
|
||||
const { customComponents } = field
|
||||
|
||||
const { Error, Label } = customComponents ?? {}
|
||||
const {
|
||||
customComponents: { Error, Label } = {},
|
||||
filterOptions,
|
||||
path,
|
||||
setValue,
|
||||
showError,
|
||||
value,
|
||||
}: FieldType<string> = useField()
|
||||
|
||||
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
|
||||
|
||||
@@ -53,8 +56,6 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (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<MetaImageProps> = (props) => {
|
||||
api={api}
|
||||
collection={collection}
|
||||
Error={Error}
|
||||
filterOptions={field.filterOptions}
|
||||
filterOptions={filterOptions}
|
||||
onChange={(incomingImage) => {
|
||||
if (incomingImage !== null) {
|
||||
if (typeof incomingImage === 'object') {
|
||||
|
||||
@@ -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<MetaTitleProps> = (props) => {
|
||||
const {
|
||||
field: { label, maxLength: maxLengthFromProps, minLength: minLengthFromProps, required },
|
||||
hasGenerateTitleFn,
|
||||
path,
|
||||
readOnly,
|
||||
} = props || {}
|
||||
} = props
|
||||
|
||||
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
|
||||
|
||||
@@ -46,8 +45,14 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
|
||||
},
|
||||
} = useConfig()
|
||||
|
||||
const field: FieldType<string> = useField({ path } as Options)
|
||||
const { customComponents: { AfterInput, BeforeInput, Label } = {} } = field
|
||||
const {
|
||||
customComponents: { AfterInput, BeforeInput, Label } = {},
|
||||
errorMessage,
|
||||
path,
|
||||
setValue,
|
||||
showError,
|
||||
value,
|
||||
}: FieldType<string> = useField()
|
||||
|
||||
const locale = useLocale()
|
||||
const { getData } = useForm()
|
||||
@@ -56,8 +61,6 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
|
||||
const minLength = minLengthFromProps || minLengthDefault
|
||||
const maxLength = maxLengthFromProps || maxLengthDefault
|
||||
|
||||
const { errorMessage, setValue, showError, value } = field
|
||||
|
||||
const regenerateTitle = useCallback(async () => {
|
||||
if (!hasGenerateTitleFn) {
|
||||
return
|
||||
|
||||
@@ -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<SerializedEditorState>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<LoadedSlateFieldProps> = (props) => {
|
||||
validate = richTextValidate,
|
||||
} = props
|
||||
|
||||
const path = pathFromProps ?? name
|
||||
const schemaPath = schemaPathFromProps ?? name
|
||||
|
||||
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
|
||||
@@ -97,11 +96,12 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
|
||||
customComponents: { Description, Error, Label } = {},
|
||||
disabled: disabledFromField,
|
||||
initialValue,
|
||||
path,
|
||||
setValue,
|
||||
showError,
|
||||
value,
|
||||
} = useField({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -11,9 +11,8 @@ import './index.scss'
|
||||
|
||||
export const QueryPresetsColumnField: JSONFieldClientComponent = ({
|
||||
field: { label, required },
|
||||
path,
|
||||
}) => {
|
||||
const { value } = useField({ path })
|
||||
const { path, value } = useField()
|
||||
|
||||
return (
|
||||
<div className="field-type query-preset-columns-field">
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<number>({
|
||||
hasRows: true,
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<number>({
|
||||
hasRows: true,
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<HiddenFieldProps> = (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(() => {
|
||||
|
||||
@@ -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<string>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<PaginatedDocs>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
})
|
||||
|
||||
const filterOptions: null | Where = useMemo(() => {
|
||||
|
||||
@@ -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<number | number[]>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<Value | Value[]>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string | string[]>({
|
||||
path,
|
||||
potentiallyStalePath: pathFromProps,
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
|
||||
14
packages/ui/src/forms/RenderFields/context.ts
Normal file
14
packages/ui/src/forms/RenderFields/context.ts
Normal 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
|
||||
}
|
||||
@@ -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<RenderFieldsProps> = (props) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<RenderField
|
||||
clientFieldConfig={field}
|
||||
forceRender={forceRender}
|
||||
indexPath={indexPath}
|
||||
key={`${path}-${i}`}
|
||||
parentPath={parentPath}
|
||||
parentSchemaPath={parentSchemaPath}
|
||||
path={path}
|
||||
permissions={fieldPermissions}
|
||||
readOnly={isReadOnly}
|
||||
schemaPath={schemaPath}
|
||||
/>
|
||||
<FieldPathContext key={`${path}-${i}`} value={path}>
|
||||
<RenderField
|
||||
clientFieldConfig={field}
|
||||
forceRender={forceRender}
|
||||
indexPath={indexPath}
|
||||
parentPath={parentPath}
|
||||
parentSchemaPath={parentSchemaPath}
|
||||
path={path}
|
||||
permissions={fieldPermissions}
|
||||
readOnly={isReadOnly}
|
||||
schemaPath={schemaPath}
|
||||
/>
|
||||
</FieldPathContext>
|
||||
)
|
||||
})}
|
||||
</RenderIfInViewport>
|
||||
|
||||
@@ -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 = <TValue,>(options: Options): FieldType<TValue> => {
|
||||
const { disableFormData = false, hasRows, path, validate } = options
|
||||
export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
|
||||
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()
|
||||
|
||||
@@ -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<T> = {
|
||||
formProcessing: boolean
|
||||
formSubmitted: boolean
|
||||
initialValue?: T
|
||||
path: string
|
||||
readOnly?: boolean
|
||||
rows?: Row[]
|
||||
setValue: (val: unknown, disableModifyingForm?: boolean) => void
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -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',
|
||||
|
||||
@@ -83,6 +83,10 @@ export const PostsCollection: CollectionConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'defaultTextField',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 extends boolean = true> {
|
||||
| T
|
||||
| {
|
||||
customTextField?: T;
|
||||
defaultTextField?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user