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