From 18f2f899c54156f86e21f8b9ef75ca5e893bb954 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 16 May 2025 15:51:57 -0400 Subject: [PATCH] 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 --- .../WatchTenantCollection/index.tsx | 12 +++- .../MetaDescriptionComponent.tsx | 6 +- .../fields/MetaImage/MetaImageComponent.tsx | 7 ++- .../fields/MetaTitle/MetaTitleComponent.tsx | 6 +- .../src/fields/Preview/PreviewComponent.tsx | 6 +- packages/ui/src/elements/Autosave/index.tsx | 1 + .../ui/src/elements/DeleteDocument/index.tsx | 4 +- .../DocumentDrawer/DrawerHeader/index.tsx | 4 +- .../PublishButton/ScheduleDrawer/index.tsx | 4 +- .../ui/src/elements/RenderTitle/index.tsx | 4 +- .../ui/src/elements/SaveDraftButton/index.tsx | 1 + packages/ui/src/elements/Status/index.tsx | 3 + packages/ui/src/exports/client/index.ts | 1 + .../ui/src/providers/DocumentInfo/index.tsx | 22 +++++-- .../ui/src/providers/DocumentInfo/types.ts | 26 ++++++++- .../ui/src/providers/DocumentTitle/context.ts | 0 .../ui/src/providers/DocumentTitle/index.tsx | 58 +++++++++++++++++++ .../ui/src/utilities/formatDocTitle/index.ts | 2 + packages/ui/src/views/Edit/Auth/index.tsx | 1 + .../views/Edit/SetDocumentStepNav/index.tsx | 5 +- .../src/views/Edit/SetDocumentTitle/index.tsx | 4 +- test/form-state/e2e.spec.ts | 51 ++++++++++++++++ 22 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 packages/ui/src/providers/DocumentTitle/context.ts create mode 100644 packages/ui/src/providers/DocumentTitle/index.tsx diff --git a/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx b/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx index b20a559b31..4d5bb15874 100644 --- a/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx +++ b/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx @@ -2,13 +2,21 @@ import type { ClientCollectionConfig } from 'payload' -import { useConfig, useDocumentInfo, useEffectEvent, useFormFields } from '@payloadcms/ui' +import { + useConfig, + useDocumentInfo, + useDocumentTitle, + useEffectEvent, + useFormFields, +} from '@payloadcms/ui' import React from 'react' import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js' export const WatchTenantCollection = () => { - const { id, collectionSlug, title } = useDocumentInfo() + const { id, collectionSlug } = useDocumentInfo() + const { title } = useDocumentTitle() + const { getEntityConfig } = useConfig() const [useAsTitleName] = React.useState( () => (getEntityConfig({ collectionSlug }) as ClientCollectionConfig).admin.useAsTitle, diff --git a/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx b/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx index 7b93ff3777..dab8f3f176 100644 --- a/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaDescription/MetaDescriptionComponent.tsx @@ -8,6 +8,7 @@ import { TextareaInput, useConfig, useDocumentInfo, + useDocumentTitle, useField, useForm, useLocale, @@ -53,6 +54,7 @@ export const MetaDescriptionComponent: React.FC = (props) const locale = useLocale() const { getData } = useForm() const docInfo = useDocumentInfo() + const { title } = useDocumentTitle() const maxLength = maxLengthFromProps || maxLengthDefault const minLength = minLengthFromProps || minLengthDefault @@ -85,7 +87,7 @@ export const MetaDescriptionComponent: React.FC = (props) initialData: docInfo.initialData, initialState: reduceToSerializableFields(docInfo.initialState ?? {}), locale: typeof locale === 'object' ? locale?.code : locale, - title: docInfo.title, + title, } satisfies Omit< Parameters[0], 'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount' @@ -112,10 +114,10 @@ export const MetaDescriptionComponent: React.FC = (props) docInfo.hasSavePermission, docInfo.initialData, docInfo.initialState, - docInfo.title, getData, locale, setValue, + title, ]) return ( diff --git a/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx b/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx index 8eca5f7138..b42ee59325 100644 --- a/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx @@ -9,6 +9,7 @@ import { UploadInput, useConfig, useDocumentInfo, + useDocumentTitle, useField, useForm, useLocale, @@ -56,6 +57,8 @@ export const MetaImageComponent: React.FC = (props) => { const { getData } = useForm() const docInfo = useDocumentInfo() + const { title } = useDocumentTitle() + const regenerateImage = useCallback(async () => { if (!hasGenerateImageFn) { return @@ -75,7 +78,7 @@ export const MetaImageComponent: React.FC = (props) => { initialData: docInfo.initialData, initialState: reduceToSerializableFields(docInfo.initialState ?? {}), locale: typeof locale === 'object' ? locale?.code : locale, - title: docInfo.title, + title, } satisfies Omit< Parameters[0], 'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount' @@ -102,10 +105,10 @@ export const MetaImageComponent: React.FC = (props) => { docInfo.hasSavePermission, docInfo.initialData, docInfo.initialState, - docInfo.title, getData, locale, setValue, + title, ]) const hasImage = Boolean(value) diff --git a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx index 4d7e57b119..50b347850f 100644 --- a/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx +++ b/packages/plugin-seo/src/fields/MetaTitle/MetaTitleComponent.tsx @@ -8,6 +8,7 @@ import { TextInput, useConfig, useDocumentInfo, + useDocumentTitle, useField, useForm, useLocale, @@ -57,6 +58,7 @@ export const MetaTitleComponent: React.FC = (props) => { const locale = useLocale() const { getData } = useForm() const docInfo = useDocumentInfo() + const { title } = useDocumentTitle() const minLength = minLengthFromProps || minLengthDefault const maxLength = maxLengthFromProps || maxLengthDefault @@ -80,7 +82,7 @@ export const MetaTitleComponent: React.FC = (props) => { initialData: docInfo.initialData, initialState: reduceToSerializableFields(docInfo.initialState ?? {}), locale: typeof locale === 'object' ? locale?.code : locale, - title: docInfo.title, + title, } satisfies Omit< Parameters[0], 'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount' @@ -107,10 +109,10 @@ export const MetaTitleComponent: React.FC = (props) => { docInfo.hasSavePermission, docInfo.initialData, docInfo.initialState, - docInfo.title, getData, locale, setValue, + title, ]) return ( diff --git a/packages/plugin-seo/src/fields/Preview/PreviewComponent.tsx b/packages/plugin-seo/src/fields/Preview/PreviewComponent.tsx index 789e6dd421..c7d7db2efb 100644 --- a/packages/plugin-seo/src/fields/Preview/PreviewComponent.tsx +++ b/packages/plugin-seo/src/fields/Preview/PreviewComponent.tsx @@ -6,6 +6,7 @@ import { useAllFormFields, useConfig, useDocumentInfo, + useDocumentTitle, useForm, useLocale, useTranslation, @@ -42,6 +43,7 @@ export const PreviewComponent: React.FC = (props) => { const [fields] = useAllFormFields() const { getData } = useForm() const docInfo = useDocumentInfo() + const { title } = useDocumentTitle() const descriptionPath = descriptionPathFromContext || 'meta.description' const titlePath = titlePathFromContext || 'meta.title' @@ -69,7 +71,7 @@ export const PreviewComponent: React.FC = (props) => { initialData: docInfo.initialData, initialState: reduceToSerializableFields(docInfo.initialState ?? {}), locale: typeof locale === 'object' ? locale?.code : locale, - title: docInfo.title, + title, } satisfies Omit< Parameters[0], 'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount' @@ -89,7 +91,7 @@ export const PreviewComponent: React.FC = (props) => { if (hasGenerateURLFn && !href) { void getHref() } - }, [fields, href, locale, docInfo, hasGenerateURLFn, getData, serverURL, api]) + }, [fields, href, locale, docInfo, hasGenerateURLFn, getData, serverURL, api, title]) return (
= ({ id, collection, global: globalDoc }) serverURL, }, } = useConfig() + const { docConfig, incrementVersionCount, diff --git a/packages/ui/src/elements/DeleteDocument/index.tsx b/packages/ui/src/elements/DeleteDocument/index.tsx index a53f4e9664..6e9b605762 100644 --- a/packages/ui/src/elements/DeleteDocument/index.tsx +++ b/packages/ui/src/elements/DeleteDocument/index.tsx @@ -12,7 +12,7 @@ import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js' import { useForm } from '../../forms/Form/context.js' import { useConfig } from '../../providers/Config/index.js' -import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { useDocumentTitle } from '../../providers/DocumentTitle/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' @@ -56,7 +56,7 @@ export const DeleteDocument: React.FC = (props) => { const { setModified } = useForm() const router = useRouter() const { i18n, t } = useTranslation() - const { title } = useDocumentInfo() + const { title } = useDocumentTitle() const { startRouteTransition } = useRouteTransition() const { openModal } = useModal() diff --git a/packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx b/packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx index 294950eb8a..7074913a0e 100644 --- a/packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx +++ b/packages/ui/src/elements/DocumentDrawer/DrawerHeader/index.tsx @@ -5,6 +5,7 @@ import { useModal } from '../../../elements/Modal/index.js' import { RenderTitle } from '../../../elements/RenderTitle/index.js' import { XIcon } from '../../../icons/X/index.js' import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js' +import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { IDLabel } from '../../IDLabel/index.js' import { documentDrawerBaseClass } from '../index.js' @@ -37,6 +38,7 @@ export const DocumentDrawerHeader: React.FC<{ } const DocumentTitle: React.FC = () => { - const { id, title } = useDocumentInfo() + const { id } = useDocumentInfo() + const { title } = useDocumentTitle() return id && id !== title ? : null } diff --git a/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx b/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx index c9df66dec6..caa1ee9418 100644 --- a/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx +++ b/packages/ui/src/elements/PublishButton/ScheduleDrawer/index.tsx @@ -17,6 +17,7 @@ import { FieldLabel } from '../../../fields/FieldLabel/index.js' import { Radio } from '../../../fields/RadioGroup/Radio/index.js' import { useConfig } from '../../../providers/Config/index.js' import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js' +import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js' import { useServerFunctions } from '../../../providers/ServerFunctions/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { requests } from '../../../utilities/api.js' @@ -59,7 +60,8 @@ export const ScheduleDrawer: React.FC = ({ slug, defaultType, schedulePub serverURL, }, } = useConfig() - const { id, collectionSlug, globalSlug, title } = useDocumentInfo() + const { id, collectionSlug, globalSlug } = useDocumentInfo() + const { title } = useDocumentTitle() const { i18n, t } = useTranslation() const { schedulePublish } = useServerFunctions() const [type, setType] = React.useState(defaultType || 'publish') diff --git a/packages/ui/src/elements/RenderTitle/index.tsx b/packages/ui/src/elements/RenderTitle/index.tsx index 82eb5fbcc8..c0e51e5c2d 100644 --- a/packages/ui/src/elements/RenderTitle/index.tsx +++ b/packages/ui/src/elements/RenderTitle/index.tsx @@ -2,6 +2,7 @@ import React, { Fragment } from 'react' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { useDocumentTitle } from '../../providers/DocumentTitle/index.js' import { IDLabel } from '../IDLabel/index.js' import './index.scss' @@ -18,7 +19,8 @@ export type RenderTitleProps = { export const RenderTitle: React.FC = (props) => { const { className, element = 'h1', fallback, title: titleFromProps } = props - const { id, isInitializing, title: titleFromContext } = useDocumentInfo() + const { id, isInitializing } = useDocumentInfo() + const { title: titleFromContext } = useDocumentTitle() const title = titleFromProps || titleFromContext || fallback diff --git a/packages/ui/src/elements/SaveDraftButton/index.tsx b/packages/ui/src/elements/SaveDraftButton/index.tsx index 509c51c8e0..71534bb330 100644 --- a/packages/ui/src/elements/SaveDraftButton/index.tsx +++ b/packages/ui/src/elements/SaveDraftButton/index.tsx @@ -23,6 +23,7 @@ export function SaveDraftButton(props: SaveDraftButtonClientProps) { serverURL, }, } = useConfig() + const { id, collectionSlug, globalSlug, setUnpublishedVersionCount, uploadStatus } = useDocumentInfo() diff --git a/packages/ui/src/elements/Status/index.tsx b/packages/ui/src/elements/Status/index.tsx index 6e473e8fc5..cd7130e2cd 100644 --- a/packages/ui/src/elements/Status/index.tsx +++ b/packages/ui/src/elements/Status/index.tsx @@ -28,13 +28,16 @@ export const Status: React.FC = () => { setUnpublishedVersionCount, unpublishedVersionCount, } = useDocumentInfo() + const { toggleModal } = useModal() + const { config: { routes: { api }, serverURL, }, } = useConfig() + const { reset: resetForm } = useForm() const { code: locale } = useLocale() const { i18n, t } = useTranslation() diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 255e18130f..dbde2fb010 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -277,6 +277,7 @@ export { export { ConfigProvider, useConfig } from '../../providers/Config/index.js' export { DocumentEventsProvider, useDocumentEvents } from '../../providers/DocumentEvents/index.js' export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js' +export { useDocumentTitle } from '../../providers/DocumentTitle/index.js' export type { DocumentInfoContext, DocumentInfoProps } from '../../providers/DocumentInfo/index.js' export { EditDepthProvider, useEditDepth } from '../../providers/EditDepth/index.js' diff --git a/packages/ui/src/providers/DocumentInfo/index.tsx b/packages/ui/src/providers/DocumentInfo/index.tsx index 7ce902301b..395cb0f064 100644 --- a/packages/ui/src/providers/DocumentInfo/index.tsx +++ b/packages/ui/src/providers/DocumentInfo/index.tsx @@ -4,16 +4,16 @@ import type { ClientUser, DocumentPreferences, SanitizedDocumentPermissions } fr import * as qs from 'qs-esm' import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { DocumentInfoContext, DocumentInfoProps } from './types.js' - import { useAuth } from '../../providers/Auth/index.js' import { requests } from '../../utilities/api.js' import { formatDocTitle } from '../../utilities/formatDocTitle/index.js' import { useConfig } from '../Config/index.js' +import { DocumentTitleProvider } from '../DocumentTitle/index.js' import { useLocale, useLocaleLoading } from '../Locale/index.js' import { usePreferences } from '../Preferences/index.js' import { useTranslation } from '../Translation/index.js' import { UploadEditsProvider, useUploadEdits } from '../UploadEdits/index.js' +import { type DocumentInfoContext, type DocumentInfoProps } from './types.js' import { useGetDocPermissions } from './useGetDocPermissions.js' const Context = createContext({} as DocumentInfoContext) @@ -75,7 +75,11 @@ const DocumentInfo: React.FC< const { uploadEdits } = useUploadEdits() - const [documentTitle, setDocumentTitle] = useState(() => + /** + * @deprecated This state will be removed in v4. + * This is for performance reasons. Use the `DocumentTitleContext` instead. + */ + const [title, setDocumentTitle] = useState(() => formatDocTitle({ collectionConfig, data: { ...(initialData || {}), id }, @@ -272,6 +276,10 @@ const DocumentInfo: React.FC< [], ) + /** + * @todo: Remove this in v4 + * Users should use the `DocumentTitleContext` instead. + */ useEffect(() => { setDocumentTitle( formatDocTitle({ @@ -343,7 +351,7 @@ const DocumentInfo: React.FC< setMostRecentVersionIsAutosaved, setUnpublishedVersionCount, setUploadStatus: updateUploadStatus, - title: documentTitle, + title, unlockDocument, unpublishedVersionCount, updateDocumentEditor, @@ -352,7 +360,11 @@ const DocumentInfo: React.FC< versionCount, } - return {children} + return ( + + {children} + + ) } export const DocumentInfoProvider: React.FC< diff --git a/packages/ui/src/providers/DocumentInfo/types.ts b/packages/ui/src/providers/DocumentInfo/types.ts index 6babc9b439..93ec9674e2 100644 --- a/packages/ui/src/providers/DocumentInfo/types.ts +++ b/packages/ui/src/providers/DocumentInfo/types.ts @@ -11,7 +11,8 @@ import type { SanitizedGlobalConfig, TypedUser, } from 'payload' -import type React from 'react' + +import React from 'react' export type DocumentInfoProps = { readonly action?: string @@ -60,12 +61,31 @@ export type DocumentInfoContext = { fieldPreferences: { [key: string]: unknown } & Partial, ) => void setDocumentIsLocked?: React.Dispatch> - setDocumentTitle: (title: string) => void + /** + * + * @deprecated This property is deprecated and will be removed in v4. + * This is for performance reasons. Use the `DocumentTitleContext` instead. + * @example + * ```tsx + * import { useDocumentTitle } from '@payloadcms/ui' + * const { setDocumentTitle } = useDocumentTitle() + * ``` + */ + setDocumentTitle: React.Dispatch> setHasPublishedDoc: React.Dispatch> setLastUpdateTime: React.Dispatch> setMostRecentVersionIsAutosaved: React.Dispatch> setUnpublishedVersionCount: React.Dispatch> setUploadStatus?: (status: 'failed' | 'idle' | 'uploading') => void + /** + * @deprecated This property is deprecated and will be removed in v4. + * This is for performance reasons. Use the `DocumentTitleContext` instead. + * @example + * ```tsx + * import { useDocumentTitle } from '@payloadcms/ui' + * const { title } = useDocumentTitle() + * ``` + */ title: string unlockDocument: (docID: number | string, slug: string) => Promise unpublishedVersionCount: number @@ -74,3 +94,5 @@ export type DocumentInfoContext = { uploadStatus?: 'failed' | 'idle' | 'uploading' versionCount: number } & DocumentInfoProps + +export const DocumentTitleContext = React.createContext('') diff --git a/packages/ui/src/providers/DocumentTitle/context.ts b/packages/ui/src/providers/DocumentTitle/context.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/ui/src/providers/DocumentTitle/index.tsx b/packages/ui/src/providers/DocumentTitle/index.tsx new file mode 100644 index 0000000000..2294219681 --- /dev/null +++ b/packages/ui/src/providers/DocumentTitle/index.tsx @@ -0,0 +1,58 @@ +import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' + +import { createContext, use, useEffect, useState } from 'react' + +import { formatDocTitle } from '../../utilities/formatDocTitle/index.js' +import { useConfig } from '../Config/index.js' +import { useDocumentInfo } from '../DocumentInfo/index.js' +import { useTranslation } from '../Translation/index.js' + +type IDocumentTitleContext = { + setDocumentTitle: (title: string) => void + title: string +} + +const DocumentTitleContext = createContext({} as IDocumentTitleContext) + +export const useDocumentTitle = (): IDocumentTitleContext => use(DocumentTitleContext) + +export const DocumentTitleProvider: React.FC<{ + children: React.ReactNode +}> = ({ children }) => { + const { id, collectionSlug, docConfig, globalSlug, initialData, savedDocumentData } = + useDocumentInfo() + + const { + config: { + admin: { dateFormat }, + }, + } = useConfig() + + const { i18n } = useTranslation() + + const [title, setDocumentTitle] = useState(() => + formatDocTitle({ + collectionConfig: collectionSlug ? (docConfig as ClientCollectionConfig) : undefined, + data: { ...(initialData || {}), id }, + dateFormat, + fallback: id?.toString(), + globalConfig: globalSlug ? (docConfig as ClientGlobalConfig) : undefined, + i18n, + }), + ) + + useEffect(() => { + setDocumentTitle( + formatDocTitle({ + collectionConfig: collectionSlug ? (docConfig as ClientCollectionConfig) : undefined, + data: { ...savedDocumentData, id }, + dateFormat, + fallback: id?.toString(), + globalConfig: globalSlug ? (docConfig as ClientGlobalConfig) : undefined, + i18n, + }), + ) + }, [savedDocumentData, dateFormat, i18n, id, collectionSlug, docConfig, globalSlug]) + + return {children} +} diff --git a/packages/ui/src/utilities/formatDocTitle/index.ts b/packages/ui/src/utilities/formatDocTitle/index.ts index 76e8ab5ad4..a7214a3412 100644 --- a/packages/ui/src/utilities/formatDocTitle/index.ts +++ b/packages/ui/src/utilities/formatDocTitle/index.ts @@ -45,6 +45,7 @@ export const formatDocTitle = ({ const dateFormat = ('date' in fieldConfig.admin && fieldConfig?.admin?.date?.displayFormat) || dateFormatFromConfig + title = formatDate({ date: title, i18n, pattern: dateFormat }) || title } } @@ -59,6 +60,7 @@ export const formatDocTitle = ({ if (isSerializedLexicalEditor(title)) { title = formatLexicalDocTitle(title.root.children?.[0]?.children || [], '') } + if (!title && isSerializedLexicalEditor(fallback)) { title = formatLexicalDocTitle(fallback.root.children?.[0]?.children || [], '') } diff --git a/packages/ui/src/views/Edit/Auth/index.tsx b/packages/ui/src/views/Edit/Auth/index.tsx index aeb925a1a5..feb88980f8 100644 --- a/packages/ui/src/views/Edit/Auth/index.tsx +++ b/packages/ui/src/views/Edit/Auth/index.tsx @@ -43,6 +43,7 @@ export const Auth: React.FC = (props) => { const modified = useFormModified() const { i18n, t } = useTranslation() const { docPermissions, isEditing, isInitializing } = useDocumentInfo() + const { config: { routes: { api }, diff --git a/packages/ui/src/views/Edit/SetDocumentStepNav/index.tsx b/packages/ui/src/views/Edit/SetDocumentStepNav/index.tsx index 5a914d3f57..5102947af2 100644 --- a/packages/ui/src/views/Edit/SetDocumentStepNav/index.tsx +++ b/packages/ui/src/views/Edit/SetDocumentStepNav/index.tsx @@ -10,6 +10,7 @@ import type { StepNavItem } from '../../../elements/StepNav/index.js' import { useStepNav } from '../../../elements/StepNav/index.js' import { useConfig } from '../../../providers/Config/index.js' import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js' +import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js' import { useEntityVisibility } from '../../../providers/EntityVisibility/index.js' import { useTranslation } from '../../../providers/Translation/index.js' @@ -26,7 +27,9 @@ export const SetDocumentStepNav: React.FC<{ const view: string | undefined = props?.view || undefined - const { isEditing, isInitializing, title } = useDocumentInfo() + const { isEditing, isInitializing } = useDocumentInfo() + const { title } = useDocumentTitle() + const { isEntityVisible } = useEntityVisibility() const isVisible = isEntityVisible({ collectionSlug, globalSlug }) diff --git a/packages/ui/src/views/Edit/SetDocumentTitle/index.tsx b/packages/ui/src/views/Edit/SetDocumentTitle/index.tsx index 045721ffed..fd9662fea2 100644 --- a/packages/ui/src/views/Edit/SetDocumentTitle/index.tsx +++ b/packages/ui/src/views/Edit/SetDocumentTitle/index.tsx @@ -4,7 +4,7 @@ import type { ClientCollectionConfig, ClientConfig, ClientGlobalConfig } from 'p import { useEffect, useRef } from 'react' import { useFormFields } from '../../../forms/Form/context.js' -import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js' +import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { formatDocTitle } from '../../../utilities/formatDocTitle/index.js' @@ -24,7 +24,7 @@ export const SetDocumentTitle: React.FC<{ const { i18n } = useTranslation() - const { setDocumentTitle } = useDocumentInfo() + const { setDocumentTitle } = useDocumentTitle() const dateFormatFromConfig = config?.admin?.dateFormat diff --git a/test/form-state/e2e.spec.ts b/test/form-state/e2e.spec.ts index d44b812ec8..091f5eea4d 100644 --- a/test/form-state/e2e.spec.ts +++ b/test/form-state/e2e.spec.ts @@ -251,6 +251,57 @@ test.describe('Form State', () => { ).toHaveValue('This is a default value') }) + // TODO: This test is not very reliable but would be really nice to have + test.skip('should not lag on slow CPUs', async () => { + await page.goto(postsUrl.create) + + await expect(page.locator('#field-title')).toBeEnabled() + + const cdpSession = await context.newCDPSession(page) + + await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 25 }) + + // Start measuring input and render times + await page.evaluate(() => { + const inputField = document.querySelector('#field-title') as HTMLInputElement + const logs: Record = {} + + inputField.addEventListener('input', (event) => { + const startTime = performance.now() + + requestAnimationFrame(() => { + const endTime = performance.now() + const elapsedTime = endTime - startTime + logs[event.target?.value] = { elapsedTime } + }) + }) + + window.getLogs = () => logs + }) + + const text = 'This is a test string to measure input lag.' + + await page.locator('#field-title').pressSequentially(text, { delay: 0 }) + + const logs: Record = await page.evaluate(() => + window.getLogs(), + ) + console.log('Logs:', logs) + + const lagTimes = Object.values(logs).map((log) => log.elapsedTime || 0) + + console.log('Lag times:', lagTimes) + + const maxInputLag = Math.max(...lagTimes) + const allowedThreshold = 50 + + expect(maxInputLag).toBeLessThanOrEqual(allowedThreshold) + + // Reset CPU throttling + await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 1 }) + await cdpSession.detach() + }) + describe('Throttled tests', () => { let cdpSession: CDPSession