Skip to content

Commit b33f4b0

Browse files
authored
fix(ui): infinite loading states when adding blocks or array rows (#10175)
Fixes #10070. Adding new blocks or array rows can randomly get stuck within an infinite loading state. This was because the abort controllers responsible for disregarding duplicate `onChange` and `onSave` events was not properly resetting its refs across invocations. This caused subsequent event handlers to incorrectly abort themselves, leading to unresolved requests and a `null` form state. Similarly, the cleanup effects responsible for aborting these requests on component unmount were also referencing its `current` property directly off the refs, which can possible be stale if not first set as a variable outside the return function. This PR also carries over some missing `onSave` logic from the default edit view into the live preview view. In the future the logic between these two views should be standardized, as they're nearly identical but often become out of sync. This can likely be done through the use of reusable hooks, such as `useOnSave`, `useOnChange`, etc. Same with the document locking functionality which is complex and deeply integrated into each of these views.
1 parent 8debb68 commit b33f4b0

File tree

15 files changed

+612
-393
lines changed

15 files changed

+612
-393
lines changed

packages/next/src/views/CreateFirstUser/index.client.tsx

+8-7
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
useServerFunctions,
2121
useTranslation,
2222
} from '@payloadcms/ui'
23-
import { abortAndIgnore } from '@payloadcms/ui/shared'
23+
import { abortAndIgnore, handleAbortRef } from '@payloadcms/ui/shared'
2424
import React, { useEffect } from 'react'
2525

2626
export const CreateFirstUserClient: React.FC<{
@@ -43,16 +43,13 @@ export const CreateFirstUserClient: React.FC<{
4343
const { t } = useTranslation()
4444
const { setUser } = useAuth()
4545

46-
const formStateAbortControllerRef = React.useRef<AbortController>(null)
46+
const abortOnChangeRef = React.useRef<AbortController>(null)
4747

4848
const collectionConfig = getEntityConfig({ collectionSlug: userSlug }) as ClientCollectionConfig
4949

5050
const onChange: FormProps['onChange'][0] = React.useCallback(
5151
async ({ formState: prevFormState }) => {
52-
abortAndIgnore(formStateAbortControllerRef.current)
53-
54-
const controller = new AbortController()
55-
formStateAbortControllerRef.current = controller
52+
const controller = handleAbortRef(abortOnChangeRef)
5653

5754
const response = await getFormState({
5855
collectionSlug: userSlug,
@@ -64,6 +61,8 @@ export const CreateFirstUserClient: React.FC<{
6461
signal: controller.signal,
6562
})
6663

64+
abortOnChangeRef.current = null
65+
6766
if (response && response.state) {
6867
return response.state
6968
}
@@ -76,8 +75,10 @@ export const CreateFirstUserClient: React.FC<{
7675
}
7776

7877
useEffect(() => {
78+
const abortOnChange = abortOnChangeRef.current
79+
7980
return () => {
80-
abortAndIgnore(formStateAbortControllerRef.current)
81+
abortAndIgnore(abortOnChange)
8182
}
8283
}, [])
8384

packages/next/src/views/LivePreview/index.client.tsx

+104-25
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ClientGlobalConfig,
88
ClientUser,
99
Data,
10+
FormState,
1011
LivePreviewConfig,
1112
} from 'payload'
1213

@@ -25,21 +26,25 @@ import {
2526
useDocumentDrawerContext,
2627
useDocumentEvents,
2728
useDocumentInfo,
29+
useEditDepth,
2830
useServerFunctions,
2931
useTranslation,
32+
useUploadEdits,
3033
} from '@payloadcms/ui'
3134
import {
3235
abortAndIgnore,
36+
formatAdminURL,
37+
handleAbortRef,
3338
handleBackToDashboard,
3439
handleGoBack,
3540
handleTakeOver,
3641
} from '@payloadcms/ui/shared'
37-
import { useRouter } from 'next/navigation.js'
42+
import { useRouter, useSearchParams } from 'next/navigation.js'
3843
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
3944

4045
import { useLivePreviewContext } from './Context/context.js'
41-
import { LivePreviewProvider } from './Context/index.js'
4246
import './index.scss'
47+
import { LivePreviewProvider } from './Context/index.js'
4348
import { LivePreview } from './Preview/index.js'
4449
import { usePopupWindow } from './usePopupWindow.js'
4550

@@ -75,10 +80,12 @@ const PreviewView: React.FC<Props> = ({
7580
disableLeaveWithoutSaving,
7681
docPermissions,
7782
documentIsLocked,
83+
getDocPermissions,
7884
getDocPreferences,
7985
globalSlug,
8086
hasPublishPermission,
8187
hasSavePermission,
88+
incrementVersionCount,
8289
initialData,
8390
initialState,
8491
isEditing,
@@ -88,11 +95,10 @@ const PreviewView: React.FC<Props> = ({
8895
setDocumentIsLocked,
8996
unlockDocument,
9097
updateDocumentEditor,
98+
updateSavedDocumentData,
9199
} = useDocumentInfo()
92100

93-
const { getFormState } = useServerFunctions()
94-
95-
const { onSave: onSaveFromProps } = useDocumentDrawerContext()
101+
const { onSave: onSaveFromContext } = useDocumentDrawerContext()
96102

97103
const operation = id ? 'update' : 'create'
98104

@@ -103,13 +109,21 @@ const PreviewView: React.FC<Props> = ({
103109
},
104110
} = useConfig()
105111
const router = useRouter()
112+
const params = useSearchParams()
113+
const locale = params.get('locale')
106114
const { t } = useTranslation()
107115
const { previewWindowType } = useLivePreviewContext()
108116
const { refreshCookieAsync, user } = useAuth()
109117
const { reportUpdate } = useDocumentEvents()
118+
const { resetUploadEdits } = useUploadEdits()
119+
const { getFormState } = useServerFunctions()
110120

111121
const docConfig = collectionConfig || globalConfig
112122

123+
const entitySlug = collectionConfig?.slug || globalConfig?.slug
124+
125+
const depth = useEditDepth()
126+
113127
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
114128
const isLockingEnabled = lockDocumentsProp !== false
115129

@@ -118,10 +132,19 @@ const PreviewView: React.FC<Props> = ({
118132
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
119133
const lockDurationInMilliseconds = lockDuration * 1000
120134

135+
const autosaveEnabled = Boolean(
136+
(collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
137+
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave),
138+
)
139+
140+
const preventLeaveWithoutSaving =
141+
typeof disableLeaveWithoutSaving !== 'undefined' ? !disableLeaveWithoutSaving : !autosaveEnabled
142+
121143
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
122144
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
123145

124-
const formStateAbortControllerRef = useRef(new AbortController())
146+
const abortOnChangeRef = useRef<AbortController>(null)
147+
const abortOnSaveRef = useRef<AbortController>(null)
125148

126149
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
127150

@@ -140,10 +163,12 @@ const PreviewView: React.FC<Props> = ({
140163
})
141164

142165
const onSave = useCallback(
143-
(json) => {
166+
async (json): Promise<FormState> => {
167+
const controller = handleAbortRef(abortOnSaveRef)
168+
144169
reportUpdate({
145170
id,
146-
entitySlug: collectionSlug,
171+
entitySlug,
147172
updatedAt: json?.result?.updatedAt || new Date().toISOString(),
148173
})
149174

@@ -153,38 +178,91 @@ const PreviewView: React.FC<Props> = ({
153178
void refreshCookieAsync()
154179
}
155180

156-
// Unlock the document after save
157-
if ((id || globalSlug) && isLockingEnabled) {
158-
setDocumentIsLocked(false)
181+
incrementVersionCount()
182+
183+
if (typeof updateSavedDocumentData === 'function') {
184+
void updateSavedDocumentData(json?.doc || {})
159185
}
160186

161-
if (typeof onSaveFromProps === 'function') {
162-
void onSaveFromProps({
187+
if (typeof onSaveFromContext === 'function') {
188+
void onSaveFromContext({
163189
...json,
164190
operation: id ? 'update' : 'create',
165191
})
166192
}
193+
194+
if (!isEditing && depth < 2) {
195+
// Redirect to the same locale if it's been set
196+
const redirectRoute = formatAdminURL({
197+
adminRoute,
198+
path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`,
199+
})
200+
router.push(redirectRoute)
201+
} else {
202+
resetUploadEdits()
203+
}
204+
205+
await getDocPermissions(json)
206+
207+
if ((id || globalSlug) && !autosaveEnabled) {
208+
const docPreferences = await getDocPreferences()
209+
210+
const { state } = await getFormState({
211+
id,
212+
collectionSlug,
213+
data: json?.doc || json?.result,
214+
docPermissions,
215+
docPreferences,
216+
globalSlug,
217+
operation,
218+
renderAllFields: true,
219+
returnLockStatus: false,
220+
schemaPath: entitySlug,
221+
signal: controller.signal,
222+
})
223+
224+
// Unlock the document after save
225+
if (isLockingEnabled) {
226+
setDocumentIsLocked(false)
227+
}
228+
229+
abortOnSaveRef.current = null
230+
231+
return state
232+
}
167233
},
168234
[
235+
adminRoute,
169236
collectionSlug,
237+
depth,
238+
docPermissions,
239+
entitySlug,
240+
getDocPermissions,
241+
getDocPreferences,
242+
getFormState,
170243
globalSlug,
171244
id,
245+
incrementVersionCount,
246+
isEditing,
172247
isLockingEnabled,
173-
onSaveFromProps,
248+
locale,
249+
onSaveFromContext,
250+
operation,
174251
refreshCookieAsync,
175252
reportUpdate,
253+
resetUploadEdits,
254+
router,
176255
setDocumentIsLocked,
256+
updateSavedDocumentData,
177257
user,
178258
userSlug,
259+
autosaveEnabled,
179260
],
180261
)
181262

182263
const onChange: FormProps['onChange'][0] = useCallback(
183264
async ({ formState: prevFormState }) => {
184-
abortAndIgnore(formStateAbortControllerRef.current)
185-
186-
const controller = new AbortController()
187-
formStateAbortControllerRef.current = controller
265+
const controller = handleAbortRef(abortOnChangeRef)
188266

189267
const currentTime = Date.now()
190268
const timeSinceLastUpdate = currentTime - editSessionStartTime
@@ -242,6 +320,8 @@ const PreviewView: React.FC<Props> = ({
242320
}
243321
}
244322

323+
abortOnChangeRef.current = null
324+
245325
return state
246326
},
247327
[
@@ -308,8 +388,12 @@ const PreviewView: React.FC<Props> = ({
308388
])
309389

310390
useEffect(() => {
391+
const abortOnChange = abortOnChangeRef.current
392+
const abortOnSave = abortOnSaveRef.current
393+
311394
return () => {
312-
abortAndIgnore(formStateAbortControllerRef.current)
395+
abortAndIgnore(abortOnChange)
396+
abortAndIgnore(abortOnSave)
313397
}
314398
})
315399

@@ -372,12 +456,7 @@ const PreviewView: React.FC<Props> = ({
372456
}}
373457
/>
374458
)}
375-
{((collectionConfig &&
376-
!(collectionConfig.versions?.drafts && collectionConfig.versions?.drafts?.autosave)) ||
377-
(globalConfig &&
378-
!(globalConfig.versions?.drafts && globalConfig.versions?.drafts?.autosave))) &&
379-
!disableLeaveWithoutSaving &&
380-
!isReadOnlyForIncomingUser && <LeaveWithoutSaving />}
459+
{!isReadOnlyForIncomingUser && preventLeaveWithoutSaving && <LeaveWithoutSaving />}
381460
<SetDocumentStepNav
382461
collectionSlug={collectionSlug}
383462
globalLabel={globalConfig?.label}

packages/ui/src/elements/BulkUpload/EditForm/index.tsx

+9-7
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { useEditDepth } from '../../../providers/EditDepth/index.js'
1717
import { OperationProvider } from '../../../providers/Operation/index.js'
1818
import { useServerFunctions } from '../../../providers/ServerFunctions/index.js'
1919
import { useUploadEdits } from '../../../providers/UploadEdits/index.js'
20-
import { abortAndIgnore } from '../../../utilities/abortAndIgnore.js'
20+
import { abortAndIgnore, handleAbortRef } from '../../../utilities/abortAndIgnore.js'
2121
import { formatAdminURL } from '../../../utilities/formatAdminURL.js'
2222
import { useDocumentDrawerContext } from '../../DocumentDrawer/Provider.js'
2323
import { DocumentFields } from '../../DocumentFields/index.js'
@@ -56,7 +56,7 @@ export function EditForm({ submitted }: EditFormProps) {
5656
getEntityConfig,
5757
} = useConfig()
5858

59-
const formStateAbortControllerRef = React.useRef<AbortController>(null)
59+
const abortOnChangeRef = React.useRef<AbortController>(null)
6060

6161
const collectionConfig = getEntityConfig({ collectionSlug: docSlug }) as ClientCollectionConfig
6262
const router = useRouter()
@@ -111,12 +111,10 @@ export function EditForm({ submitted }: EditFormProps) {
111111

112112
const onChange: NonNullable<FormProps['onChange']>[0] = useCallback(
113113
async ({ formState: prevFormState }) => {
114-
abortAndIgnore(formStateAbortControllerRef.current)
115-
116-
const controller = new AbortController()
117-
formStateAbortControllerRef.current = controller
114+
const controller = handleAbortRef(abortOnChangeRef)
118115

119116
const docPreferences = await getDocPreferences()
117+
120118
const { state: newFormState } = await getFormState({
121119
collectionSlug,
122120
docPermissions,
@@ -127,14 +125,18 @@ export function EditForm({ submitted }: EditFormProps) {
127125
signal: controller.signal,
128126
})
129127

128+
abortOnChangeRef.current = null
129+
130130
return newFormState
131131
},
132132
[collectionSlug, schemaPath, getDocPreferences, getFormState, docPermissions],
133133
)
134134

135135
useEffect(() => {
136+
const abortOnChange = abortOnChangeRef.current
137+
136138
return () => {
137-
abortAndIgnore(formStateAbortControllerRef.current)
139+
abortAndIgnore(abortOnChange)
138140
}
139141
}, [])
140142

0 commit comments

Comments
 (0)