Skip to content

Commit 18f2f89

Browse files
authored
perf(ui): useAsTitle field lags on slow cpu (#12436)
When running the Payload admin panel on a machine with a slower CPU, form state lags significantly and can become nearly unusable or even crash when interacting with the document's `useAsTitle` field. Here's an example: https://github.com/user-attachments/assets/3535fa99-1b31-4cb6-b6a8-5eb9a36b31b7 #### Why this happens The reason for this is that entire React component trees are re-rendering on every keystroke of the `useAsTitle` field, twice over. Here's a breakdown of the flow: 1. First, we dispatch form state events to the form context. Only the components that are subscribed to form state re-render when this happens (good). 2. Then, we sync the `useAsTitle` field to the document info provider, which lives outside the form. Regardless of whether its children need to be aware of the document title, all components subscribed to the document info context will re-render (there are many, including the form itself). Given how far up the rendering tree the document info provider is, its rendering footprint, and the rate of speed at which these events are dispatched, this is resource intensive. #### What is the fix The fix is to isolate the document's title into it's own context. This way only the components that are subscribed to specifically this context will re-render as the title changes. Here's the same test with the same CPU throttling, but no lag: https://github.com/user-attachments/assets/c8ced9b1-b5f0-4789-8d00-a2523d833524
1 parent d4899b8 commit 18f2f89

File tree

22 files changed

+203
-25
lines changed

22 files changed

+203
-25
lines changed

packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@
22

33
import type { ClientCollectionConfig } from 'payload'
44

5-
import { useConfig, useDocumentInfo, useEffectEvent, useFormFields } from '@payloadcms/ui'
5+
import {
6+
useConfig,
7+
useDocumentInfo,
8+
useDocumentTitle,
9+
useEffectEvent,
10+
useFormFields,
11+
} from '@payloadcms/ui'
612
import React from 'react'
713

814
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'
915

1016
export const WatchTenantCollection = () => {
11-
const { id, collectionSlug, title } = useDocumentInfo()
17+
const { id, collectionSlug } = useDocumentInfo()
18+
const { title } = useDocumentTitle()
19+
1220
const { getEntityConfig } = useConfig()
1321
const [useAsTitleName] = React.useState(
1422
() => (getEntityConfig({ collectionSlug }) as ClientCollectionConfig).admin.useAsTitle,

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
TextareaInput,
99
useConfig,
1010
useDocumentInfo,
11+
useDocumentTitle,
1112
useField,
1213
useForm,
1314
useLocale,
@@ -53,6 +54,7 @@ export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props)
5354
const locale = useLocale()
5455
const { getData } = useForm()
5556
const docInfo = useDocumentInfo()
57+
const { title } = useDocumentTitle()
5658

5759
const maxLength = maxLengthFromProps || maxLengthDefault
5860
const minLength = minLengthFromProps || minLengthDefault
@@ -85,7 +87,7 @@ export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props)
8587
initialData: docInfo.initialData,
8688
initialState: reduceToSerializableFields(docInfo.initialState ?? {}),
8789
locale: typeof locale === 'object' ? locale?.code : locale,
88-
title: docInfo.title,
90+
title,
8991
} satisfies Omit<
9092
Parameters<GenerateDescription>[0],
9193
'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount'
@@ -112,10 +114,10 @@ export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props)
112114
docInfo.hasSavePermission,
113115
docInfo.initialData,
114116
docInfo.initialState,
115-
docInfo.title,
116117
getData,
117118
locale,
118119
setValue,
120+
title,
119121
])
120122

121123
return (

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
UploadInput,
1010
useConfig,
1111
useDocumentInfo,
12+
useDocumentTitle,
1213
useField,
1314
useForm,
1415
useLocale,
@@ -56,6 +57,8 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
5657
const { getData } = useForm()
5758
const docInfo = useDocumentInfo()
5859

60+
const { title } = useDocumentTitle()
61+
5962
const regenerateImage = useCallback(async () => {
6063
if (!hasGenerateImageFn) {
6164
return
@@ -75,7 +78,7 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
7578
initialData: docInfo.initialData,
7679
initialState: reduceToSerializableFields(docInfo.initialState ?? {}),
7780
locale: typeof locale === 'object' ? locale?.code : locale,
78-
title: docInfo.title,
81+
title,
7982
} satisfies Omit<
8083
Parameters<GenerateImage>[0],
8184
'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount'
@@ -102,10 +105,10 @@ export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
102105
docInfo.hasSavePermission,
103106
docInfo.initialData,
104107
docInfo.initialState,
105-
docInfo.title,
106108
getData,
107109
locale,
108110
setValue,
111+
title,
109112
])
110113

111114
const hasImage = Boolean(value)

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
TextInput,
99
useConfig,
1010
useDocumentInfo,
11+
useDocumentTitle,
1112
useField,
1213
useForm,
1314
useLocale,
@@ -57,6 +58,7 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
5758
const locale = useLocale()
5859
const { getData } = useForm()
5960
const docInfo = useDocumentInfo()
61+
const { title } = useDocumentTitle()
6062

6163
const minLength = minLengthFromProps || minLengthDefault
6264
const maxLength = maxLengthFromProps || maxLengthDefault
@@ -80,7 +82,7 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
8082
initialData: docInfo.initialData,
8183
initialState: reduceToSerializableFields(docInfo.initialState ?? {}),
8284
locale: typeof locale === 'object' ? locale?.code : locale,
83-
title: docInfo.title,
85+
title,
8486
} satisfies Omit<
8587
Parameters<GenerateTitle>[0],
8688
'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount'
@@ -107,10 +109,10 @@ export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
107109
docInfo.hasSavePermission,
108110
docInfo.initialData,
109111
docInfo.initialState,
110-
docInfo.title,
111112
getData,
112113
locale,
113114
setValue,
115+
title,
114116
])
115117

116118
return (

packages/plugin-seo/src/fields/Preview/PreviewComponent.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useAllFormFields,
77
useConfig,
88
useDocumentInfo,
9+
useDocumentTitle,
910
useForm,
1011
useLocale,
1112
useTranslation,
@@ -42,6 +43,7 @@ export const PreviewComponent: React.FC<PreviewProps> = (props) => {
4243
const [fields] = useAllFormFields()
4344
const { getData } = useForm()
4445
const docInfo = useDocumentInfo()
46+
const { title } = useDocumentTitle()
4547

4648
const descriptionPath = descriptionPathFromContext || 'meta.description'
4749
const titlePath = titlePathFromContext || 'meta.title'
@@ -69,7 +71,7 @@ export const PreviewComponent: React.FC<PreviewProps> = (props) => {
6971
initialData: docInfo.initialData,
7072
initialState: reduceToSerializableFields(docInfo.initialState ?? {}),
7173
locale: typeof locale === 'object' ? locale?.code : locale,
72-
title: docInfo.title,
74+
title,
7375
} satisfies Omit<
7476
Parameters<GenerateURL>[0],
7577
'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount'
@@ -89,7 +91,7 @@ export const PreviewComponent: React.FC<PreviewProps> = (props) => {
8991
if (hasGenerateURLFn && !href) {
9092
void getHref()
9193
}
92-
}, [fields, href, locale, docInfo, hasGenerateURLFn, getData, serverURL, api])
94+
}, [fields, href, locale, docInfo, hasGenerateURLFn, getData, serverURL, api, title])
9395

9496
return (
9597
<div

packages/ui/src/elements/Autosave/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
4444
serverURL,
4545
},
4646
} = useConfig()
47+
4748
const {
4849
docConfig,
4950
incrementVersionCount,

packages/ui/src/elements/DeleteDocument/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js'
1212

1313
import { useForm } from '../../forms/Form/context.js'
1414
import { useConfig } from '../../providers/Config/index.js'
15-
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
15+
import { useDocumentTitle } from '../../providers/DocumentTitle/index.js'
1616
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
1717
import { useTranslation } from '../../providers/Translation/index.js'
1818
import { requests } from '../../utilities/api.js'
@@ -56,7 +56,7 @@ export const DeleteDocument: React.FC<Props> = (props) => {
5656
const { setModified } = useForm()
5757
const router = useRouter()
5858
const { i18n, t } = useTranslation()
59-
const { title } = useDocumentInfo()
59+
const { title } = useDocumentTitle()
6060
const { startRouteTransition } = useRouteTransition()
6161
const { openModal } = useModal()
6262

packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useModal } from '../../../elements/Modal/index.js'
55
import { RenderTitle } from '../../../elements/RenderTitle/index.js'
66
import { XIcon } from '../../../icons/X/index.js'
77
import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js'
8+
import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js'
89
import { useTranslation } from '../../../providers/Translation/index.js'
910
import { IDLabel } from '../../IDLabel/index.js'
1011
import { documentDrawerBaseClass } from '../index.js'
@@ -37,6 +38,7 @@ export const DocumentDrawerHeader: React.FC<{
3738
}
3839

3940
const DocumentTitle: React.FC = () => {
40-
const { id, title } = useDocumentInfo()
41+
const { id } = useDocumentInfo()
42+
const { title } = useDocumentTitle()
4143
return id && id !== title ? <IDLabel id={id.toString()} /> : null
4244
}

packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { FieldLabel } from '../../../fields/FieldLabel/index.js'
1717
import { Radio } from '../../../fields/RadioGroup/Radio/index.js'
1818
import { useConfig } from '../../../providers/Config/index.js'
1919
import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js'
20+
import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js'
2021
import { useServerFunctions } from '../../../providers/ServerFunctions/index.js'
2122
import { useTranslation } from '../../../providers/Translation/index.js'
2223
import { requests } from '../../../utilities/api.js'
@@ -59,7 +60,8 @@ export const ScheduleDrawer: React.FC<Props> = ({ slug, defaultType, schedulePub
5960
serverURL,
6061
},
6162
} = useConfig()
62-
const { id, collectionSlug, globalSlug, title } = useDocumentInfo()
63+
const { id, collectionSlug, globalSlug } = useDocumentInfo()
64+
const { title } = useDocumentTitle()
6365
const { i18n, t } = useTranslation()
6466
const { schedulePublish } = useServerFunctions()
6567
const [type, setType] = React.useState<PublishType>(defaultType || 'publish')

packages/ui/src/elements/RenderTitle/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React, { Fragment } from 'react'
33

44
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
5+
import { useDocumentTitle } from '../../providers/DocumentTitle/index.js'
56
import { IDLabel } from '../IDLabel/index.js'
67
import './index.scss'
78

@@ -18,7 +19,8 @@ export type RenderTitleProps = {
1819
export const RenderTitle: React.FC<RenderTitleProps> = (props) => {
1920
const { className, element = 'h1', fallback, title: titleFromProps } = props
2021

21-
const { id, isInitializing, title: titleFromContext } = useDocumentInfo()
22+
const { id, isInitializing } = useDocumentInfo()
23+
const { title: titleFromContext } = useDocumentTitle()
2224

2325
const title = titleFromProps || titleFromContext || fallback
2426

0 commit comments

Comments
 (0)