refactor(ui): replace autosave queue pattern with useQueues hook (#11884)

Replaces the queue pattern used within autosave with the `useQueues`
hook introduced in #11579. To do this, queued tasks now accept an
options object with callbacks which can be used to tie into events of
the process, such as before it begins to prevent it from running, and
after it has finished to perform side effects.

The `useQueues` hook now also maintains an array of queued tasks as
opposed to individual refs.
This commit is contained in:
Jacob Fletcher
2025-03-28 13:54:15 -04:00
committed by GitHub
parent 2b6313ed48
commit 62c4e81a1f
2 changed files with 194 additions and 182 deletions

View File

@@ -11,20 +11,20 @@ import {
useAllFormFields, useAllFormFields,
useForm, useForm,
useFormModified, useFormModified,
useFormProcessing,
useFormSubmitted, useFormSubmitted,
} from '../../forms/Form/context.js' } from '../../forms/Form/context.js'
import { useDebounce } from '../../hooks/useDebounce.js' import { useDebounce } from '../../hooks/useDebounce.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js' import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useQueues } from '../../hooks/useQueues.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { useDocumentEvents } from '../../providers/DocumentEvents/index.js' import { useDocumentEvents } from '../../providers/DocumentEvents/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js' import { useLocale } from '../../providers/Locale/index.js'
import './index.scss'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { formatTimeToNow } from '../../utilities/formatDocTitle/formatDateTitle.js' import { formatTimeToNow } from '../../utilities/formatDocTitle/formatDateTitle.js'
import { reduceFieldsToValuesWithValidation } from '../../utilities/reduceFieldsToValuesWithValidation.js' import { reduceFieldsToValuesWithValidation } from '../../utilities/reduceFieldsToValuesWithValidation.js'
import { LeaveWithoutSaving } from '../LeaveWithoutSaving/index.js' import { LeaveWithoutSaving } from '../LeaveWithoutSaving/index.js'
import './index.scss'
const baseClass = 'autosave' const baseClass = 'autosave'
// The minimum time the saving state should be shown // The minimum time the saving state should be shown
@@ -54,12 +54,9 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
setUnpublishedVersionCount, setUnpublishedVersionCount,
updateSavedDocumentData, updateSavedDocumentData,
} = useDocumentInfo() } = useDocumentInfo()
const queueRef = useRef([])
const isProcessingRef = useRef(false)
const { reportUpdate } = useDocumentEvents() const { reportUpdate } = useDocumentEvents()
const { dispatchFields, isValid, setBackgroundProcessing, setIsValid, setSubmitted } = useForm() const { dispatchFields, isValid, setBackgroundProcessing, setIsValid, setSubmitted } = useForm()
const isFormProcessing = useFormProcessing()
const [fields] = useAllFormFields() const [fields] = useAllFormFields()
const modified = useFormModified() const modified = useFormModified()
@@ -105,43 +102,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
// can always retrieve the most to date locale // can always retrieve the most to date locale
localeRef.current = locale localeRef.current = locale
const processQueue = React.useCallback(async () => { const { queueTask } = useQueues()
if (isProcessingRef.current || queueRef.current.length === 0) {
return
}
// Do not autosave if the form is already processing (e.g. if the user clicked the publish button
// right before this autosave runs), as parallel updates could cause conflicts
if (isFormProcessing) {
queueRef.current = []
return
}
if (!isValidRef.current) {
// Clear queue so we don't end up in an infinite loop
queueRef.current = []
// Reset internal validation state so queue processing can run again
isValidRef.current = true
return
}
isProcessingRef.current = true
const latestAction = queueRef.current[queueRef.current.length - 1]
queueRef.current = []
setBackgroundProcessing(true)
try {
await latestAction()
} finally {
isProcessingRef.current = false
setBackgroundProcessing(false)
if (queueRef.current.length > 0) {
await processQueue()
}
}
setBackgroundProcessing(false)
}, [isFormProcessing, setBackgroundProcessing])
const autosaveTimeoutRef = useRef<NodeJS.Timeout | null>(null) const autosaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -165,142 +126,155 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
} }
} }
const autosave = async () => { queueTask(
if (modified) { async () => {
startTimestamp = new Date().getTime() if (modified) {
startTimestamp = new Date().getTime()
setSaving(true) setSaving(true)
let url: string let url: string
let method: string let method: string
let entitySlug: string let entitySlug: string
if (collection && id) { if (collection && id) {
entitySlug = collection.slug entitySlug = collection.slug
url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}` url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'PATCH' method = 'PATCH'
} }
if (globalDoc) { if (globalDoc) {
entitySlug = globalDoc.slug entitySlug = globalDoc.slug
url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}` url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'POST' method = 'POST'
} }
if (url) { if (url) {
if (modifiedRef.current) { if (modifiedRef.current) {
const { data, valid } = reduceFieldsToValuesWithValidation(fieldRef.current, true) const { data, valid } = reduceFieldsToValuesWithValidation(fieldRef.current, true)
data._status = 'draft' data._status = 'draft'
const skipSubmission = const skipSubmission =
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
if (!skipSubmission && isValidRef.current) { if (!skipSubmission && isValidRef.current) {
let res let res
try {
res = await fetch(url, {
body: JSON.stringify(data),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
method,
})
} catch (error) {
// Swallow Error
}
const newDate = new Date() try {
// We need to log the time in order to figure out if we need to trigger the state off later res = await fetch(url, {
endTimestamp = newDate.getTime() body: JSON.stringify(data),
credentials: 'include',
if (res.status === 200) { headers: {
setLastUpdateTime(newDate.getTime()) 'Accept-Language': i18n.language,
'Content-Type': 'application/json',
reportUpdate({
id,
entitySlug,
updatedAt: newDate.toISOString(),
})
if (!mostRecentVersionIsAutosaved) {
incrementVersionCount()
setMostRecentVersionIsAutosaved(true)
setUnpublishedVersionCount((prev) => prev + 1)
}
}
const json = await res.json()
if (versionsConfig?.drafts && versionsConfig?.drafts?.validate && json?.errors) {
if (Array.isArray(json.errors)) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(
([fieldErrs, nonFieldErrs], err) => {
const newFieldErrs = []
const newNonFieldErrs = []
if (err?.message) {
newNonFieldErrs.push(err)
}
if (Array.isArray(err?.data)) {
err.data.forEach((dataError) => {
if (dataError?.field) {
newFieldErrs.push(dataError)
} else {
newNonFieldErrs.push(dataError)
}
})
}
return [
[...fieldErrs, ...newFieldErrs],
[...nonFieldErrs, ...newNonFieldErrs],
]
}, },
[[], []], method,
) })
} catch (_err) {
// Swallow Error
}
dispatchFields({ const newDate = new Date()
type: 'ADD_SERVER_ERRORS', // We need to log the time in order to figure out if we need to trigger the state off later
errors: fieldErrors, endTimestamp = newDate.getTime()
if (res.status === 200) {
setLastUpdateTime(newDate.getTime())
reportUpdate({
id,
entitySlug,
updatedAt: newDate.toISOString(),
}) })
nonFieldErrors.forEach((err) => { if (!mostRecentVersionIsAutosaved) {
toast.error(err.message || i18n.t('error:unknown')) incrementVersionCount()
}) setMostRecentVersionIsAutosaved(true)
setUnpublishedVersionCount((prev) => prev + 1)
// Set valid to false internally so the queue doesn't process }
isValidRef.current = false
setIsValid(false)
setSubmitted(true)
hideIndicator()
return
} }
} else { const json = await res.json()
// If it's not an error then we can update the document data inside the context
const document = json?.doc || json?.result
// Manually update the data since this function doesn't fire the `submit` function from useForm if (versionsConfig?.drafts && versionsConfig?.drafts?.validate && json?.errors) {
if (document) { if (Array.isArray(json.errors)) {
setIsValid(true) const [fieldErrors, nonFieldErrors] = json.errors.reduce(
([fieldErrs, nonFieldErrs], err) => {
const newFieldErrs = []
const newNonFieldErrs = []
// Reset internal state allowing the queue to process if (err?.message) {
isValidRef.current = true newNonFieldErrs.push(err)
updateSavedDocumentData(document) }
if (Array.isArray(err?.data)) {
err.data.forEach((dataError) => {
if (dataError?.field) {
newFieldErrs.push(dataError)
} else {
newNonFieldErrs.push(dataError)
}
})
}
return [
[...fieldErrs, ...newFieldErrs],
[...nonFieldErrs, ...newNonFieldErrs],
]
},
[[], []],
)
dispatchFields({
type: 'ADD_SERVER_ERRORS',
errors: fieldErrors,
})
nonFieldErrors.forEach((err) => {
toast.error(err.message || i18n.t('error:unknown'))
})
// Set valid to false internally so the queue doesn't process
isValidRef.current = false
setIsValid(false)
setSubmitted(true)
hideIndicator()
return
}
} else {
// If it's not an error then we can update the document data inside the context
const document = json?.doc || json?.result
// Manually update the data since this function doesn't fire the `submit` function from useForm
if (document) {
setIsValid(true)
// Reset internal state allowing the queue to process
isValidRef.current = true
updateSavedDocumentData(document)
}
} }
hideIndicator()
} }
hideIndicator()
} }
} }
} }
} },
} {
afterProcess: () => {
setBackgroundProcessing(false)
},
beforeProcess: () => {
if (!isValidRef.current) {
isValidRef.current = true
return false
}
queueRef.current.push(autosave) setBackgroundProcessing(true)
void processQueue() },
},
)
}) })
const didMount = useRef(false) const didMount = useRef(false)

View File

@@ -1,47 +1,85 @@
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
type QueuedFunction = () => Promise<void>
type QueuedTaskOptions = {
/**
* A function that is called after the queue has processed a function
* Used to perform side effects after processing the queue
* @returns {void}
*/
afterProcess?: () => void
/**
* A function that can be used to prevent the queue from processing under certain conditions
* Can also be used to perform side effects before processing the queue
* @returns {boolean} If `false`, the queue will not process
*/
beforeProcess?: () => boolean
}
type QueueTask = (fn: QueuedFunction, options?: QueuedTaskOptions) => void
/**
* A React hook that allows you to queue up functions to be executed in order.
* This is useful when you need to ensure long running networks requests are processed sequentially.
* Builds up a "queue" of functions to be executed in order, only ever processing the last function in the queue.
* This ensures that a long queue of tasks doesn't cause a backlog of tasks to be processed.
* E.g. if you queue a task and it begins running, then you queue 9 more tasks:
* 1. The currently task will finish
* 2. The next task in the queue will run
* 3. All remaining tasks will be discarded
* @returns {queueTask} A function used to queue a function.
* @example
* const { queueTask } = useQueues()
* queueTask(async () => {
* await fetch('https://api.example.com')
* })
*/
export function useQueues(): { export function useQueues(): {
queueTask: (fn: (signal: AbortSignal) => Promise<void>) => void queueTask: QueueTask
} { } {
const runningTaskRef = useRef<null | Promise<void>>(null) const queue = useRef<QueuedFunction[]>([])
const queuedTask = useRef<((signal: AbortSignal) => Promise<void>) | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const queueTask = useCallback((fn: (signal: AbortSignal) => Promise<void>) => { const isProcessing = useRef(false)
// Overwrite the queued task every time a new one arrives
queuedTask.current = fn
// If a task is already running, abort it and return const queueTask = useCallback<QueueTask>((fn, options) => {
if (runningTaskRef.current !== null) { queue.current.push(fn)
if (abortControllerRef.current) {
abortControllerRef.current.abort() async function processQueue() {
if (isProcessing.current) {
return
} }
return // Allow the consumer to prevent the queue from processing under certain conditions
} if (typeof options?.beforeProcess === 'function') {
const shouldContinue = options.beforeProcess()
const executeTask = async () => { if (shouldContinue === false) {
while (queuedTask.current) { return
const taskToRun = queuedTask.current }
queuedTask.current = null // Reset latest task before running }
const controller = new AbortController() while (queue.current.length > 0) {
abortControllerRef.current = controller const latestTask = queue.current.pop() // Only process the last task in the queue
queue.current = [] // Discard all other tasks
isProcessing.current = true
try { try {
runningTaskRef.current = taskToRun(controller.signal) await latestTask()
await runningTaskRef.current // Wait for the task to complete
} catch (err) { } catch (err) {
if (err.name !== 'AbortError') { console.error('Error in queued function:', err) // eslint-disable-line no-console
console.error('Error in queued function:', err) // eslint-disable-line no-console
}
} finally { } finally {
runningTaskRef.current = null isProcessing.current = false
if (typeof options?.afterProcess === 'function') {
options.afterProcess()
}
} }
} }
} }
void executeTask() void processQueue()
}, []) }, [])
return { queueTask } return { queueTask }