Skip to content

Commit fafe37e

Browse files
authored
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
1 parent 0d47a5d commit fafe37e

File tree

6 files changed

+348
-308
lines changed

6 files changed

+348
-308
lines changed

packages/next/src/views/List/index.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,10 @@ export const renderListView = async (
204204
<Fragment>
205205
<HydrateAuthProvider permissions={permissions} />
206206
<ListQueryProvider
207-
collectionSlug={collectionSlug}
208207
data={data}
209208
defaultLimit={limit}
210209
defaultSort={sort}
211210
modifySearchParams={!isInDrawer}
212-
preferenceKey={preferenceKey}
213211
>
214212
{RenderServerComponent({
215213
clientProps,

packages/next/src/views/Versions/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,6 @@ export const VersionsView: PayloadServerReactComponent<EditViewComponent> = asyn
194194
<main className={baseClass}>
195195
<Gutter className={`${baseClass}__wrap`}>
196196
<ListQueryProvider
197-
collectionSlug={collectionSlug}
198197
data={versionsData}
199198
defaultLimit={limitToUse}
200199
defaultSort={sort as string}
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
'use client'
2+
3+
import type { FieldWithPathClient, FormState } from 'payload'
4+
5+
import { useModal } from '@faceless-ui/modal'
6+
import { getTranslation } from '@payloadcms/translations'
7+
import { useRouter, useSearchParams } from 'next/navigation.js'
8+
import * as qs from 'qs-esm'
9+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
10+
11+
import type { FormProps } from '../../forms/Form/index.js'
12+
13+
import { useForm } from '../../forms/Form/context.js'
14+
import { Form } from '../../forms/Form/index.js'
15+
import { RenderFields } from '../../forms/RenderFields/index.js'
16+
import { FormSubmit } from '../../forms/Submit/index.js'
17+
import { XIcon } from '../../icons/X/index.js'
18+
import { useAuth } from '../../providers/Auth/index.js'
19+
import { useConfig } from '../../providers/Config/index.js'
20+
import { DocumentInfoProvider } from '../../providers/DocumentInfo/index.js'
21+
import { OperationContext } from '../../providers/Operation/index.js'
22+
import { useRouteCache } from '../../providers/RouteCache/index.js'
23+
import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js'
24+
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
25+
import { useTranslation } from '../../providers/Translation/index.js'
26+
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
27+
import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js'
28+
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
29+
import './index.scss'
30+
import { FieldSelect } from '../FieldSelect/index.js'
31+
import { baseClass, type EditManyProps } from './index.js'
32+
33+
const sanitizeUnselectedFields = (formState: FormState, selected: FieldWithPathClient[]) => {
34+
const filteredData = selected.reduce((acc, field) => {
35+
const foundState = formState?.[field.path]
36+
37+
if (foundState) {
38+
acc[field.path] = formState?.[field.path]?.value
39+
}
40+
41+
return acc
42+
}, {} as FormData)
43+
44+
return filteredData
45+
}
46+
47+
const Submit: React.FC<{
48+
readonly action: string
49+
readonly disabled: boolean
50+
readonly selected?: FieldWithPathClient[]
51+
}> = ({ action, disabled, selected }) => {
52+
const { submit } = useForm()
53+
const { t } = useTranslation()
54+
55+
const save = useCallback(() => {
56+
void submit({
57+
action,
58+
method: 'PATCH',
59+
overrides: (formState) => sanitizeUnselectedFields(formState, selected),
60+
skipValidation: true,
61+
})
62+
}, [action, submit, selected])
63+
64+
return (
65+
<FormSubmit className={`${baseClass}__save`} disabled={disabled} onClick={save}>
66+
{t('general:save')}
67+
</FormSubmit>
68+
)
69+
}
70+
71+
const PublishButton: React.FC<{
72+
action: string
73+
disabled: boolean
74+
selected?: FieldWithPathClient[]
75+
}> = ({ action, disabled, selected }) => {
76+
const { submit } = useForm()
77+
const { t } = useTranslation()
78+
79+
const save = useCallback(() => {
80+
void submit({
81+
action,
82+
method: 'PATCH',
83+
overrides: (formState) => ({
84+
...sanitizeUnselectedFields(formState, selected),
85+
_status: 'published',
86+
}),
87+
skipValidation: true,
88+
})
89+
}, [action, submit, selected])
90+
91+
return (
92+
<FormSubmit className={`${baseClass}__publish`} disabled={disabled} onClick={save}>
93+
{t('version:publishChanges')}
94+
</FormSubmit>
95+
)
96+
}
97+
98+
const SaveDraftButton: React.FC<{
99+
action: string
100+
disabled: boolean
101+
selected?: FieldWithPathClient[]
102+
}> = ({ action, disabled, selected }) => {
103+
const { submit } = useForm()
104+
const { t } = useTranslation()
105+
106+
const save = useCallback(() => {
107+
void submit({
108+
action,
109+
method: 'PATCH',
110+
overrides: (formState) => ({
111+
...sanitizeUnselectedFields(formState, selected),
112+
_status: 'draft',
113+
}),
114+
skipValidation: true,
115+
})
116+
}, [action, submit, selected])
117+
118+
return (
119+
<FormSubmit
120+
buttonStyle="secondary"
121+
className={`${baseClass}__draft`}
122+
disabled={disabled}
123+
onClick={save}
124+
>
125+
{t('version:saveDraft')}
126+
</FormSubmit>
127+
)
128+
}
129+
130+
export const EditManyDrawerContent: React.FC<
131+
{
132+
drawerSlug: string
133+
selected: FieldWithPathClient[]
134+
} & EditManyProps
135+
> = (props) => {
136+
const {
137+
collection: { slug, fields, labels: { plural } } = {},
138+
collection,
139+
drawerSlug,
140+
selected: selectedFromProps,
141+
} = props
142+
143+
const { permissions, user } = useAuth()
144+
145+
const { closeModal } = useModal()
146+
147+
const {
148+
config: {
149+
routes: { api: apiRoute },
150+
serverURL,
151+
},
152+
} = useConfig()
153+
154+
const { getFormState } = useServerFunctions()
155+
156+
const { count, getQueryParams, selectAll } = useSelection()
157+
const { i18n, t } = useTranslation()
158+
const [selected, setSelected] = useState<FieldWithPathClient[]>([])
159+
160+
useEffect(() => {
161+
setSelected(selectedFromProps)
162+
}, [selectedFromProps])
163+
164+
const router = useRouter()
165+
const [initialState, setInitialState] = useState<FormState>()
166+
const hasInitializedState = React.useRef(false)
167+
const abortFormStateRef = React.useRef<AbortController>(null)
168+
const { clearRouteCache } = useRouteCache()
169+
const collectionPermissions = permissions?.collections?.[slug]
170+
const searchParams = useSearchParams()
171+
172+
React.useEffect(() => {
173+
const controller = new AbortController()
174+
175+
if (!hasInitializedState.current) {
176+
const getInitialState = async () => {
177+
const { state: result } = await getFormState({
178+
collectionSlug: slug,
179+
data: {},
180+
docPermissions: collectionPermissions,
181+
docPreferences: null,
182+
operation: 'update',
183+
schemaPath: slug,
184+
signal: controller.signal,
185+
})
186+
187+
setInitialState(result)
188+
hasInitializedState.current = true
189+
}
190+
191+
void getInitialState()
192+
}
193+
194+
return () => {
195+
abortAndIgnore(controller)
196+
}
197+
}, [apiRoute, hasInitializedState, serverURL, slug, getFormState, user, collectionPermissions])
198+
199+
const onChange: FormProps['onChange'][0] = useCallback(
200+
async ({ formState: prevFormState }) => {
201+
const controller = handleAbortRef(abortFormStateRef)
202+
203+
const { state } = await getFormState({
204+
collectionSlug: slug,
205+
docPermissions: collectionPermissions,
206+
docPreferences: null,
207+
formState: prevFormState,
208+
operation: 'update',
209+
schemaPath: slug,
210+
signal: controller.signal,
211+
})
212+
213+
abortFormStateRef.current = null
214+
215+
return state
216+
},
217+
[getFormState, slug, collectionPermissions],
218+
)
219+
220+
useEffect(() => {
221+
const abortFormState = abortFormStateRef.current
222+
223+
return () => {
224+
abortAndIgnore(abortFormState)
225+
}
226+
}, [])
227+
228+
const queryString = useMemo(() => {
229+
const queryWithSearch = mergeListSearchAndWhere({
230+
collectionConfig: collection,
231+
search: searchParams.get('search'),
232+
})
233+
234+
return getQueryParams(queryWithSearch)
235+
}, [collection, searchParams, getQueryParams])
236+
237+
const onSuccess = () => {
238+
router.replace(
239+
qs.stringify(
240+
{
241+
...parseSearchParams(searchParams),
242+
page: selectAll === SelectAllStatus.AllAvailable ? '1' : undefined,
243+
},
244+
{ addQueryPrefix: true },
245+
),
246+
)
247+
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
248+
closeModal(drawerSlug)
249+
}
250+
251+
return (
252+
<DocumentInfoProvider
253+
collectionSlug={slug}
254+
currentEditor={user}
255+
hasPublishedDoc={false}
256+
id={null}
257+
initialData={{}}
258+
initialState={initialState}
259+
isLocked={false}
260+
lastUpdateTime={0}
261+
mostRecentVersionIsAutosaved={false}
262+
unpublishedVersionCount={0}
263+
versionCount={0}
264+
>
265+
<OperationContext.Provider value="update">
266+
<div className={`${baseClass}__main`}>
267+
<div className={`${baseClass}__header`}>
268+
<h2 className={`${baseClass}__header__title`}>
269+
{t('general:editingLabel', { count, label: getTranslation(plural, i18n) })}
270+
</h2>
271+
<button
272+
aria-label={t('general:close')}
273+
className={`${baseClass}__header__close`}
274+
id={`close-drawer__${drawerSlug}`}
275+
onClick={() => closeModal(drawerSlug)}
276+
type="button"
277+
>
278+
<XIcon />
279+
</button>
280+
</div>
281+
<Form
282+
className={`${baseClass}__form`}
283+
initialState={initialState}
284+
onChange={[onChange]}
285+
onSuccess={onSuccess}
286+
>
287+
<FieldSelect fields={fields} setSelected={setSelected} />
288+
{selected.length === 0 ? null : (
289+
<RenderFields
290+
fields={selected}
291+
parentIndexPath=""
292+
parentPath=""
293+
parentSchemaPath={slug}
294+
permissions={collectionPermissions?.fields}
295+
readOnly={false}
296+
/>
297+
)}
298+
<div className={`${baseClass}__sidebar-wrap`}>
299+
<div className={`${baseClass}__sidebar`}>
300+
<div className={`${baseClass}__sidebar-sticky-wrap`}>
301+
<div className={`${baseClass}__document-actions`}>
302+
{collection?.versions?.drafts ? (
303+
<React.Fragment>
304+
<SaveDraftButton
305+
action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`}
306+
disabled={selected.length === 0}
307+
selected={selected}
308+
/>
309+
<PublishButton
310+
action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`}
311+
disabled={selected.length === 0}
312+
selected={selected}
313+
/>
314+
</React.Fragment>
315+
) : (
316+
<Submit
317+
action={`${serverURL}${apiRoute}/${slug}${queryString}`}
318+
disabled={selected.length === 0}
319+
selected={selected}
320+
/>
321+
)}
322+
</div>
323+
</div>
324+
</div>
325+
</div>
326+
</Form>
327+
</div>
328+
</OperationContext.Provider>
329+
</DocumentInfoProvider>
330+
)
331+
}

0 commit comments

Comments
 (0)