perf(ui): speed up list view rendering, ensure root layout does not re-render when navigating to list view (#10607)

Data for the EditMany view was fetched even though the EditMany Drawer
was not open. This, in combination with the router.replace call to add
the default limit query param, caused the root layout to re-render
This commit is contained in:
Alessio Gravili
2025-01-15 18:34:55 -07:00
committed by GitHub
parent 0d47a5db5d
commit fafe37e8b8
6 changed files with 348 additions and 308 deletions

View File

@@ -204,12 +204,10 @@ export const renderListView = async (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<ListQueryProvider
collectionSlug={collectionSlug}
data={data}
defaultLimit={limit}
defaultSort={sort}
modifySearchParams={!isInDrawer}
preferenceKey={preferenceKey}
>
{RenderServerComponent({
clientProps,

View File

@@ -194,7 +194,6 @@ export const VersionsView: PayloadServerReactComponent<EditViewComponent> = asyn
<main className={baseClass}>
<Gutter className={`${baseClass}__wrap`}>
<ListQueryProvider
collectionSlug={collectionSlug}
data={versionsData}
defaultLimit={limitToUse}
defaultSort={sort as string}

View File

@@ -0,0 +1,331 @@
'use client'
import type { FieldWithPathClient, FormState } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter, useSearchParams } from 'next/navigation.js'
import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type { FormProps } from '../../forms/Form/index.js'
import { useForm } from '../../forms/Form/context.js'
import { Form } from '../../forms/Form/index.js'
import { RenderFields } from '../../forms/RenderFields/index.js'
import { FormSubmit } from '../../forms/Submit/index.js'
import { XIcon } from '../../icons/X/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { DocumentInfoProvider } from '../../providers/DocumentInfo/index.js'
import { OperationContext } from '../../providers/Operation/index.js'
import { useRouteCache } from '../../providers/RouteCache/index.js'
import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import './index.scss'
import { FieldSelect } from '../FieldSelect/index.js'
import { baseClass, type EditManyProps } from './index.js'
const sanitizeUnselectedFields = (formState: FormState, selected: FieldWithPathClient[]) => {
const filteredData = selected.reduce((acc, field) => {
const foundState = formState?.[field.path]
if (foundState) {
acc[field.path] = formState?.[field.path]?.value
}
return acc
}, {} as FormData)
return filteredData
}
const Submit: React.FC<{
readonly action: string
readonly disabled: boolean
readonly selected?: FieldWithPathClient[]
}> = ({ action, disabled, selected }) => {
const { submit } = useForm()
const { t } = useTranslation()
const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: (formState) => sanitizeUnselectedFields(formState, selected),
skipValidation: true,
})
}, [action, submit, selected])
return (
<FormSubmit className={`${baseClass}__save`} disabled={disabled} onClick={save}>
{t('general:save')}
</FormSubmit>
)
}
const PublishButton: React.FC<{
action: string
disabled: boolean
selected?: FieldWithPathClient[]
}> = ({ action, disabled, selected }) => {
const { submit } = useForm()
const { t } = useTranslation()
const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: (formState) => ({
...sanitizeUnselectedFields(formState, selected),
_status: 'published',
}),
skipValidation: true,
})
}, [action, submit, selected])
return (
<FormSubmit className={`${baseClass}__publish`} disabled={disabled} onClick={save}>
{t('version:publishChanges')}
</FormSubmit>
)
}
const SaveDraftButton: React.FC<{
action: string
disabled: boolean
selected?: FieldWithPathClient[]
}> = ({ action, disabled, selected }) => {
const { submit } = useForm()
const { t } = useTranslation()
const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: (formState) => ({
...sanitizeUnselectedFields(formState, selected),
_status: 'draft',
}),
skipValidation: true,
})
}, [action, submit, selected])
return (
<FormSubmit
buttonStyle="secondary"
className={`${baseClass}__draft`}
disabled={disabled}
onClick={save}
>
{t('version:saveDraft')}
</FormSubmit>
)
}
export const EditManyDrawerContent: React.FC<
{
drawerSlug: string
selected: FieldWithPathClient[]
} & EditManyProps
> = (props) => {
const {
collection: { slug, fields, labels: { plural } } = {},
collection,
drawerSlug,
selected: selectedFromProps,
} = props
const { permissions, user } = useAuth()
const { closeModal } = useModal()
const {
config: {
routes: { api: apiRoute },
serverURL,
},
} = useConfig()
const { getFormState } = useServerFunctions()
const { count, getQueryParams, selectAll } = useSelection()
const { i18n, t } = useTranslation()
const [selected, setSelected] = useState<FieldWithPathClient[]>([])
useEffect(() => {
setSelected(selectedFromProps)
}, [selectedFromProps])
const router = useRouter()
const [initialState, setInitialState] = useState<FormState>()
const hasInitializedState = React.useRef(false)
const abortFormStateRef = React.useRef<AbortController>(null)
const { clearRouteCache } = useRouteCache()
const collectionPermissions = permissions?.collections?.[slug]
const searchParams = useSearchParams()
React.useEffect(() => {
const controller = new AbortController()
if (!hasInitializedState.current) {
const getInitialState = async () => {
const { state: result } = await getFormState({
collectionSlug: slug,
data: {},
docPermissions: collectionPermissions,
docPreferences: null,
operation: 'update',
schemaPath: slug,
signal: controller.signal,
})
setInitialState(result)
hasInitializedState.current = true
}
void getInitialState()
}
return () => {
abortAndIgnore(controller)
}
}, [apiRoute, hasInitializedState, serverURL, slug, getFormState, user, collectionPermissions])
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
const controller = handleAbortRef(abortFormStateRef)
const { state } = await getFormState({
collectionSlug: slug,
docPermissions: collectionPermissions,
docPreferences: null,
formState: prevFormState,
operation: 'update',
schemaPath: slug,
signal: controller.signal,
})
abortFormStateRef.current = null
return state
},
[getFormState, slug, collectionPermissions],
)
useEffect(() => {
const abortFormState = abortFormStateRef.current
return () => {
abortAndIgnore(abortFormState)
}
}, [])
const queryString = useMemo(() => {
const queryWithSearch = mergeListSearchAndWhere({
collectionConfig: collection,
search: searchParams.get('search'),
})
return getQueryParams(queryWithSearch)
}, [collection, searchParams, getQueryParams])
const onSuccess = () => {
router.replace(
qs.stringify(
{
...parseSearchParams(searchParams),
page: selectAll === SelectAllStatus.AllAvailable ? '1' : undefined,
},
{ addQueryPrefix: true },
),
)
clearRouteCache() // Use clearRouteCache instead of router.refresh, as we only need to clear the cache if the user has route caching enabled - clearRouteCache checks for this
closeModal(drawerSlug)
}
return (
<DocumentInfoProvider
collectionSlug={slug}
currentEditor={user}
hasPublishedDoc={false}
id={null}
initialData={{}}
initialState={initialState}
isLocked={false}
lastUpdateTime={0}
mostRecentVersionIsAutosaved={false}
unpublishedVersionCount={0}
versionCount={0}
>
<OperationContext.Provider value="update">
<div className={`${baseClass}__main`}>
<div className={`${baseClass}__header`}>
<h2 className={`${baseClass}__header__title`}>
{t('general:editingLabel', { count, label: getTranslation(plural, i18n) })}
</h2>
<button
aria-label={t('general:close')}
className={`${baseClass}__header__close`}
id={`close-drawer__${drawerSlug}`}
onClick={() => closeModal(drawerSlug)}
type="button"
>
<XIcon />
</button>
</div>
<Form
className={`${baseClass}__form`}
initialState={initialState}
onChange={[onChange]}
onSuccess={onSuccess}
>
<FieldSelect fields={fields} setSelected={setSelected} />
{selected.length === 0 ? null : (
<RenderFields
fields={selected}
parentIndexPath=""
parentPath=""
parentSchemaPath={slug}
permissions={collectionPermissions?.fields}
readOnly={false}
/>
)}
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}>
<div className={`${baseClass}__document-actions`}>
{collection?.versions?.drafts ? (
<React.Fragment>
<SaveDraftButton
action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`}
disabled={selected.length === 0}
selected={selected}
/>
<PublishButton
action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`}
disabled={selected.length === 0}
selected={selected}
/>
</React.Fragment>
) : (
<Submit
action={`${serverURL}${apiRoute}/${slug}${queryString}`}
disabled={selected.length === 0}
selected={selected}
/>
)}
</div>
</div>
</div>
</div>
</Form>
</div>
</OperationContext.Provider>
</DocumentInfoProvider>
)
}

View File

@@ -1,248 +1,38 @@
'use client'
import type { ClientCollectionConfig, FieldWithPathClient, FormState } from 'payload'
import type { ClientCollectionConfig, FieldWithPathClient } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter, useSearchParams } from 'next/navigation.js'
import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useState } from 'react'
import type { FormProps } from '../../forms/Form/index.js'
import { useForm } from '../../forms/Form/context.js'
import { Form } from '../../forms/Form/index.js'
import { RenderFields } from '../../forms/RenderFields/index.js'
import { FormSubmit } from '../../forms/Submit/index.js'
import { XIcon } from '../../icons/X/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { DocumentInfoProvider } from '../../providers/DocumentInfo/index.js'
import { EditDepthProvider } from '../../providers/EditDepth/index.js'
import { OperationContext } from '../../providers/Operation/index.js'
import { useRouteCache } from '../../providers/RouteCache/index.js'
import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import './index.scss'
import { Drawer, DrawerToggler } from '../Drawer/index.js'
import { FieldSelect } from '../FieldSelect/index.js'
import { EditManyDrawerContent } from './DrawerContent.js'
const baseClass = 'edit-many'
export const baseClass = 'edit-many'
export type EditManyProps = {
readonly collection: ClientCollectionConfig
}
const sanitizeUnselectedFields = (formState: FormState, selected: FieldWithPathClient[]) => {
const filteredData = selected.reduce((acc, field) => {
const foundState = formState?.[field.path]
if (foundState) {
acc[field.path] = formState?.[field.path]?.value
}
return acc
}, {} as FormData)
return filteredData
}
const Submit: React.FC<{
readonly action: string
readonly disabled: boolean
readonly selected?: FieldWithPathClient[]
}> = ({ action, disabled, selected }) => {
const { submit } = useForm()
const { t } = useTranslation()
const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: (formState) => sanitizeUnselectedFields(formState, selected),
skipValidation: true,
})
}, [action, submit, selected])
return (
<FormSubmit className={`${baseClass}__save`} disabled={disabled} onClick={save}>
{t('general:save')}
</FormSubmit>
)
}
const PublishButton: React.FC<{
action: string
disabled: boolean
selected?: FieldWithPathClient[]
}> = ({ action, disabled, selected }) => {
const { submit } = useForm()
const { t } = useTranslation()
const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: (formState) => ({
...sanitizeUnselectedFields(formState, selected),
_status: 'published',
}),
skipValidation: true,
})
}, [action, submit, selected])
return (
<FormSubmit className={`${baseClass}__publish`} disabled={disabled} onClick={save}>
{t('version:publishChanges')}
</FormSubmit>
)
}
const SaveDraftButton: React.FC<{
action: string
disabled: boolean
selected?: FieldWithPathClient[]
}> = ({ action, disabled, selected }) => {
const { submit } = useForm()
const { t } = useTranslation()
const save = useCallback(() => {
void submit({
action,
method: 'PATCH',
overrides: (formState) => ({
...sanitizeUnselectedFields(formState, selected),
_status: 'draft',
}),
skipValidation: true,
})
}, [action, submit, selected])
return (
<FormSubmit
buttonStyle="secondary"
className={`${baseClass}__draft`}
disabled={disabled}
onClick={save}
>
{t('version:saveDraft')}
</FormSubmit>
)
}
export const EditMany: React.FC<EditManyProps> = (props) => {
const { collection: { slug, fields, labels: { plural } } = {}, collection } = props
const { permissions, user } = useAuth()
const { closeModal } = useModal()
const {
config: {
routes: { api: apiRoute },
serverURL,
},
} = useConfig()
collection: { slug },
} = props
const { getFormState } = useServerFunctions()
const { permissions } = useAuth()
const { count, getQueryParams, selectAll } = useSelection()
const { i18n, t } = useTranslation()
const { selectAll } = useSelection()
const { t } = useTranslation()
const [selected, setSelected] = useState<FieldWithPathClient[]>([])
const searchParams = useSearchParams()
const router = useRouter()
const [initialState, setInitialState] = useState<FormState>()
const hasInitializedState = React.useRef(false)
const abortFormStateRef = React.useRef<AbortController>(null)
const { clearRouteCache } = useRouteCache()
const collectionPermissions = permissions?.collections?.[slug]
const hasUpdatePermission = collectionPermissions?.update
const drawerSlug = `edit-${slug}`
React.useEffect(() => {
const controller = new AbortController()
if (!hasInitializedState.current) {
const getInitialState = async () => {
const { state: result } = await getFormState({
collectionSlug: slug,
data: {},
docPermissions: collectionPermissions,
docPreferences: null,
operation: 'update',
schemaPath: slug,
signal: controller.signal,
})
setInitialState(result)
hasInitializedState.current = true
}
void getInitialState()
}
return () => {
abortAndIgnore(controller)
}
}, [apiRoute, hasInitializedState, serverURL, slug, getFormState, user, collectionPermissions])
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
const controller = handleAbortRef(abortFormStateRef)
const { state } = await getFormState({
collectionSlug: slug,
docPermissions: collectionPermissions,
docPreferences: null,
formState: prevFormState,
operation: 'update',
schemaPath: slug,
signal: controller.signal,
})
abortFormStateRef.current = null
return state
},
[getFormState, slug, collectionPermissions],
)
useEffect(() => {
const abortFormState = abortFormStateRef.current
return () => {
abortAndIgnore(abortFormState)
}
}, [])
const queryString = useMemo(() => {
const queryWithSearch = mergeListSearchAndWhere({
collectionConfig: collection,
search: searchParams.get('search'),
})
return getQueryParams(queryWithSearch)
}, [collection, searchParams, getQueryParams])
const onSuccess = () => {
router.replace(
qs.stringify(
{
...parseSearchParams(searchParams),
page: selectAll === SelectAllStatus.AllAvailable ? '1' : undefined,
},
{ addQueryPrefix: true },
),
)
clearRouteCache() // Use clearRouteCache instead of router.refresh, as we only need to clear the cache if the user has route caching enabled - clearRouteCache checks for this
closeModal(drawerSlug)
}
if (selectAll === SelectAllStatus.None || !hasUpdatePermission) {
return null
}
@@ -261,84 +51,11 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
</DrawerToggler>
<EditDepthProvider>
<Drawer Header={null} slug={drawerSlug}>
<DocumentInfoProvider
collectionSlug={slug}
currentEditor={user}
hasPublishedDoc={false}
id={null}
initialData={{}}
initialState={initialState}
isLocked={false}
lastUpdateTime={0}
mostRecentVersionIsAutosaved={false}
unpublishedVersionCount={0}
versionCount={0}
>
<OperationContext.Provider value="update">
<div className={`${baseClass}__main`}>
<div className={`${baseClass}__header`}>
<h2 className={`${baseClass}__header__title`}>
{t('general:editingLabel', { count, label: getTranslation(plural, i18n) })}
</h2>
<button
aria-label={t('general:close')}
className={`${baseClass}__header__close`}
id={`close-drawer__${drawerSlug}`}
onClick={() => closeModal(drawerSlug)}
type="button"
>
<XIcon />
</button>
</div>
<Form
className={`${baseClass}__form`}
initialState={initialState}
onChange={[onChange]}
onSuccess={onSuccess}
>
<FieldSelect fields={fields} setSelected={setSelected} />
{selected.length === 0 ? null : (
<RenderFields
fields={selected}
parentIndexPath=""
parentPath=""
parentSchemaPath={slug}
permissions={collectionPermissions?.fields}
readOnly={false}
/>
)}
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}>
<div className={`${baseClass}__document-actions`}>
{collection?.versions?.drafts ? (
<React.Fragment>
<SaveDraftButton
action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`}
disabled={selected.length === 0}
selected={selected}
/>
<PublishButton
action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`}
disabled={selected.length === 0}
selected={selected}
/>
</React.Fragment>
) : (
<Submit
action={`${serverURL}${apiRoute}/${slug}${queryString}`}
disabled={selected.length === 0}
selected={selected}
/>
)}
</div>
</div>
</div>
</div>
</Form>
</div>
</OperationContext.Provider>
</DocumentInfoProvider>
<EditManyDrawerContent
collection={props.collection}
drawerSlug={drawerSlug}
selected={selected}
/>
</Drawer>
</EditDepthProvider>
</div>

View File

@@ -254,14 +254,12 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
{data.docs && data.docs.length > 0 && (
<RelationshipProvider>
<ListQueryProvider
collectionSlug={relationTo}
data={data}
defaultLimit={
field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit
}
modifySearchParams={false}
onQueryChange={setQuery}
preferenceKey={preferenceKey}
>
<TableColumnsProvider
collectionSlug={relationTo}

View File

@@ -8,7 +8,6 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { usePreferences } from '../Preferences/index.js'
type ContextHandlers = {
handlePageChange?: (page: number) => Promise<void>
@@ -20,7 +19,7 @@ type ContextHandlers = {
export type ListQueryProps = {
readonly children: React.ReactNode
readonly collectionSlug: string
readonly collectionSlug?: string
readonly data: PaginatedDocs
readonly defaultLimit?: number
readonly defaultSort?: Sort
@@ -43,17 +42,14 @@ export const useListQuery = (): ListQueryContext => useContext(Context)
export const ListQueryProvider: React.FC<ListQueryProps> = ({
children,
collectionSlug,
data,
defaultLimit,
defaultSort,
modifySearchParams,
onQueryChange: onQueryChangeFromProps,
preferenceKey,
}) => {
'use no memo'
const router = useRouter()
const { setPreference } = usePreferences()
const rawSearchParams = useSearchParams()
const searchParams = useMemo(() => parseSearchParams(rawSearchParams), [rawSearchParams])
@@ -176,7 +172,8 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
if (shouldUpdateQueryString) {
setCurrentQuery(newQuery)
router.replace(`?${qs.stringify(newQuery)}`)
// Do not use router.replace here to avoid re-rendering on initial load
window.history.pushState(null, '', `?${qs.stringify(newQuery)}`)
}
}
}, [defaultSort, defaultLimit, router, modifySearchParams])