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.
This commit is contained in:
Jacob Fletcher
2024-12-26 12:17:06 -05:00
committed by GitHub
parent 8debb68db2
commit b33f4b0143
15 changed files with 611 additions and 392 deletions

View File

@@ -20,7 +20,7 @@ import {
useServerFunctions,
useTranslation,
} from '@payloadcms/ui'
import { abortAndIgnore } from '@payloadcms/ui/shared'
import { abortAndIgnore, handleAbortRef } from '@payloadcms/ui/shared'
import React, { useEffect } from 'react'
export const CreateFirstUserClient: React.FC<{
@@ -43,16 +43,13 @@ export const CreateFirstUserClient: React.FC<{
const { t } = useTranslation()
const { setUser } = useAuth()
const formStateAbortControllerRef = React.useRef<AbortController>(null)
const abortOnChangeRef = React.useRef<AbortController>(null)
const collectionConfig = getEntityConfig({ collectionSlug: userSlug }) as ClientCollectionConfig
const onChange: FormProps['onChange'][0] = React.useCallback(
async ({ formState: prevFormState }) => {
abortAndIgnore(formStateAbortControllerRef.current)
const controller = new AbortController()
formStateAbortControllerRef.current = controller
const controller = handleAbortRef(abortOnChangeRef)
const response = await getFormState({
collectionSlug: userSlug,
@@ -64,6 +61,8 @@ export const CreateFirstUserClient: React.FC<{
signal: controller.signal,
})
abortOnChangeRef.current = null
if (response && response.state) {
return response.state
}
@@ -76,8 +75,10 @@ export const CreateFirstUserClient: React.FC<{
}
useEffect(() => {
const abortOnChange = abortOnChangeRef.current
return () => {
abortAndIgnore(formStateAbortControllerRef.current)
abortAndIgnore(abortOnChange)
}
}, [])

View File

@@ -7,6 +7,7 @@ import type {
ClientGlobalConfig,
ClientUser,
Data,
FormState,
LivePreviewConfig,
} from 'payload'
@@ -25,21 +26,25 @@ import {
useDocumentDrawerContext,
useDocumentEvents,
useDocumentInfo,
useEditDepth,
useServerFunctions,
useTranslation,
useUploadEdits,
} from '@payloadcms/ui'
import {
abortAndIgnore,
formatAdminURL,
handleAbortRef,
handleBackToDashboard,
handleGoBack,
handleTakeOver,
} from '@payloadcms/ui/shared'
import { useRouter } from 'next/navigation.js'
import { useRouter, useSearchParams } from 'next/navigation.js'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useLivePreviewContext } from './Context/context.js'
import { LivePreviewProvider } from './Context/index.js'
import './index.scss'
import { LivePreviewProvider } from './Context/index.js'
import { LivePreview } from './Preview/index.js'
import { usePopupWindow } from './usePopupWindow.js'
@@ -75,10 +80,12 @@ const PreviewView: React.FC<Props> = ({
disableLeaveWithoutSaving,
docPermissions,
documentIsLocked,
getDocPermissions,
getDocPreferences,
globalSlug,
hasPublishPermission,
hasSavePermission,
incrementVersionCount,
initialData,
initialState,
isEditing,
@@ -88,11 +95,10 @@ const PreviewView: React.FC<Props> = ({
setDocumentIsLocked,
unlockDocument,
updateDocumentEditor,
updateSavedDocumentData,
} = useDocumentInfo()
const { getFormState } = useServerFunctions()
const { onSave: onSaveFromProps } = useDocumentDrawerContext()
const { onSave: onSaveFromContext } = useDocumentDrawerContext()
const operation = id ? 'update' : 'create'
@@ -103,13 +109,21 @@ const PreviewView: React.FC<Props> = ({
},
} = useConfig()
const router = useRouter()
const params = useSearchParams()
const locale = params.get('locale')
const { t } = useTranslation()
const { previewWindowType } = useLivePreviewContext()
const { refreshCookieAsync, user } = useAuth()
const { reportUpdate } = useDocumentEvents()
const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions()
const docConfig = collectionConfig || globalConfig
const entitySlug = collectionConfig?.slug || globalConfig?.slug
const depth = useEditDepth()
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
const isLockingEnabled = lockDocumentsProp !== false
@@ -118,10 +132,19 @@ const PreviewView: React.FC<Props> = ({
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000
const autosaveEnabled = Boolean(
(collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave),
)
const preventLeaveWithoutSaving =
typeof disableLeaveWithoutSaving !== 'undefined' ? !disableLeaveWithoutSaving : !autosaveEnabled
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const formStateAbortControllerRef = useRef(new AbortController())
const abortOnChangeRef = useRef<AbortController>(null)
const abortOnSaveRef = useRef<AbortController>(null)
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
@@ -140,10 +163,12 @@ const PreviewView: React.FC<Props> = ({
})
const onSave = useCallback(
(json) => {
async (json): Promise<FormState> => {
const controller = handleAbortRef(abortOnSaveRef)
reportUpdate({
id,
entitySlug: collectionSlug,
entitySlug,
updatedAt: json?.result?.updatedAt || new Date().toISOString(),
})
@@ -153,38 +178,91 @@ const PreviewView: React.FC<Props> = ({
void refreshCookieAsync()
}
// Unlock the document after save
if ((id || globalSlug) && isLockingEnabled) {
setDocumentIsLocked(false)
incrementVersionCount()
if (typeof updateSavedDocumentData === 'function') {
void updateSavedDocumentData(json?.doc || {})
}
if (typeof onSaveFromProps === 'function') {
void onSaveFromProps({
if (typeof onSaveFromContext === 'function') {
void onSaveFromContext({
...json,
operation: id ? 'update' : 'create',
})
}
if (!isEditing && depth < 2) {
// Redirect to the same locale if it's been set
const redirectRoute = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`,
})
router.push(redirectRoute)
} else {
resetUploadEdits()
}
await getDocPermissions(json)
if ((id || globalSlug) && !autosaveEnabled) {
const docPreferences = await getDocPreferences()
const { state } = await getFormState({
id,
collectionSlug,
data: json?.doc || json?.result,
docPermissions,
docPreferences,
globalSlug,
operation,
renderAllFields: true,
returnLockStatus: false,
schemaPath: entitySlug,
signal: controller.signal,
})
// Unlock the document after save
if (isLockingEnabled) {
setDocumentIsLocked(false)
}
abortOnSaveRef.current = null
return state
}
},
[
adminRoute,
collectionSlug,
depth,
docPermissions,
entitySlug,
getDocPermissions,
getDocPreferences,
getFormState,
globalSlug,
id,
incrementVersionCount,
isEditing,
isLockingEnabled,
onSaveFromProps,
locale,
onSaveFromContext,
operation,
refreshCookieAsync,
reportUpdate,
resetUploadEdits,
router,
setDocumentIsLocked,
updateSavedDocumentData,
user,
userSlug,
autosaveEnabled,
],
)
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
abortAndIgnore(formStateAbortControllerRef.current)
const controller = new AbortController()
formStateAbortControllerRef.current = controller
const controller = handleAbortRef(abortOnChangeRef)
const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - editSessionStartTime
@@ -242,6 +320,8 @@ const PreviewView: React.FC<Props> = ({
}
}
abortOnChangeRef.current = null
return state
},
[
@@ -308,8 +388,12 @@ const PreviewView: React.FC<Props> = ({
])
useEffect(() => {
const abortOnChange = abortOnChangeRef.current
const abortOnSave = abortOnSaveRef.current
return () => {
abortAndIgnore(formStateAbortControllerRef.current)
abortAndIgnore(abortOnChange)
abortAndIgnore(abortOnSave)
}
})
@@ -372,12 +456,7 @@ const PreviewView: React.FC<Props> = ({
}}
/>
)}
{((collectionConfig &&
!(collectionConfig.versions?.drafts && collectionConfig.versions?.drafts?.autosave)) ||
(globalConfig &&
!(globalConfig.versions?.drafts && globalConfig.versions?.drafts?.autosave))) &&
!disableLeaveWithoutSaving &&
!isReadOnlyForIncomingUser && <LeaveWithoutSaving />}
{!isReadOnlyForIncomingUser && preventLeaveWithoutSaving && <LeaveWithoutSaving />}
<SetDocumentStepNav
collectionSlug={collectionSlug}
globalLabel={globalConfig?.label}

View File

@@ -17,7 +17,7 @@ import { useEditDepth } from '../../../providers/EditDepth/index.js'
import { OperationProvider } from '../../../providers/Operation/index.js'
import { useServerFunctions } from '../../../providers/ServerFunctions/index.js'
import { useUploadEdits } from '../../../providers/UploadEdits/index.js'
import { abortAndIgnore } from '../../../utilities/abortAndIgnore.js'
import { abortAndIgnore, handleAbortRef } from '../../../utilities/abortAndIgnore.js'
import { formatAdminURL } from '../../../utilities/formatAdminURL.js'
import { useDocumentDrawerContext } from '../../DocumentDrawer/Provider.js'
import { DocumentFields } from '../../DocumentFields/index.js'
@@ -56,7 +56,7 @@ export function EditForm({ submitted }: EditFormProps) {
getEntityConfig,
} = useConfig()
const formStateAbortControllerRef = React.useRef<AbortController>(null)
const abortOnChangeRef = React.useRef<AbortController>(null)
const collectionConfig = getEntityConfig({ collectionSlug: docSlug }) as ClientCollectionConfig
const router = useRouter()
@@ -111,12 +111,10 @@ export function EditForm({ submitted }: EditFormProps) {
const onChange: NonNullable<FormProps['onChange']>[0] = useCallback(
async ({ formState: prevFormState }) => {
abortAndIgnore(formStateAbortControllerRef.current)
const controller = new AbortController()
formStateAbortControllerRef.current = controller
const controller = handleAbortRef(abortOnChangeRef)
const docPreferences = await getDocPreferences()
const { state: newFormState } = await getFormState({
collectionSlug,
docPermissions,
@@ -127,14 +125,18 @@ export function EditForm({ submitted }: EditFormProps) {
signal: controller.signal,
})
abortOnChangeRef.current = null
return newFormState
},
[collectionSlug, schemaPath, getDocPreferences, getFormState, docPermissions],
)
useEffect(() => {
const abortOnChange = abortOnChangeRef.current
return () => {
abortAndIgnore(formStateAbortControllerRef.current)
abortAndIgnore(abortOnChange)
}
}, [])

View File

@@ -10,7 +10,7 @@ import { LoadingOverlay } from '../../elements/Loading/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { DocumentDrawerContextProvider } from './Provider.js'
export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
@@ -37,7 +37,7 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
collections.find((collection) => collection.slug === collectionSlug),
)
const documentViewAbortControllerRef = React.useRef<AbortController>(null)
const abortGetDocumentViewRef = React.useRef<AbortController>(null)
const { closeModal } = useModal()
const { t } = useTranslation()
@@ -50,10 +50,7 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
const getDocumentView = useCallback(
(docID?: number | string) => {
abortAndIgnore(documentViewAbortControllerRef.current)
const controller = new AbortController()
documentViewAbortControllerRef.current = controller
const controller = handleAbortRef(abortGetDocumentViewRef)
const fetchDocumentView = async () => {
setIsLoading(true)
@@ -81,6 +78,8 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
closeModal(drawerSlug)
// toast.error(data?.errors?.[0].message || t('error:unspecific'))
}
abortGetDocumentViewRef.current = null
}
void fetchDocumentView()
@@ -154,8 +153,10 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
// Cleanup any pending requests when the component unmounts
useEffect(() => {
const abortGetDocumentView = abortGetDocumentViewRef.current
return () => {
abortAndIgnore(documentViewAbortControllerRef.current)
abortAndIgnore(abortGetDocumentView)
}
}, [])

View File

@@ -23,7 +23,7 @@ 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 } from '../../utilities/abortAndIgnore.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import './index.scss'
@@ -156,7 +156,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
const router = useRouter()
const [initialState, setInitialState] = useState<FormState>()
const hasInitializedState = React.useRef(false)
const formStateAbortControllerRef = React.useRef<AbortController>(null)
const abortFormStateRef = React.useRef<AbortController>(null)
const { clearRouteCache } = useRouteCache()
const collectionPermissions = permissions?.collections?.[slug]
@@ -193,10 +193,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
abortAndIgnore(formStateAbortControllerRef.current)
const controller = new AbortController()
formStateAbortControllerRef.current = controller
const controller = handleAbortRef(abortFormStateRef)
const { state } = await getFormState({
collectionSlug: slug,
@@ -208,14 +205,18 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
signal: controller.signal,
})
abortFormStateRef.current = null
return state
},
[getFormState, slug, collectionPermissions],
)
useEffect(() => {
const abortFormState = abortFormStateRef.current
return () => {
abortAndIgnore(formStateAbortControllerRef.current)
abortAndIgnore(abortFormState)
}
}, [])

View File

@@ -10,7 +10,7 @@ import type { Column } from '../Table/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { usePreferences } from '../../providers/Preferences/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
export interface ITableColumns {
columns: Column[]
@@ -78,11 +78,12 @@ export const TableColumnsProvider: React.FC<Props> = ({
const { getPreference } = usePreferences()
const [tableColumns, setTableColumns] = React.useState(columnState)
const tableStateControllerRef = React.useRef<AbortController>(null)
const abortTableStateRef = React.useRef<AbortController>(null)
const abortToggleColumnRef = React.useRef<AbortController>(null)
const moveColumn = useCallback(
async (args: { fromIndex: number; toIndex: number }) => {
abortAndIgnore(tableStateControllerRef.current)
const controller = handleAbortRef(abortTableStateRef)
const { fromIndex, toIndex } = args
const withMovedColumn = [...tableColumns]
@@ -91,9 +92,6 @@ export const TableColumnsProvider: React.FC<Props> = ({
setTableColumns(withMovedColumn)
const controller = new AbortController()
tableStateControllerRef.current = controller
const result = await getTableState({
collectionSlug,
columns: sanitizeColumns(withMovedColumn),
@@ -108,6 +106,8 @@ export const TableColumnsProvider: React.FC<Props> = ({
setTableColumns(result.state)
setTable(result.Table)
}
abortTableStateRef.current = null
},
[
tableColumns,
@@ -123,7 +123,7 @@ export const TableColumnsProvider: React.FC<Props> = ({
const toggleColumn = useCallback(
async (column: string) => {
abortAndIgnore(tableStateControllerRef.current)
const controller = handleAbortRef(abortToggleColumnRef)
const { newColumnState, toggledColumns } = tableColumns.reduce<{
newColumnState: Column[]
@@ -155,9 +155,6 @@ export const TableColumnsProvider: React.FC<Props> = ({
setTableColumns(newColumnState)
const controller = new AbortController()
tableStateControllerRef.current = controller
const result = await getTableState({
collectionSlug,
columns: toggledColumns,
@@ -172,6 +169,8 @@ export const TableColumnsProvider: React.FC<Props> = ({
setTableColumns(result.state)
setTable(result.Table)
}
abortToggleColumnRef.current = null
},
[
tableColumns,
@@ -281,8 +280,10 @@ export const TableColumnsProvider: React.FC<Props> = ({
}, [columnState])
useEffect(() => {
const abortTableState = abortTableStateRef.current
return () => {
abortAndIgnore(tableStateControllerRef.current)
abortAndIgnore(abortTableState)
}
}, [])

View File

@@ -8,7 +8,7 @@ export { mergeFieldStyles } from '../../fields/mergeFieldStyles.js'
export { reduceToSerializableFields } from '../../forms/Form/reduceToSerializableFields.js'
export { PayloadIcon } from '../../graphics/Icon/index.js'
export { PayloadLogo } from '../../graphics/Logo/index.js'
export { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
export { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
export { requests } from '../../utilities/api.js'
export { findLocaleFromCode } from '../../utilities/findLocaleFromCode.js'
export { formatAdminURL } from '../../utilities/formatAdminURL.js'

View File

@@ -130,7 +130,9 @@ export const ArrayRow: React.FC<ArrayRowProps> = ({
}
header={
<div className={`${baseClass}__row-header`}>
{isLoading ? null : (
{isLoading ? (
<ShimmerEffect height="1rem" width="8rem" />
) : (
<RowLabel
CustomComponent={CustomRowLabel}
label={fallbackLabel}

View File

@@ -140,9 +140,10 @@ export const BlockRow: React.FC<BlocksFieldProps> = ({
: undefined
}
header={
isLoading
? null
: Label || (
isLoading ? (
<ShimmerEffect height="1rem" width="8rem" />
) : (
Label || (
<div className={`${baseClass}__block-header`}>
<span className={`${baseClass}__block-number`}>
{String(rowIndex + 1).padStart(2, '0')}
@@ -157,6 +158,7 @@ export const BlockRow: React.FC<BlocksFieldProps> = ({
{fieldHasErrors && <ErrorPill count={errorCount} i18n={i18n} withMessage />}
</div>
)
)
}
isCollapsed={row.collapsed}
key={row.id}

View File

@@ -30,7 +30,7 @@ import { useLocale } from '../../providers/Locale/index.js'
import { useOperation } from '../../providers/Operation/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { requests } from '../../utilities/api.js'
import {
FormContext,
@@ -93,7 +93,7 @@ export const Form: React.FC<FormProps> = (props) => {
const [submitted, setSubmitted] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const contextRef = useRef({} as FormContextType)
const resetFormStateAbortControllerRef = useRef<AbortController>(null)
const abortResetFormRef = useRef<AbortController>(null)
const fieldsReducer = useReducer(fieldReducer, {}, () => initialState)
@@ -483,10 +483,7 @@ export const Form: React.FC<FormProps> = (props) => {
const reset = useCallback(
async (data: unknown) => {
abortAndIgnore(resetFormStateAbortControllerRef.current)
const controller = new AbortController()
resetFormStateAbortControllerRef.current = controller
const controller = handleAbortRef(abortResetFormRef)
const docPreferences = await getDocPreferences()
@@ -507,6 +504,8 @@ export const Form: React.FC<FormProps> = (props) => {
contextRef.current = { ...initContextState } as FormContextType
setModified(false)
dispatchFields({ type: 'REPLACE_STATE', state: newState })
abortResetFormRef.current = null
},
[
collectionSlug,
@@ -576,8 +575,10 @@ export const Form: React.FC<FormProps> = (props) => {
)
useEffect(() => {
const abortOnChange = abortResetFormRef.current
return () => {
abortAndIgnore(resetFormStateAbortControllerRef.current)
abortAndIgnore(abortOnChange)
}
}, [])

View File

@@ -95,6 +95,7 @@ const DocumentInfo: React.FC<
const [mostRecentVersionIsAutosaved, setMostRecentVersionIsAutosaved] = useState(
mostRecentVersionIsAutosavedFromProps,
)
const [versionCount, setVersionCount] = useState(versionCountFromProps)
const [hasPublishedDoc, setHasPublishedDoc] = useState(hasPublishedDocFromProps)

View File

@@ -1,9 +1,26 @@
export function abortAndIgnore(controller: AbortController) {
if (controller) {
export function abortAndIgnore(abortController: AbortController) {
if (abortController) {
try {
controller.abort()
abortController.abort()
} catch (_err) {
// swallow error
}
}
}
export function handleAbortRef(
abortControllerRef: React.RefObject<AbortController>,
): AbortController {
if (abortControllerRef.current) {
try {
abortControllerRef.current.abort()
abortControllerRef.current = null
} catch (_err) {
// swallow error
}
} else {
const controller = new AbortController()
abortControllerRef.current = controller
return controller
}
}

View File

@@ -31,7 +31,7 @@ import { useEditDepth } from '../../providers/EditDepth/index.js'
import { OperationProvider } from '../../providers/Operation/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useUploadEdits } from '../../providers/UploadEdits/index.js'
import { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { handleBackToDashboard } from '../../utilities/handleBackToDashboard.js'
import { handleGoBack } from '../../utilities/handleGoBack.js'
@@ -120,8 +120,8 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions()
const onChangeAbortControllerRef = useRef<AbortController>(null)
const onSaveAbortControllerRef = useRef<AbortController>(null)
const abortOnChangeRef = useRef<AbortController>(null)
const abortOnSaveRef = useRef<AbortController>(null)
const locale = params.get('locale')
@@ -142,22 +142,13 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
const lockDurationInMilliseconds = lockDuration * 1000
let preventLeaveWithoutSaving = true
let autosaveEnabled = false
const autosaveEnabled = Boolean(
(collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave),
)
if (collectionConfig) {
autosaveEnabled = Boolean(
collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave,
)
preventLeaveWithoutSaving = !autosaveEnabled
} else if (globalConfig) {
autosaveEnabled = Boolean(
globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave,
)
preventLeaveWithoutSaving = !autosaveEnabled
} else if (typeof disableLeaveWithoutSaving !== 'undefined') {
preventLeaveWithoutSaving = !disableLeaveWithoutSaving
}
const preventLeaveWithoutSaving =
typeof disableLeaveWithoutSaving !== 'undefined' ? !disableLeaveWithoutSaving : !autosaveEnabled
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
@@ -238,6 +229,8 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
const onSave = useCallback(
async (json): Promise<FormState> => {
const controller = handleAbortRef(abortOnSaveRef)
reportUpdate({
id,
entitySlug,
@@ -277,10 +270,6 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
await getDocPermissions(json)
if ((id || globalSlug) && !autosaveEnabled) {
abortAndIgnore(onSaveAbortControllerRef.current)
const controller = new AbortController()
onSaveAbortControllerRef.current = controller
const docPreferences = await getDocPreferences()
const { state } = await getFormState({
@@ -302,6 +291,8 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
setDocumentIsLocked(false)
}
abortOnSaveRef.current = null
return state
}
},
@@ -337,10 +328,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
abortAndIgnore(onChangeAbortControllerRef.current)
const controller = new AbortController()
onChangeAbortControllerRef.current = controller
const controller = handleAbortRef(abortOnChangeRef)
const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - editSessionStartTime
@@ -374,6 +362,8 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
handleDocumentLocking(lockedState)
}
abortOnChangeRef.current = null
return state
},
[
@@ -428,9 +418,12 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
])
useEffect(() => {
const abortOnChange = abortOnChangeRef.current
const abortOnSave = abortOnSaveRef.current
return () => {
abortAndIgnore(onChangeAbortControllerRef.current)
abortAndIgnore(onSaveAbortControllerRef.current)
abortAndIgnore(abortOnChange)
abortAndIgnore(abortOnSave)
}
}, [])

View File

@@ -820,9 +820,31 @@ export interface PagesSelect<T extends boolean = true> {
layout?:
| T
| {
cta?:
cta?: T | CallToActionBlockSelect<T>;
content?: T | ContentBlockSelect<T>;
mediaBlock?: T | MediaBlockSelect<T>;
archive?: T | ArchiveBlockSelect<T>;
formBlock?: T | FormBlockSelect<T>;
};
meta?:
| T
| {
title?: T;
image?: T;
description?: T;
};
publishedAt?: T;
slug?: T;
slugLock?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "CallToActionBlock_select".
*/
export interface CallToActionBlockSelect<T extends boolean = true> {
richText?: T;
links?:
| T
@@ -841,10 +863,12 @@ export interface PagesSelect<T extends boolean = true> {
};
id?: T;
blockName?: T;
};
content?:
| T
| {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ContentBlock_select".
*/
export interface ContentBlockSelect<T extends boolean = true> {
columns?:
| T
| {
@@ -865,17 +889,21 @@ export interface PagesSelect<T extends boolean = true> {
};
id?: T;
blockName?: T;
};
mediaBlock?:
| T
| {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "MediaBlock_select".
*/
export interface MediaBlockSelect<T extends boolean = true> {
media?: T;
id?: T;
blockName?: T;
};
archive?:
| T
| {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ArchiveBlock_select".
*/
export interface ArchiveBlockSelect<T extends boolean = true> {
introContent?: T;
populateBy?: T;
relationTo?: T;
@@ -884,30 +912,17 @@ export interface PagesSelect<T extends boolean = true> {
selectedDocs?: T;
id?: T;
blockName?: T;
};
formBlock?:
| T
| {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "FormBlock_select".
*/
export interface FormBlockSelect<T extends boolean = true> {
form?: T;
enableIntro?: T;
introContent?: T;
id?: T;
blockName?: T;
};
};
meta?:
| T
| {
title?: T;
image?: T;
description?: T;
};
publishedAt?: T;
slug?: T;
slugLock?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -310,9 +310,6 @@ export interface LexicalMigrateField {
export interface LexicalLocalizedField {
id: string;
title: string;
/**
* Non-localized field with localized block subfields
*/
lexicalBlocksSubLocalized?: {
root: {
type: string;
@@ -328,9 +325,6 @@ export interface LexicalLocalizedField {
};
[k: string]: unknown;
} | null;
/**
* Localized field with localized block subfields
*/
lexicalBlocksLocalized?: {
root: {
type: string;
@@ -481,9 +475,6 @@ export interface ArrayField {
id?: string | null;
}[]
| null;
/**
* Row labels rendered as react components.
*/
rowLabelAsComponent?:
| {
title?: string | null;
@@ -598,9 +589,6 @@ export interface BlockField {
}
)[]
| null;
/**
* The purpose of this field is to test validateExistingBlockIsIdentical works with similar blocks with group fields
*/
blocksWithSimilarGroup?:
| (
| {
@@ -789,18 +777,9 @@ export interface TextField {
id: string;
text: string;
hiddenTextField?: string | null;
/**
* This field should be hidden
*/
adminHiddenTextField?: string | null;
/**
* This field should be disabled
*/
disabledTextField?: string | null;
localizedText?: string | null;
/**
* en description
*/
i18nText?: string | null;
defaultString?: string | null;
defaultEmptyString?: string | null;
@@ -921,14 +900,8 @@ export interface ConditionalLogic {
userConditional?: string | null;
parentGroup?: {
enableParentGroupFields?: boolean | null;
/**
* Ensures we can rely on nested fields within `data`.
*/
siblingField?: string | null;
};
/**
* Ensures we can rely on nested fields within `siblingsData`.
*/
reliesOnParentGroup?: string | null;
groupSelection?: ('group1' | 'group2') | null;
group1?: {
@@ -1008,9 +981,6 @@ export interface EmailField {
email: string;
localizedEmail?: string | null;
emailWithAutocomplete?: string | null;
/**
* en description
*/
i18nEmail?: string | null;
defaultEmail?: string | null;
defaultEmptyString?: string | null;
@@ -1040,9 +1010,6 @@ export interface RadioField {
*/
export interface GroupField {
id: string;
/**
* This is a group.
*/
group: {
text: string;
defaultParent?: string | null;
@@ -1435,9 +1402,6 @@ export interface RichTextField {
[k: string]: unknown;
};
lexicalCustomFields_html?: string | null;
/**
* This rich text field uses the lexical editor.
*/
lexical?: {
root: {
type: string;
@@ -1453,9 +1417,6 @@ export interface RichTextField {
};
[k: string]: unknown;
} | null;
/**
* This select field is rendered here to ensure its options dropdown renders above the rich text toolbar.
*/
selectHasMany?: ('one' | 'two' | 'three' | 'four' | 'five' | 'six')[] | null;
richText: {
[k: string]: unknown;
@@ -1544,9 +1505,6 @@ export interface TabsFields2 {
*/
export interface TabsField {
id: string;
/**
* This should not collapse despite there being many tabs pushing the main fields open.
*/
sidebarField?: string | null;
array: {
text: string;
@@ -2157,42 +2115,257 @@ export interface BlockFieldsSelect<T extends boolean = true> {
blocks?:
| T
| {
content?: T | ContentBlockSelect<T>;
number?: T | NumberBlockSelect<T>;
subBlocks?: T | SubBlocksBlockSelect<T>;
tabs?: T | TabsBlockSelect<T>;
content?:
| T
| {
text?: T;
richText?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
subBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
tabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
};
duplicate?:
| T
| {
content?: T | ContentBlockSelect<T>;
number?: T | NumberBlockSelect<T>;
subBlocks?: T | SubBlocksBlockSelect<T>;
tabs?: T | TabsBlockSelect<T>;
content?:
| T
| {
text?: T;
richText?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
subBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
tabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
};
collapsedByDefaultBlocks?:
| T
| {
localizedContent?: T | LocalizedContentBlockSelect<T>;
localizedNumber?: T | LocalizedNumberBlockSelect<T>;
localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
localizedTabs?: T | LocalizedTabsBlockSelect<T>;
localizedContent?:
| T
| {
text?: T;
richText?: T;
id?: T;
blockName?: T;
};
localizedNumber?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
localizedSubBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
localizedTabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
};
disableSort?:
| T
| {
localizedContent?: T | LocalizedContentBlockSelect<T>;
localizedNumber?: T | LocalizedNumberBlockSelect<T>;
localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
localizedTabs?: T | LocalizedTabsBlockSelect<T>;
localizedContent?:
| T
| {
text?: T;
richText?: T;
id?: T;
blockName?: T;
};
localizedNumber?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
localizedSubBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
localizedTabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
};
localizedBlocks?:
| T
| {
localizedContent?: T | LocalizedContentBlockSelect<T>;
localizedNumber?: T | LocalizedNumberBlockSelect<T>;
localizedSubBlocks?: T | LocalizedSubBlocksBlockSelect<T>;
localizedTabs?: T | LocalizedTabsBlockSelect<T>;
localizedContent?:
| T
| {
text?: T;
richText?: T;
id?: T;
blockName?: T;
};
localizedNumber?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
localizedSubBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
localizedTabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
};
i18nBlocks?:
| T
@@ -2330,116 +2503,6 @@ export interface BlockFieldsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ContentBlock_select".
*/
export interface ContentBlockSelect<T extends boolean = true> {
text?: T;
richText?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "NumberBlock_select".
*/
export interface NumberBlockSelect<T extends boolean = true> {
number?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "SubBlocksBlock_select".
*/
export interface SubBlocksBlockSelect<T extends boolean = true> {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TabsBlock_select".
*/
export interface TabsBlockSelect<T extends boolean = true> {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedContentBlock_select".
*/
export interface LocalizedContentBlockSelect<T extends boolean = true> {
text?: T;
richText?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedNumberBlock_select".
*/
export interface LocalizedNumberBlockSelect<T extends boolean = true> {
number?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedSubBlocksBlock_select".
*/
export interface LocalizedSubBlocksBlockSelect<T extends boolean = true> {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localizedTabsBlock_select".
*/
export interface LocalizedTabsBlockSelect<T extends boolean = true> {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "checkbox-fields_select".
@@ -3022,10 +3085,53 @@ export interface TabsFieldsSelect<T extends boolean = true> {
blocks?:
| T
| {
content?: T | ContentBlockSelect<T>;
number?: T | NumberBlockSelect<T>;
subBlocks?: T | SubBlocksBlockSelect<T>;
tabs?: T | TabsBlockSelect<T>;
content?:
| T
| {
text?: T;
richText?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
subBlocks?:
| T
| {
subBlocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
id?: T;
blockName?: T;
};
tabs?:
| T
| {
textInCollapsible?: T;
textInRow?: T;
id?: T;
blockName?: T;
};
};
group?:
| T
@@ -3035,7 +3141,24 @@ export interface TabsFieldsSelect<T extends boolean = true> {
textInRow?: T;
numberInRow?: T;
json?: T;
tab?: T | TabWithNameSelect<T>;
tab?:
| T
| {
array?:
| T
| {
text?: T;
id?: T;
};
text?: T;
defaultValue?: T;
arrayInRow?:
| T
| {
textInArrayInRow?: T;
id?: T;
};
};
namedTabWithDefaultValue?:
| T
| {
@@ -3085,26 +3208,6 @@ export interface TabsFieldsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TabWithName_select".
*/
export interface TabWithNameSelect<T extends boolean = true> {
array?:
| T
| {
text?: T;
id?: T;
};
text?: T;
defaultValue?: T;
arrayInRow?:
| T
| {
textInArrayInRow?: T;
id?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "text-fields_select".