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,
useForm,
useFormModified,
useFormProcessing,
useFormSubmitted,
} from '../../forms/Form/context.js'
import { useDebounce } from '../../hooks/useDebounce.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useQueues } from '../../hooks/useQueues.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentEvents } from '../../providers/DocumentEvents/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import './index.scss'
import { useTranslation } from '../../providers/Translation/index.js'
import { formatTimeToNow } from '../../utilities/formatDocTitle/formatDateTitle.js'
import { reduceFieldsToValuesWithValidation } from '../../utilities/reduceFieldsToValuesWithValidation.js'
import { LeaveWithoutSaving } from '../LeaveWithoutSaving/index.js'
import './index.scss'
const baseClass = 'autosave'
// The minimum time the saving state should be shown
@@ -54,12 +54,9 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
setUnpublishedVersionCount,
updateSavedDocumentData,
} = useDocumentInfo()
const queueRef = useRef([])
const isProcessingRef = useRef(false)
const { reportUpdate } = useDocumentEvents()
const { dispatchFields, isValid, setBackgroundProcessing, setIsValid, setSubmitted } = useForm()
const isFormProcessing = useFormProcessing()
const [fields] = useAllFormFields()
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
localeRef.current = locale
const processQueue = React.useCallback(async () => {
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 { queueTask } = useQueues()
const autosaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -165,7 +126,8 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
}
}
const autosave = async () => {
queueTask(
async () => {
if (modified) {
startTimestamp = new Date().getTime()
@@ -198,6 +160,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
if (!skipSubmission && isValidRef.current) {
let res
try {
res = await fetch(url, {
body: JSON.stringify(data),
@@ -208,7 +171,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
},
method,
})
} catch (error) {
} catch (_err) {
// Swallow Error
}
@@ -297,10 +260,21 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
}
}
}
},
{
afterProcess: () => {
setBackgroundProcessing(false)
},
beforeProcess: () => {
if (!isValidRef.current) {
isValidRef.current = true
return false
}
queueRef.current.push(autosave)
void processQueue()
setBackgroundProcessing(true)
},
},
)
})
const didMount = useRef(false)

View File

@@ -1,47 +1,85 @@
import { useCallback, useRef } from 'react'
export function useQueues(): {
queueTask: (fn: (signal: AbortSignal) => Promise<void>) => void
} {
const runningTaskRef = useRef<null | Promise<void>>(null)
const queuedTask = useRef<((signal: AbortSignal) => Promise<void>) | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
type QueuedFunction = () => Promise<void>
const queueTask = useCallback((fn: (signal: AbortSignal) => Promise<void>) => {
// Overwrite the queued task every time a new one arrives
queuedTask.current = fn
// If a task is already running, abort it and return
if (runningTaskRef.current !== null) {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
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(): {
queueTask: QueueTask
} {
const queue = useRef<QueuedFunction[]>([])
const isProcessing = useRef(false)
const queueTask = useCallback<QueueTask>((fn, options) => {
queue.current.push(fn)
async function processQueue() {
if (isProcessing.current) {
return
}
const executeTask = async () => {
while (queuedTask.current) {
const taskToRun = queuedTask.current
queuedTask.current = null // Reset latest task before running
// Allow the consumer to prevent the queue from processing under certain conditions
if (typeof options?.beforeProcess === 'function') {
const shouldContinue = options.beforeProcess()
const controller = new AbortController()
abortControllerRef.current = controller
if (shouldContinue === false) {
return
}
}
while (queue.current.length > 0) {
const latestTask = queue.current.pop() // Only process the last task in the queue
queue.current = [] // Discard all other tasks
isProcessing.current = true
try {
runningTaskRef.current = taskToRun(controller.signal)
await runningTaskRef.current // Wait for the task to complete
await latestTask()
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error in queued function:', err) // eslint-disable-line no-console
}
} finally {
runningTaskRef.current = null
isProcessing.current = false
if (typeof options?.afterProcess === 'function') {
options.afterProcess()
}
}
}
}
void executeTask()
void processQueue()
}, [])
return { queueTask }