chore: improves abort controller logic for server functions (#9131)

### What?
Removes abort controllers that were shared globally inside the server
actions provider.

### Why?
Constructing them in this way will cause different fetches using the
same function to cancel one another accidentally.

These are currently causing issues when two components call server
functions, even different functions, because the global ref inside was
being overwritten and aborting the previous one.

### How?
Standardizes how we construct and destroy abort controllers. This PR is focused around creating them to pass into the exposed serverAction provider functions. There are other places where this pattern can be applied.
This commit is contained in:
Jarrod Flesch
2024-11-12 11:20:17 -05:00
committed by GitHub
parent 7cd805adb9
commit 97cffa51f8
16 changed files with 199 additions and 241 deletions

View File

@@ -20,7 +20,8 @@ import {
useServerFunctions, useServerFunctions,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import React from 'react' import { abortAndIgnore } from '@payloadcms/ui/shared'
import React, { useEffect } from 'react'
export const CreateFirstUserClient: React.FC<{ export const CreateFirstUserClient: React.FC<{
docPermissions: DocumentPermissions docPermissions: DocumentPermissions
@@ -42,10 +43,17 @@ export const CreateFirstUserClient: React.FC<{
const { t } = useTranslation() const { t } = useTranslation()
const { setUser } = useAuth() const { setUser } = useAuth()
const formStateAbortControllerRef = React.useRef<AbortController>(null)
const collectionConfig = getEntityConfig({ collectionSlug: userSlug }) as ClientCollectionConfig const collectionConfig = getEntityConfig({ collectionSlug: userSlug }) as ClientCollectionConfig
const onChange: FormProps['onChange'][0] = React.useCallback( const onChange: FormProps['onChange'][0] = React.useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
abortAndIgnore(formStateAbortControllerRef.current)
const controller = new AbortController()
formStateAbortControllerRef.current = controller
const { state } = await getFormState({ const { state } = await getFormState({
collectionSlug: userSlug, collectionSlug: userSlug,
docPermissions, docPermissions,
@@ -53,6 +61,7 @@ export const CreateFirstUserClient: React.FC<{
formState: prevFormState, formState: prevFormState,
operation: 'create', operation: 'create',
schemaPath: `_${userSlug}.auth`, schemaPath: `_${userSlug}.auth`,
signal: controller.signal,
}) })
return state return state
@@ -64,6 +73,12 @@ export const CreateFirstUserClient: React.FC<{
setUser(data) setUser(data)
} }
useEffect(() => {
return () => {
abortAndIgnore(formStateAbortControllerRef.current)
}
}, [])
return ( return (
<Form <Form
action={`${serverURL}${apiRoute}/${userSlug}/first-register`} action={`${serverURL}${apiRoute}/${userSlug}/first-register`}

View File

@@ -28,7 +28,12 @@ import {
useServerFunctions, useServerFunctions,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { handleBackToDashboard, handleGoBack, handleTakeOver } from '@payloadcms/ui/shared' import {
abortAndIgnore,
handleBackToDashboard,
handleGoBack,
handleTakeOver,
} from '@payloadcms/ui/shared'
import { useRouter } from 'next/navigation.js' import { useRouter } from 'next/navigation.js'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react' import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
@@ -116,7 +121,7 @@ const PreviewView: React.FC<Props> = ({
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false) const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false) const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const abortControllerRef = useRef(new AbortController()) const formStateAbortControllerRef = useRef(new AbortController())
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now()) const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
@@ -176,16 +181,10 @@ const PreviewView: React.FC<Props> = ({
const onChange: FormProps['onChange'][0] = useCallback( const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
if (abortControllerRef.current) { abortAndIgnore(formStateAbortControllerRef.current)
try {
abortControllerRef.current.abort()
} catch (_err) {
// swallow error
}
}
const abortController = new AbortController() const controller = new AbortController()
abortControllerRef.current = abortController formStateAbortControllerRef.current = controller
const currentTime = Date.now() const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - editSessionStartTime const timeSinceLastUpdate = currentTime - editSessionStartTime
@@ -208,7 +207,7 @@ const PreviewView: React.FC<Props> = ({
operation, operation,
returnLockStatus: isLockingEnabled ? true : false, returnLockStatus: isLockingEnabled ? true : false,
schemaPath, schemaPath,
signal: abortController.signal, signal: controller.signal,
updateLastEdited, updateLastEdited,
}) })
@@ -265,14 +264,6 @@ const PreviewView: React.FC<Props> = ({
// Clean up when the component unmounts or when the document is unlocked // Clean up when the component unmounts or when the document is unlocked
useEffect(() => { useEffect(() => {
return () => { return () => {
if (abortControllerRef.current) {
try {
abortControllerRef.current.abort()
} catch (_err) {
// swallow error
}
}
if (!isLockingEnabled) { if (!isLockingEnabled) {
return return
} }
@@ -316,6 +307,12 @@ const PreviewView: React.FC<Props> = ({
setDocumentIsLocked, setDocumentIsLocked,
]) ])
useEffect(() => {
return () => {
abortAndIgnore(formStateAbortControllerRef.current)
}
})
const shouldShowDocumentLockedModal = const shouldShowDocumentLockedModal =
documentIsLocked && documentIsLocked &&
currentEditor && currentEditor &&

View File

@@ -11,6 +11,7 @@ import {
useServerFunctions, useServerFunctions,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { abortAndIgnore } from '@payloadcms/ui/shared'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const baseClass = 'lexical-block' const baseClass = 'lexical-block'
@@ -37,7 +38,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
const { const {
fieldProps: { featureClientSchemaMap, field: parentLexicalRichTextField, path, schemaPath }, fieldProps: { featureClientSchemaMap, field: parentLexicalRichTextField, path, schemaPath },
} = useEditorConfigContext() } = useEditorConfigContext()
const abortControllerRef = useRef(new AbortController()) const onChangeAbortControllerRef = useRef(new AbortController())
const { docPermissions, getDocPreferences } = useDocumentInfo() const { docPermissions, getDocPreferences } = useDocumentInfo()
const { getFormState } = useServerFunctions() const { getFormState } = useServerFunctions()
@@ -69,7 +70,6 @@ export const BlockComponent: React.FC<Props> = (props) => {
data: formData, data: formData,
docPermissions, docPermissions,
docPreferences: await getDocPreferences(), docPreferences: await getDocPreferences(),
doNotAbort: true,
globalSlug, globalSlug,
operation: 'update', operation: 'update',
renderAllFields: true, renderAllFields: true,
@@ -94,11 +94,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
} }
return () => { return () => {
try { abortAndIgnore(abortController)
abortController.abort()
} catch (_err) {
// swallow error
}
} }
}, [ }, [
getFormState, getFormState,
@@ -108,32 +104,26 @@ export const BlockComponent: React.FC<Props> = (props) => {
globalSlug, globalSlug,
getDocPreferences, getDocPreferences,
docPermissions, docPermissions,
]) // DO NOT ADD FORMDATA HERE! Adding formData will kick you out of sub block editors while writing. // DO NOT ADD FORMDATA HERE! Adding formData will kick you out of sub block editors while writing.
])
const onChange = useCallback( const onChange = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
if (abortControllerRef.current) { abortAndIgnore(onChangeAbortControllerRef.current)
try {
abortControllerRef.current.abort()
} catch (_err) {
// swallow error
}
}
const abortController = new AbortController() const controller = new AbortController()
abortControllerRef.current = abortController onChangeAbortControllerRef.current = controller
const { state: newFormState } = await getFormState({ const { state: newFormState } = await getFormState({
id, id,
collectionSlug, collectionSlug,
docPermissions, docPermissions,
docPreferences: await getDocPreferences(), docPreferences: await getDocPreferences(),
doNotAbort: true,
formState: prevFormState, formState: prevFormState,
globalSlug, globalSlug,
operation: 'update', operation: 'update',
schemaPath: schemaFieldsPath, schemaPath: schemaFieldsPath,
signal: abortController.signal, signal: controller.signal,
}) })
if (!newFormState) { if (!newFormState) {
@@ -162,16 +152,9 @@ export const BlockComponent: React.FC<Props> = (props) => {
], ],
) )
// cleanup effect
useEffect(() => { useEffect(() => {
const abortController = abortControllerRef.current
return () => { return () => {
try { abortAndIgnore(onChangeAbortControllerRef.current)
abortController.abort()
} catch (_err) {
// swallow error
}
} }
}, []) }, [])
@@ -243,7 +226,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
schemaFieldsPath, schemaFieldsPath,
classNames, classNames,
i18n, i18n,
]) // DO NOT ADD FORMDATA HERE! Adding formData will kick you out of sub block editors while writing. // DO NOT ADD FORMDATA HERE! Adding formData will kick you out of sub block editors while writing.
])
return <div className={baseClass + ' ' + baseClass + '-' + formData.blockType}>{formContent}</div> return <div className={baseClass + ' ' + baseClass + '-' + formData.blockType}>{formContent}</div>
} }

View File

@@ -9,6 +9,7 @@ import {
useServerFunctions, useServerFunctions,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { abortAndIgnore } from '@payloadcms/ui/shared'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
@@ -28,7 +29,7 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
const { t } = useTranslation() const { t } = useTranslation()
const { id, collectionSlug, docPermissions, getDocPreferences, globalSlug } = useDocumentInfo() const { id, collectionSlug, docPermissions, getDocPreferences, globalSlug } = useDocumentInfo()
const abortControllerRef = useRef(new AbortController()) const onChangeAbortControllerRef = useRef(new AbortController())
const [initialState, setInitialState] = useState<false | FormState | undefined>(false) const [initialState, setInitialState] = useState<false | FormState | undefined>(false)
@@ -45,7 +46,7 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
const fields: any = fieldMapOverride ?? featureClientSchemaMap[featureKey]?.[schemaFieldsPath] // Field Schema const fields: any = fieldMapOverride ?? featureClientSchemaMap[featureKey]?.[schemaFieldsPath] // Field Schema
useEffect(() => { useEffect(() => {
const abortController = new AbortController() const controller = new AbortController()
const awaitInitialState = async () => { const awaitInitialState = async () => {
const { state } = await getFormState({ const { state } = await getFormState({
@@ -54,12 +55,11 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
data: data ?? {}, data: data ?? {},
docPermissions, docPermissions,
docPreferences: await getDocPreferences(), docPreferences: await getDocPreferences(),
doNotAbort: true,
globalSlug, globalSlug,
operation: 'update', operation: 'update',
renderAllFields: true, renderAllFields: true,
schemaPath: schemaFieldsPath, schemaPath: schemaFieldsPath,
signal: abortController.signal, signal: controller.signal,
}) })
setInitialState(state) setInitialState(state)
@@ -68,11 +68,7 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
void awaitInitialState() void awaitInitialState()
return () => { return () => {
try { abortAndIgnore(controller)
abortController.abort()
} catch (_err) {
// swallow error
}
} }
}, [ }, [
schemaFieldsPath, schemaFieldsPath,
@@ -87,16 +83,10 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
const onChange = useCallback( const onChange = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
if (abortControllerRef.current) { abortAndIgnore(onChangeAbortControllerRef.current)
try {
abortControllerRef.current.abort()
} catch (_err) {
// swallow error
}
}
const abortController = new AbortController() const controller = new AbortController()
abortControllerRef.current = abortController onChangeAbortControllerRef.current = controller
const { state } = await getFormState({ const { state } = await getFormState({
id, id,
@@ -107,7 +97,7 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
globalSlug, globalSlug,
operation: 'update', operation: 'update',
schemaPath: schemaFieldsPath, schemaPath: schemaFieldsPath,
signal: abortController.signal, signal: controller.signal,
}) })
if (!state) { if (!state) {
@@ -129,14 +119,8 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
// cleanup effect // cleanup effect
useEffect(() => { useEffect(() => {
const abortController = abortControllerRef.current
return () => { return () => {
try { abortAndIgnore(onChangeAbortControllerRef.current)
abortController.abort()
} catch (_err) {
// swallow error
}
} }
}, []) }, [])

View File

@@ -100,7 +100,6 @@ export const LinkButton: React.FC<{
data, data,
docPermissions, docPermissions,
docPreferences: await getDocPreferences(), docPreferences: await getDocPreferences(),
doNotAbort: true,
globalSlug, globalSlug,
operation: 'update', operation: 'update',
renderAllFields: true, renderAllFields: true,

View File

@@ -103,7 +103,6 @@ export const LinkElement = () => {
data, data,
docPermissions, docPermissions,
docPreferences: await getDocPreferences(), docPreferences: await getDocPreferences(),
doNotAbort: true,
globalSlug, globalSlug,
operation: 'update', operation: 'update',
renderAllFields: true, renderAllFields: true,

View File

@@ -77,7 +77,6 @@ export const UploadDrawer: React.FC<{
data, data,
docPermissions, docPermissions,
docPreferences: await getDocPreferences(), docPreferences: await getDocPreferences(),
doNotAbort: true,
globalSlug, globalSlug,
operation: 'update', operation: 'update',
renderAllFields: true, renderAllFields: true,

View File

@@ -3,7 +3,7 @@
import type { ClientCollectionConfig } from 'payload' import type { ClientCollectionConfig } from 'payload'
import { useRouter, useSearchParams } from 'next/navigation.js' import { useRouter, useSearchParams } from 'next/navigation.js'
import React, { useCallback } from 'react' import React, { useCallback, useEffect } from 'react'
import type { EditFormProps } from './types.js' import type { EditFormProps } from './types.js'
@@ -17,6 +17,7 @@ import { useEditDepth } from '../../../providers/EditDepth/index.js'
import { OperationProvider } from '../../../providers/Operation/index.js' import { OperationProvider } from '../../../providers/Operation/index.js'
import { useServerFunctions } from '../../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../../providers/ServerFunctions/index.js'
import { useUploadEdits } from '../../../providers/UploadEdits/index.js' import { useUploadEdits } from '../../../providers/UploadEdits/index.js'
import { abortAndIgnore } from '../../../utilities/abortAndIgnore.js'
import { formatAdminURL } from '../../../utilities/formatAdminURL.js' import { formatAdminURL } from '../../../utilities/formatAdminURL.js'
import { useDocumentDrawerContext } from '../../DocumentDrawer/Provider.js' import { useDocumentDrawerContext } from '../../DocumentDrawer/Provider.js'
import { DocumentFields } from '../../DocumentFields/index.js' import { DocumentFields } from '../../DocumentFields/index.js'
@@ -55,8 +56,9 @@ export function EditForm({ submitted }: EditFormProps) {
getEntityConfig, getEntityConfig,
} = useConfig() } = useConfig()
const collectionConfig = getEntityConfig({ collectionSlug: docSlug }) as ClientCollectionConfig const formStateAbortControllerRef = React.useRef<AbortController>(null)
const collectionConfig = getEntityConfig({ collectionSlug: docSlug }) as ClientCollectionConfig
const router = useRouter() const router = useRouter()
const depth = useEditDepth() const depth = useEditDepth()
const params = useSearchParams() const params = useSearchParams()
@@ -109,6 +111,11 @@ export function EditForm({ submitted }: EditFormProps) {
const onChange: NonNullable<FormProps['onChange']>[0] = useCallback( const onChange: NonNullable<FormProps['onChange']>[0] = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
abortAndIgnore(formStateAbortControllerRef.current)
const controller = new AbortController()
formStateAbortControllerRef.current = controller
const docPreferences = await getDocPreferences() const docPreferences = await getDocPreferences()
const { state: newFormState } = await getFormState({ const { state: newFormState } = await getFormState({
collectionSlug, collectionSlug,
@@ -117,6 +124,7 @@ export function EditForm({ submitted }: EditFormProps) {
formState: prevFormState, formState: prevFormState,
operation: 'create', operation: 'create',
schemaPath, schemaPath,
signal: controller.signal,
}) })
return newFormState return newFormState
@@ -124,6 +132,12 @@ export function EditForm({ submitted }: EditFormProps) {
[collectionSlug, schemaPath, getDocPreferences, getFormState, docPermissions], [collectionSlug, schemaPath, getDocPreferences, getFormState, docPermissions],
) )
useEffect(() => {
return () => {
abortAndIgnore(formStateAbortControllerRef.current)
}
}, [])
return ( return (
<OperationProvider operation="create"> <OperationProvider operation="create">
<BulkUploadProvider> <BulkUploadProvider>

View File

@@ -10,6 +10,7 @@ import { LoadingOverlay } from '../../elements/Loading/index.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
import { DocumentDrawerContextProvider } from './Provider.js' import { DocumentDrawerContextProvider } from './Provider.js'
export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
@@ -35,6 +36,8 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
collections.find((collection) => collection.slug === collectionSlug), collections.find((collection) => collection.slug === collectionSlug),
) )
const documentViewAbortControllerRef = React.useRef<AbortController>(null)
const { closeModal } = useModal() const { closeModal } = useModal()
const { t } = useTranslation() const { t } = useTranslation()
@@ -44,31 +47,40 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const getDocumentView = useCallback( const getDocumentView = useCallback(
async (docID?: number | string, doNotAbort?: boolean) => { (docID?: number | string) => {
setIsLoading(true) abortAndIgnore(documentViewAbortControllerRef.current)
try { const controller = new AbortController()
const result = await renderDocument({ documentViewAbortControllerRef.current = controller
collectionSlug,
disableActions,
docID,
doNotAbort,
drawerSlug,
initialData,
redirectAfterDelete: redirectAfterDelete !== undefined ? redirectAfterDelete : false,
redirectAfterDuplicate:
redirectAfterDuplicate !== undefined ? redirectAfterDuplicate : false,
})
if (result?.Document) { const fetchDocumentView = async () => {
setDocumentView(result.Document) setIsLoading(true)
setIsLoading(false)
try {
const result = await renderDocument({
collectionSlug,
disableActions,
docID,
drawerSlug,
initialData,
redirectAfterDelete: redirectAfterDelete !== undefined ? redirectAfterDelete : false,
redirectAfterDuplicate:
redirectAfterDuplicate !== undefined ? redirectAfterDuplicate : false,
signal: controller.signal,
})
if (result?.Document) {
setDocumentView(result.Document)
setIsLoading(false)
}
} catch (error) {
toast.error(error?.message || t('error:unspecific'))
closeModal(drawerSlug)
// toast.error(data?.errors?.[0].message || t('error:unspecific'))
} }
} catch (error) {
toast.error(error?.message || t('error:unspecific'))
closeModal(drawerSlug)
// toast.error(data?.errors?.[0].message || t('error:unspecific'))
} }
void fetchDocumentView()
}, },
[ [
collectionSlug, collectionSlug,
@@ -79,20 +91,13 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
redirectAfterDuplicate, redirectAfterDuplicate,
renderDocument, renderDocument,
closeModal, closeModal,
initialState,
t, t,
], ],
) )
useEffect(() => {
if (!DocumentView) {
void getDocumentView(existingDocID, true)
}
}, [DocumentView, getDocumentView, existingDocID])
const onSave = useCallback<DocumentDrawerProps['onSave']>( const onSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => { (args) => {
void getDocumentView(args.doc.id) getDocumentView(args.doc.id)
if (typeof onSaveFromProps === 'function') { if (typeof onSaveFromProps === 'function') {
void onSaveFromProps({ void onSaveFromProps({
@@ -104,9 +109,9 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
[onSaveFromProps, collectionConfig, getDocumentView], [onSaveFromProps, collectionConfig, getDocumentView],
) )
const onDuplicate = useCallback<DocumentDrawerProps['onSave']>( const onDuplicate = useCallback<DocumentDrawerProps['onDuplicate']>(
(args) => { (args) => {
void getDocumentView(args.doc.id) getDocumentView(args.doc.id)
if (typeof onDuplicateFromProps === 'function') { if (typeof onDuplicateFromProps === 'function') {
void onDuplicateFromProps({ void onDuplicateFromProps({
@@ -133,9 +138,22 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
) )
const clearDoc = useCallback(() => { const clearDoc = useCallback(() => {
void getDocumentView() getDocumentView()
}, [getDocumentView]) }, [getDocumentView])
useEffect(() => {
if (!DocumentView) {
getDocumentView(existingDocID)
}
}, [DocumentView, getDocumentView, existingDocID])
// Cleanup any pending requests when the component unmounts
useEffect(() => {
return () => {
abortAndIgnore(documentViewAbortControllerRef.current)
}
}, [])
if (isLoading) { if (isLoading) {
return <LoadingOverlay /> return <LoadingOverlay />
} }

View File

@@ -4,7 +4,7 @@ import type { ClientCollectionConfig, FormState } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js' import { useRouter } from 'next/navigation.js'
import React, { useCallback, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import type { FormProps } from '../../forms/Form/index.js' import type { FormProps } from '../../forms/Form/index.js'
@@ -23,6 +23,7 @@ import { useSearchParams } from '../../providers/SearchParams/index.js'
import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js' import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
import { Drawer, DrawerToggler } from '../Drawer/index.js' import { Drawer, DrawerToggler } from '../Drawer/index.js'
import { FieldSelect } from '../FieldSelect/index.js' import { FieldSelect } from '../FieldSelect/index.js'
import './index.scss' import './index.scss'
@@ -127,6 +128,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
const router = useRouter() const router = useRouter()
const [initialState, setInitialState] = useState<FormState>() const [initialState, setInitialState] = useState<FormState>()
const hasInitializedState = React.useRef(false) const hasInitializedState = React.useRef(false)
const formStateAbortControllerRef = React.useRef<AbortController>(null)
const { clearRouteCache } = useRouteCache() const { clearRouteCache } = useRouteCache()
const collectionPermissions = permissions?.collections?.[slug] const collectionPermissions = permissions?.collections?.[slug]
@@ -135,6 +137,8 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
const drawerSlug = `edit-${slug}` const drawerSlug = `edit-${slug}`
React.useEffect(() => { React.useEffect(() => {
const controller = new AbortController()
if (!hasInitializedState.current) { if (!hasInitializedState.current) {
const getInitialState = async () => { const getInitialState = async () => {
const { state: result } = await getFormState({ const { state: result } = await getFormState({
@@ -144,6 +148,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
docPreferences: null, docPreferences: null,
operation: 'update', operation: 'update',
schemaPath: slug, schemaPath: slug,
signal: controller.signal,
}) })
setInitialState(result) setInitialState(result)
@@ -151,11 +156,20 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
} }
void getInitialState() void getInitialState()
return () => {
abortAndIgnore(controller)
}
} }
}, [apiRoute, hasInitializedState, serverURL, slug, getFormState, user, collectionPermissions]) }, [apiRoute, hasInitializedState, serverURL, slug, getFormState, user, collectionPermissions])
const onChange: FormProps['onChange'][0] = useCallback( const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
abortAndIgnore(formStateAbortControllerRef.current)
const controller = new AbortController()
formStateAbortControllerRef.current = controller
const { state } = await getFormState({ const { state } = await getFormState({
collectionSlug: slug, collectionSlug: slug,
docPermissions: collectionPermissions, docPermissions: collectionPermissions,
@@ -163,6 +177,7 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
formState: prevFormState, formState: prevFormState,
operation: 'update', operation: 'update',
schemaPath: slug, schemaPath: slug,
signal: controller.signal,
}) })
return state return state
@@ -170,6 +185,10 @@ export const EditMany: React.FC<EditManyProps> = (props) => {
[slug, getFormState, collectionPermissions], [slug, getFormState, collectionPermissions],
) )
useEffect(() => {
abortAndIgnore(formStateAbortControllerRef.current)
}, [])
if (selectAll === SelectAllStatus.None || !hasUpdatePermission) { if (selectAll === SelectAllStatus.None || !hasUpdatePermission) {
return null return null
} }

View File

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

View File

@@ -29,6 +29,7 @@ import { useLocale } from '../../providers/Locale/index.js'
import { useOperation } from '../../providers/Operation/index.js' import { useOperation } from '../../providers/Operation/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
import { requests } from '../../utilities/api.js' import { requests } from '../../utilities/api.js'
import { import {
FormContext, FormContext,
@@ -91,8 +92,7 @@ export const Form: React.FC<FormProps> = (props) => {
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const contextRef = useRef({} as FormContextType) const contextRef = useRef({} as FormContextType)
const abortControllerRef = useRef(new AbortController()) const resetFormStateAbortControllerRef = useRef<AbortController>(null)
const abortControllerRef2 = useRef(new AbortController())
const fieldsReducer = useReducer(fieldReducer, {}, () => initialState) const fieldsReducer = useReducer(fieldReducer, {}, () => initialState)
@@ -456,16 +456,10 @@ export const Form: React.FC<FormProps> = (props) => {
const reset = useCallback( const reset = useCallback(
async (data: unknown) => { async (data: unknown) => {
if (abortControllerRef.current) { abortAndIgnore(resetFormStateAbortControllerRef.current)
try {
abortControllerRef.current.abort()
} catch (error) {
// swallow error
}
}
const abortController = new AbortController() const controller = new AbortController()
abortControllerRef.current = abortController resetFormStateAbortControllerRef.current = controller
const docPreferences = await getDocPreferences() const docPreferences = await getDocPreferences()
@@ -479,7 +473,7 @@ export const Form: React.FC<FormProps> = (props) => {
operation, operation,
renderAllFields: true, renderAllFields: true,
schemaPath: collectionSlug ? collectionSlug : globalSlug, schemaPath: collectionSlug ? collectionSlug : globalSlug,
signal: abortController.signal, signal: controller.signal,
}) })
contextRef.current = { ...initContextState } as FormContextType contextRef.current = { ...initContextState } as FormContextType
@@ -548,27 +542,9 @@ export const Form: React.FC<FormProps> = (props) => {
[dispatchFields, getDataByPath], [dispatchFields, getDataByPath],
) )
// clean on unmount
useEffect(() => { useEffect(() => {
const re1 = abortControllerRef.current
const re2 = abortControllerRef2.current
return () => { return () => {
if (re1) { abortAndIgnore(resetFormStateAbortControllerRef.current)
try {
re1.abort()
} catch (_err) {
// swallow error
}
}
if (re2) {
try {
re2.abort()
} catch (_err) {
// swallow error
}
}
} }
}, []) }, [])

View File

@@ -6,14 +6,13 @@ import type {
ServerFunctionClient, ServerFunctionClient,
} from 'payload' } from 'payload'
import React, { createContext, useCallback, useEffect, useRef } from 'react' import React, { createContext, useCallback } from 'react'
import type { buildFormStateHandler } from '../../utilities/buildFormState.js' import type { buildFormStateHandler } from '../../utilities/buildFormState.js'
import type { buildTableStateHandler } from '../../utilities/buildTableState.js' import type { buildTableStateHandler } from '../../utilities/buildTableState.js'
type GetFormStateClient = ( type GetFormStateClient = (
args: { args: {
doNotAbort?: boolean
signal?: AbortSignal signal?: AbortSignal
} & Omit<BuildFormStateArgs, 'clientConfig' | 'req'>, } & Omit<BuildFormStateArgs, 'clientConfig' | 'req'>,
) => ReturnType<typeof buildFormStateHandler> ) => ReturnType<typeof buildFormStateHandler>
@@ -28,7 +27,6 @@ type RenderDocument = (args: {
collectionSlug: string collectionSlug: string
disableActions?: boolean disableActions?: boolean
docID?: number | string docID?: number | string
doNotAbort?: boolean
drawerSlug?: string drawerSlug?: string
initialData?: Data initialData?: Data
redirectAfterDelete?: boolean redirectAfterDelete?: boolean
@@ -69,10 +67,6 @@ export const ServerFunctionsProvider: React.FC<{
throw new Error('ServerFunctionsProvider requires a serverFunction prop') throw new Error('ServerFunctionsProvider requires a serverFunction prop')
} }
// This is the local abort controller, to abort requests when the _provider_ itself unmounts, etc.
// Each callback also accept a remote signal, to abort requests when each _component_ unmounts, etc.
const abortControllerRef = useRef(new AbortController())
const getDocumentSlots = useCallback<GetDocumentSlots>( const getDocumentSlots = useCallback<GetDocumentSlots>(
async (args) => async (args) =>
await serverFunction({ await serverFunction({
@@ -84,39 +78,16 @@ export const ServerFunctionsProvider: React.FC<{
const getFormState = useCallback<GetFormStateClient>( const getFormState = useCallback<GetFormStateClient>(
async (args) => { async (args) => {
if (args?.doNotAbort) {
try {
const result = (await serverFunction({
name: 'form-state',
args,
})) as ReturnType<typeof buildFormStateHandler> // TODO: infer this type when `strictNullChecks` is enabled
return result
} catch (_err) {
console.error(_err) // eslint-disable-line no-console
}
return { state: null }
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const abortController = new AbortController()
abortControllerRef.current = abortController
const localSignal = abortController.signal
const { signal: remoteSignal, ...rest } = args || {} const { signal: remoteSignal, ...rest } = args || {}
try { try {
if (!remoteSignal?.aborted && !localSignal?.aborted) { if (!remoteSignal?.aborted) {
const result = (await serverFunction({ const result = (await serverFunction({
name: 'form-state', name: 'form-state',
args: rest, args: rest,
})) as ReturnType<typeof buildFormStateHandler> // TODO: infer this type when `strictNullChecks` is enabled })) as ReturnType<typeof buildFormStateHandler> // TODO: infer this type when `strictNullChecks` is enabled
if (!remoteSignal?.aborted && !localSignal?.aborted) { if (!remoteSignal?.aborted) {
return result return result
} }
} }
@@ -131,15 +102,19 @@ export const ServerFunctionsProvider: React.FC<{
const getTableState = useCallback<GetTableStateClient>( const getTableState = useCallback<GetTableStateClient>(
async (args) => { async (args) => {
const { ...rest } = args || {} const { signal: remoteSignal, ...rest } = args || {}
try { try {
const result = (await serverFunction({ if (!remoteSignal?.aborted) {
name: 'table-state', const result = (await serverFunction({
args: rest, name: 'table-state',
})) as ReturnType<typeof buildTableStateHandler> // TODO: infer this type when `strictNullChecks` is enabled args: rest,
})) as ReturnType<typeof buildTableStateHandler> // TODO: infer this type when `strictNullChecks` is enabled
return result if (!remoteSignal?.aborted) {
return result
}
}
} catch (_err) { } catch (_err) {
console.error(_err) // eslint-disable-line no-console console.error(_err) // eslint-disable-line no-console
} }
@@ -151,37 +126,16 @@ export const ServerFunctionsProvider: React.FC<{
const renderDocument = useCallback<RenderDocument>( const renderDocument = useCallback<RenderDocument>(
async (args) => { async (args) => {
if (args?.doNotAbort) {
try {
const result = (await serverFunction({
name: 'render-document',
args,
})) as { docID: string; Document: React.ReactNode }
return result
} catch (_err) {
console.error(_err) // eslint-disable-line no-console
return
}
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const abortController = new AbortController()
abortControllerRef.current = abortController
const localSignal = abortController.signal
const { signal: remoteSignal, ...rest } = args || {} const { signal: remoteSignal, ...rest } = args || {}
try { try {
if (!remoteSignal?.aborted && !localSignal?.aborted) { if (!remoteSignal?.aborted) {
const result = (await serverFunction({ const result = (await serverFunction({
name: 'render-document', name: 'render-document',
args: rest, args: rest,
})) as { docID: string; Document: React.ReactNode } })) as { docID: string; Document: React.ReactNode }
if (!remoteSignal?.aborted && !localSignal?.aborted) { if (!remoteSignal?.aborted) {
return result return result
} }
} }
@@ -192,20 +146,6 @@ export const ServerFunctionsProvider: React.FC<{
[serverFunction], [serverFunction],
) )
useEffect(() => {
const controller = abortControllerRef.current
return () => {
if (controller) {
try {
// controller.abort()
} catch (_err) {
// swallow error
}
}
}
}, [])
return ( return (
<ServerFunctionsContext.Provider <ServerFunctionsContext.Provider
value={{ value={{

View File

@@ -0,0 +1,9 @@
export function abortAndIgnore(controller: AbortController) {
if (controller) {
try {
controller.abort()
} catch (_err) {
// swallow error
}
}
}

View File

@@ -28,6 +28,7 @@ import { useEditDepth } from '../../providers/EditDepth/index.js'
import { OperationProvider } from '../../providers/Operation/index.js' import { OperationProvider } from '../../providers/Operation/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useUploadEdits } from '../../providers/UploadEdits/index.js' import { useUploadEdits } from '../../providers/UploadEdits/index.js'
import { abortAndIgnore } from '../../utilities/abortAndIgnore.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js' import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { handleBackToDashboard } from '../../utilities/handleBackToDashboard.js' import { handleBackToDashboard } from '../../utilities/handleBackToDashboard.js'
import { handleGoBack } from '../../utilities/handleGoBack.js' import { handleGoBack } from '../../utilities/handleGoBack.js'
@@ -115,7 +116,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
const { resetUploadEdits } = useUploadEdits() const { resetUploadEdits } = useUploadEdits()
const { getFormState } = useServerFunctions() const { getFormState } = useServerFunctions()
const abortControllerRef = useRef(new AbortController()) const onChangeAbortControllerRef = useRef<AbortController>(null)
const locale = params.get('locale') const locale = params.get('locale')
@@ -261,16 +262,10 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
const onChange: FormProps['onChange'][0] = useCallback( const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
if (abortControllerRef.current) { abortAndIgnore(onChangeAbortControllerRef.current)
try {
abortControllerRef.current.abort()
} catch (e) {
// swallow error
}
}
const abortController = new AbortController() const controller = new AbortController()
abortControllerRef.current = abortController onChangeAbortControllerRef.current = controller
const currentTime = Date.now() const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - editSessionStartTime const timeSinceLastUpdate = currentTime - editSessionStartTime
@@ -296,7 +291,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
renderAllFields: false, renderAllFields: false,
returnLockStatus: isLockingEnabled ? true : false, returnLockStatus: isLockingEnabled ? true : false,
schemaPath: schemaPathSegments.join('.'), schemaPath: schemaPathSegments.join('.'),
// signal: abortController.signal, signal: controller.signal,
updateLastEdited, updateLastEdited,
}) })
@@ -352,14 +347,6 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
// Clean up when the component unmounts or when the document is unlocked // Clean up when the component unmounts or when the document is unlocked
useEffect(() => { useEffect(() => {
return () => { return () => {
if (abortControllerRef.current) {
try {
abortControllerRef.current.abort()
} catch (e) {
// swallow error
}
}
if (!isLockingEnabled) { if (!isLockingEnabled) {
return return
} }
@@ -403,6 +390,12 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
setDocumentIsLocked, setDocumentIsLocked,
]) ])
useEffect(() => {
return () => {
abortAndIgnore(onChangeAbortControllerRef.current)
}
}, [])
const shouldShowDocumentLockedModal = const shouldShowDocumentLockedModal =
documentIsLocked && documentIsLocked &&
currentEditor && currentEditor &&

View File

@@ -481,10 +481,8 @@ describe('access control', () => {
await expect(page.locator('.unauthorized')).toBeVisible() await expect(page.locator('.unauthorized')).toBeVisible()
await page.goto(logoutURL)
await page.waitForURL(logoutURL)
// Log back in for the next test // Log back in for the next test
await page.goto(logoutURL)
await login({ await login({
data: { data: {
email: devUser.email, email: devUser.email,
@@ -528,6 +526,19 @@ describe('access control', () => {
await page.waitForURL(unauthorizedURL) await page.waitForURL(unauthorizedURL)
await expect(page.locator('.unauthorized')).toBeVisible() await expect(page.locator('.unauthorized')).toBeVisible()
// Log back in for the next test
await context.clearCookies()
await page.goto(logoutURL)
await page.waitForURL(logoutURL)
await login({
data: {
email: devUser.email,
password: devUser.password,
},
page,
serverURL,
})
}) })
}) })