Skip to content

Commit 21599b8

Browse files
authored
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.
1 parent e90ff72 commit 21599b8

File tree

40 files changed

+211
-106
lines changed

40 files changed

+211
-106
lines changed

packages/plugin-import-export/src/components/FieldsToExport/index.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ const baseClass = 'fields-to-export'
2020

2121
export const FieldsToExport: SelectFieldClientComponent = (props) => {
2222
const { id } = useDocumentInfo()
23-
const { path } = props
24-
const { setValue, value } = useField<string[]>({ path })
23+
const { setValue, value } = useField<string[]>()
2524
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
2625
const { getEntityConfig } = useConfig()
2726
const { collection } = useImportExport()

packages/plugin-import-export/src/components/SortBy/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ const baseClass = 'sort-by-fields'
2020

2121
export const SortBy: SelectFieldClientComponent = (props) => {
2222
const { id } = useDocumentInfo()
23-
const { path } = props
24-
const { setValue, value } = useField<string>({ path })
23+
const { setValue, value } = useField<string>()
2524
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
2625
const { query } = useListQuery()
2726
const { getEntityConfig } = useConfig()
2827
const { collection } = useImportExport()
28+
2929
const [displayedValue, setDisplayedValue] = useState<{
3030
id: string
3131
label: ReactNode

packages/plugin-import-export/src/components/WhereField/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const WhereField: React.FC = () => {
1111
const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({
1212
path: 'selectionToUse',
1313
})
14+
1415
const { setValue } = useField({ path: 'where' })
1516
const { selectAll, selected } = useSelection()
1617
const { query } = useListQuery()

packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ type Props = {
1616
} & RelationshipFieldClientProps
1717

1818
export const TenantField = (args: Props) => {
19-
const { debug, path, unique } = args
20-
const { setValue, value } = useField<number | string>({ path })
19+
const { debug, unique } = args
20+
const { setValue, value } = useField<number | string>()
2121
const { options, selectedTenantID, setPreventRefreshOnChange, setTenant } = useTenantSelection()
2222

2323
const hasSetValueRef = React.useRef(false)

packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import type { FieldType, Options } from '@payloadcms/ui'
3+
import type { FieldType } from '@payloadcms/ui'
44
import type { TextareaFieldClientProps } from 'payload'
55

66
import {
@@ -38,7 +38,6 @@ export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props)
3838
required,
3939
},
4040
hasGenerateDescriptionFn,
41-
path,
4241
readOnly,
4342
} = props
4443

@@ -58,12 +57,14 @@ export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props)
5857
const maxLength = maxLengthFromProps || maxLengthDefault
5958
const minLength = minLengthFromProps || minLengthDefault
6059

61-
const { customComponents, errorMessage, setValue, showError, value }: FieldType<string> =
62-
useField({
63-
path,
64-
} as Options)
65-
66-
const { AfterInput, BeforeInput, Label } = customComponents ?? {}
60+
const {
61+
customComponents: { AfterInput, BeforeInput, Label } = {},
62+
errorMessage,
63+
path,
64+
setValue,
65+
showError,
66+
value,
67+
}: FieldType<string> = useField()
6768

6869
const regenerateDescription = useCallback(async () => {
6970
if (!hasGenerateDescriptionFn) {

packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import type { FieldType, Options } from '@payloadcms/ui'
3+
import type { FieldType } from '@payloadcms/ui'
44
import type { UploadFieldClientProps } from 'payload'
55

66
import {
@@ -30,9 +30,8 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
3030
const {
3131
field: { label, localized, relationTo, required },
3232
hasGenerateImageFn,
33-
path,
3433
readOnly,
35-
} = props || {}
34+
} = props
3635

3736
const {
3837
config: {
@@ -42,19 +41,21 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
4241
getEntityConfig,
4342
} = useConfig()
4443

45-
const field: FieldType<string> = useField({ ...props, path } as Options)
46-
const { customComponents } = field
47-
48-
const { Error, Label } = customComponents ?? {}
44+
const {
45+
customComponents: { Error, Label } = {},
46+
filterOptions,
47+
path,
48+
setValue,
49+
showError,
50+
value,
51+
}: FieldType<string> = useField()
4952

5053
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
5154

5255
const locale = useLocale()
5356
const { getData } = useForm()
5457
const docInfo = useDocumentInfo()
5558

56-
const { setValue, showError, value } = field
57-
5859
const regenerateImage = useCallback(async () => {
5960
if (!hasGenerateImageFn) {
6061
return
@@ -174,7 +175,7 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
174175
api={api}
175176
collection={collection}
176177
Error={Error}
177-
filterOptions={field.filterOptions}
178+
filterOptions={filterOptions}
178179
onChange={(incomingImage) => {
179180
if (incomingImage !== null) {
180181
if (typeof incomingImage === 'object') {

packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import type { FieldType, Options } from '@payloadcms/ui'
3+
import type { FieldType } from '@payloadcms/ui'
44
import type { TextFieldClientProps } from 'payload'
55

66
import {
@@ -33,9 +33,8 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
3333
const {
3434
field: { label, maxLength: maxLengthFromProps, minLength: minLengthFromProps, required },
3535
hasGenerateTitleFn,
36-
path,
3736
readOnly,
38-
} = props || {}
37+
} = props
3938

4039
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
4140

@@ -46,8 +45,14 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
4645
},
4746
} = useConfig()
4847

49-
const field: FieldType<string> = useField({ path } as Options)
50-
const { customComponents: { AfterInput, BeforeInput, Label } = {} } = field
48+
const {
49+
customComponents: { AfterInput, BeforeInput, Label } = {},
50+
errorMessage,
51+
path,
52+
setValue,
53+
showError,
54+
value,
55+
}: FieldType<string> = useField()
5156

5257
const locale = useLocale()
5358
const { getData } = useForm()
@@ -56,8 +61,6 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
5661
const minLength = minLengthFromProps || minLengthDefault
5762
const maxLength = maxLengthFromProps || maxLengthDefault
5863

59-
const { errorMessage, setValue, showError, value } = field
60-
6164
const regenerateTitle = useCallback(async () => {
6265
if (!hasGenerateTitleFn) {
6366
return

packages/richtext-lexical/src/field/Field.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ const RichTextComponent: React.FC<
3636
editorConfig,
3737
field,
3838
field: {
39-
name,
4039
admin: { className, description, readOnly: readOnlyFromAdmin } = {},
4140
label,
4241
localized,
@@ -48,7 +47,6 @@ const RichTextComponent: React.FC<
4847
} = props
4948

5049
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
51-
const path = pathFromProps ?? name
5250

5351
const editDepth = useEditDepth()
5452

@@ -70,11 +68,12 @@ const RichTextComponent: React.FC<
7068
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
7169
disabled: disabledFromField,
7270
initialValue,
71+
path,
7372
setValue,
7473
showError,
7574
value,
7675
} = useField<SerializedEditorState>({
77-
path,
76+
potentiallyStalePath: pathFromProps,
7877
validate: memoizedValidate,
7978
})
8079

packages/richtext-slate/src/field/RichText.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import type { LoadedSlateFieldProps } from './types.js'
2828
import { defaultRichTextValue } from '../data/defaultValue.js'
2929
import { richTextValidate } from '../data/validation.js'
3030
import { listTypes } from './elements/listTypes.js'
31-
import './index.scss'
3231
import { hotkeys } from './hotkeys.js'
3332
import { toggleLeaf } from './leaves/toggle.js'
3433
import { withEnterBreakOut } from './plugins/withEnterBreakOut.js'
@@ -37,6 +36,7 @@ import { ElementButtonProvider } from './providers/ElementButtonProvider.js'
3736
import { ElementProvider } from './providers/ElementProvider.js'
3837
import { LeafButtonProvider } from './providers/LeafButtonProvider.js'
3938
import { LeafProvider } from './providers/LeafProvider.js'
39+
import './index.scss'
4040

4141
const baseClass = 'rich-text'
4242

@@ -66,7 +66,6 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
6666
validate = richTextValidate,
6767
} = props
6868

69-
const path = pathFromProps ?? name
7069
const schemaPath = schemaPathFromProps ?? name
7170

7271
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
@@ -97,11 +96,12 @@ const RichTextField: React.FC<LoadedSlateFieldProps> = (props) => {
9796
customComponents: { Description, Error, Label } = {},
9897
disabled: disabledFromField,
9998
initialValue,
99+
path,
100100
setValue,
101101
showError,
102102
value,
103103
} = useField({
104-
path,
104+
potentiallyStalePath: pathFromProps,
105105
validate: memoizedValidate,
106106
})
107107

packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@ import './index.scss'
1111

1212
export const QueryPresetsColumnField: JSONFieldClientComponent = ({
1313
field: { label, required },
14-
path,
1514
}) => {
16-
const { value } = useField({ path })
15+
const { path, value } = useField()
1716

1817
return (
1918
<div className="field-type query-preset-columns-field">

0 commit comments

Comments
 (0)