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:
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user