このコミットが含まれているのは:
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
|
||||
import { countsErrorMessage } from '@/lib/countsDisplay'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -26,178 +27,228 @@ const CountsExportProgress = (
|
||||
const motion = useClientMotion ()
|
||||
const [state, setState] = useState<ExportStatus> ({
|
||||
status: 'starting', processed: 0, total: null })
|
||||
const [cancelError, setCancelError] = useState (false)
|
||||
const [cancelError, setCancelError] = useState<unknown> (null)
|
||||
const [failure, setFailure] = useState<unknown> (null)
|
||||
const [downloadError, setDownloadError] = useState<string | null> (null)
|
||||
// One mounted dialogue owns one immutable job, even if its parent rerenders.
|
||||
const initial = useRef ({ payload, locale })
|
||||
const actions = useRef (controls)
|
||||
actions.current = controls
|
||||
const terminal = useRef (false)
|
||||
const finished = useRef (false)
|
||||
const cancelAction = useRef<() => Promise<boolean>> (async () => false)
|
||||
const cancelled = useRef (false)
|
||||
const generation = useRef (0)
|
||||
const job = useRef<string | null> (null)
|
||||
const creation = useRef<Promise<{ job: string }> | null> (null)
|
||||
const downloaded = useRef (false)
|
||||
const registration = useRef (false)
|
||||
const registration = useRef<Promise<ExportStatus> | null> (null)
|
||||
const attempt = useRef<string | null> (null)
|
||||
const downloadDeadline = useRef (0)
|
||||
const acknowledgementTimer = useRef<ReturnType<typeof setTimeout> | null> (null)
|
||||
const releaseDownload = useRef<(() => void) | null> (null)
|
||||
const [downloadError, setDownloadError] = useState<string | null> (null)
|
||||
const pollRequest = useRef<AbortController | null> (null)
|
||||
const cancellation = useRef<Promise<boolean> | null> (null)
|
||||
useEffect (() => { handle.cancel = () => cancelAction.current () }, [handle])
|
||||
useEffect (() => {
|
||||
handle.cancel = async () => {
|
||||
if (terminal.current) return true
|
||||
const epoch = ++generation.current
|
||||
let active = true
|
||||
let lastResponse = Date.now ()
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let acknowledgementTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let request: AbortController | null = null
|
||||
const current = () => active && generation.current === epoch
|
||||
const stop = (reason: string) => {
|
||||
if (!current () || terminal.current) return
|
||||
terminal.current = true
|
||||
if (timer != null) clearTimeout (timer)
|
||||
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
||||
request?.abort ()
|
||||
releaseDownload.current?.()
|
||||
releaseDownload.current = null
|
||||
setDownloadError (reason)
|
||||
}
|
||||
const cancel = (): Promise<boolean> => {
|
||||
if (finished.current || (terminal.current && job.current == null))
|
||||
return Promise.resolve (true)
|
||||
cancelled.current = true
|
||||
if (job.current != null)
|
||||
{
|
||||
terminal.current = true
|
||||
if (timer != null) clearTimeout (timer)
|
||||
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
||||
request?.abort ()
|
||||
releaseDownload.current?.()
|
||||
releaseDownload.current = null
|
||||
cancellation.current ??= (async () => {
|
||||
const controller = new AbortController ()
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
try
|
||||
{
|
||||
setCancelError (false)
|
||||
const created = await creation.current
|
||||
if (created == null) return false
|
||||
job.current = created.job
|
||||
const result = await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (job.current)}`, {
|
||||
locale, signal: controller.signal })
|
||||
setState (result)
|
||||
`/counts/export/jobs/${encodeURIComponent (created.job)}`, {
|
||||
locale: initial.current.locale, signal: controller.signal })
|
||||
if (current ())
|
||||
{
|
||||
setState (result)
|
||||
setCancelError (null)
|
||||
finished.current = ['cancelled', 'failed'].includes (result.status)
|
||||
terminal.current = finished.current
|
||||
if (!terminal.current)
|
||||
{
|
||||
if (timer != null) clearTimeout (timer)
|
||||
timer = setTimeout (() => poll (created.job), 800)
|
||||
}
|
||||
}
|
||||
return ['cancelled', 'failed'].includes (result.status)
|
||||
}
|
||||
catch
|
||||
catch (error)
|
||||
{
|
||||
setCancelError (true)
|
||||
if (isApiError (error) && error.response?.status === 404) return true
|
||||
if (current ()) setCancelError (error)
|
||||
return false
|
||||
}
|
||||
finally
|
||||
{
|
||||
clearTimeout (deadline)
|
||||
cancellation.current = null
|
||||
}
|
||||
}
|
||||
return false
|
||||
}) ()
|
||||
return cancellation.current
|
||||
}
|
||||
cancelAction.current = cancel
|
||||
const armDeadline = () => {
|
||||
if (downloadDeadline.current === 0) return
|
||||
acknowledgementTimer = setTimeout (() => stop ('timeout'),
|
||||
Math.max (0, downloadDeadline.current - Date.now ()))
|
||||
}
|
||||
}, [handle, locale])
|
||||
useEffect (() => {
|
||||
let active = true
|
||||
let lastResponse = Date.now ()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const poll = async (token: string) => {
|
||||
if (!current () || terminal.current) return
|
||||
const controller = new AbortController ()
|
||||
pollRequest.current = controller
|
||||
request = controller
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
try
|
||||
{
|
||||
const response = await apiGet<ExportStatus> (
|
||||
const result = await apiGet<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||
locale, signal: controller.signal })
|
||||
if (!active || terminal.current) return
|
||||
const result = (cancelled.current && response.status === 'ready')
|
||||
? await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||
locale, signal: controller.signal }) : response
|
||||
if (!active || terminal.current) return
|
||||
locale: initial.current.locale, signal: controller.signal })
|
||||
if (!current () || terminal.current) return
|
||||
lastResponse = Date.now ()
|
||||
setState (result)
|
||||
terminal.current = ['failed', 'cancelled'].includes (result.status)
|
||||
if (result.status === 'ready' && result.token != null && !downloaded.current)
|
||||
if (cancelled.current)
|
||||
{
|
||||
registration.current = true
|
||||
const receipt = await apiPost<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
|
||||
locale, signal: controller.signal })
|
||||
if (!active || cancelled.current) return
|
||||
if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt')
|
||||
downloaded.current = true
|
||||
attempt.current = receipt.downloadAttempt
|
||||
downloadDeadline.current = Date.now () + 30_000
|
||||
acknowledgementTimer.current = setTimeout (() => {
|
||||
if (!active || terminal.current) return
|
||||
terminal.current = true
|
||||
pollRequest.current?.abort ()
|
||||
releaseDownload.current?.()
|
||||
setDownloadError ('timeout')
|
||||
}, 30_000)
|
||||
releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => {
|
||||
if (active)
|
||||
if (await cancel ()) return
|
||||
}
|
||||
else if (result.status === 'ready')
|
||||
{
|
||||
if (result.token == null) { stop ('failed'); return }
|
||||
if (attempt.current == null)
|
||||
{
|
||||
registration.current ??= apiPost<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
|
||||
locale: initial.current.locale, signal: controller.signal })
|
||||
const receipt = await registration.current
|
||||
if (!current () || terminal.current || cancelled.current) return
|
||||
if (receipt.downloadAttempt == null) { stop ('failed'); return }
|
||||
attempt.current = receipt.downloadAttempt
|
||||
downloadDeadline.current = Date.now () + 30_000
|
||||
armDeadline ()
|
||||
try
|
||||
{
|
||||
releaseDownload.current = startAPIDownload ('/counts/export', result.token,
|
||||
() => stop ('failed'), new URLSearchParams ({
|
||||
job: token, attempt: receipt.downloadAttempt }))
|
||||
}
|
||||
catch
|
||||
{
|
||||
stop ('failed')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (result.downloadAttempt === attempt.current)
|
||||
{
|
||||
if (result.downloadStatus === 'started')
|
||||
{
|
||||
terminal.current = true
|
||||
setDownloadError ('failed')
|
||||
finished.current = true
|
||||
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
||||
// Rack yielded a chunk; browser receipt and saving are not confirmed.
|
||||
releaseDownload.current = null
|
||||
actions.current.close ()
|
||||
return
|
||||
}
|
||||
}, new URLSearchParams ({ job: token, attempt: receipt.downloadAttempt }))
|
||||
}
|
||||
if (attempt.current != null && result.downloadAttempt === attempt.current)
|
||||
{
|
||||
if (result.downloadStatus === 'started' && !cancelled.current)
|
||||
{
|
||||
terminal.current = true
|
||||
// Native navigation continues after the dialogue unmounts.
|
||||
releaseDownload.current = null
|
||||
controls.close ()
|
||||
return
|
||||
}
|
||||
if (['failed', 'unavailable'].includes (result.downloadStatus ?? ''))
|
||||
{
|
||||
terminal.current = true
|
||||
setDownloadError (result.downloadStatus!)
|
||||
if (['failed', 'unavailable', 'timeout'].includes (result.downloadStatus ?? ''))
|
||||
{ stop (result.downloadStatus!); return }
|
||||
}
|
||||
}
|
||||
if (attempt.current != null && Date.now () > downloadDeadline.current)
|
||||
else if (['failed', 'cancelled'].includes (result.status))
|
||||
{
|
||||
finished.current = true
|
||||
terminal.current = true
|
||||
setDownloadError ('timeout')
|
||||
releaseDownload.current?.()
|
||||
return
|
||||
}
|
||||
if (!terminal.current) timer = setTimeout (() => poll (token), 800)
|
||||
if (timer != null) clearTimeout (timer)
|
||||
timer = setTimeout (() => poll (token), 800)
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
if (active && !terminal.current)
|
||||
{
|
||||
if (downloaded.current || registration.current || (isApiError (error) && error.response?.status === 404)
|
||||
|| Date.now () - lastResponse > 30_000)
|
||||
{
|
||||
terminal.current = true
|
||||
setDownloadError ('unavailable')
|
||||
setState (previous => ({ ...previous, status: 'failed' }))
|
||||
return
|
||||
}
|
||||
setCancelError (true)
|
||||
timer = setTimeout (() => poll (token), 2000)
|
||||
}
|
||||
if (!current () || terminal.current) return
|
||||
const status = isApiError (error) ? error.response?.status : null
|
||||
if ([400, 401, 403, 422].includes (status ?? 0)) setFailure (error)
|
||||
if (registration.current != null && attempt.current == null)
|
||||
{ stop ('failed'); return }
|
||||
if (status != null && status >= 400 && status < 500)
|
||||
{ stop ('unavailable'); return }
|
||||
if (Date.now () - lastResponse >= 30_000)
|
||||
{ stop ('timeout'); return }
|
||||
timer = setTimeout (() => poll (token), 2000)
|
||||
}
|
||||
finally
|
||||
{
|
||||
clearTimeout (deadline)
|
||||
if (pollRequest.current === controller) pollRequest.current = null
|
||||
if (request === controller) request = null
|
||||
}
|
||||
}
|
||||
if (creation.current == null)
|
||||
{
|
||||
const controller = new AbortController ()
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
creation.current = apiPost<{ job: string }> ('/counts/export/jobs', payload, {
|
||||
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
|
||||
creation.current = apiPost<{ job: string }> (
|
||||
'/counts/export/jobs', initial.current.payload, {
|
||||
locale: initial.current.locale, signal: controller.signal })
|
||||
.finally (() => clearTimeout (deadline))
|
||||
}
|
||||
creation.current.then (async result => {
|
||||
if (!active || terminal.current) return
|
||||
armDeadline ()
|
||||
void creation.current.then (result => {
|
||||
if (!current () || terminal.current) return
|
||||
job.current = result.job
|
||||
if (cancelled.current) await handle.cancel ()
|
||||
await poll (result.job)
|
||||
}).catch (() => {
|
||||
if (active)
|
||||
{
|
||||
setDownloadError ('failed')
|
||||
terminal.current = true
|
||||
setState (previous => ({ ...previous, status: 'failed' }))
|
||||
}
|
||||
void poll (result.job)
|
||||
}).catch (error => {
|
||||
if (current ()) setFailure (error)
|
||||
stop ('failed')
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
if (timer != null) clearTimeout (timer)
|
||||
pollRequest.current?.abort ()
|
||||
releaseDownload.current?.()
|
||||
if (acknowledgementTimer.current != null) clearTimeout (acknowledgementTimer.current)
|
||||
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
||||
request?.abort ()
|
||||
// StrictMode immediately installs another effect; real unmount releases the frame.
|
||||
queueMicrotask (() => {
|
||||
if (generation.current !== epoch) return
|
||||
releaseDownload.current?.()
|
||||
releaseDownload.current = null
|
||||
})
|
||||
}
|
||||
}, [handle, locale])
|
||||
useEffect (() => {
|
||||
controls.setActions ([])
|
||||
}, [controls])
|
||||
}, [])
|
||||
useEffect (() => { controls.setActions ([]) }, [controls])
|
||||
const percent = state.total == null ? null : (state.total === 0 ? 100
|
||||
: Math.min (100, Math.floor (state.processed / state.total * 100)))
|
||||
const busy = !['ready', 'failed', 'cancelled'].includes (state.status)
|
||||
const phase = message (`exportProgress.${state.status === 'running'
|
||||
? state.phase ?? 'running' : state.status}`)
|
||||
const busy = downloadError == null && cancelError == null
|
||||
&& !['failed', 'cancelled'].includes (state.status)
|
||||
const phaseKey = (downloadError != null || cancelError != null) ? 'failed'
|
||||
: (state.status === 'running' ? state.phase ?? 'running'
|
||||
: (state.status === 'ready' ? 'writing' : state.status))
|
||||
const phase = message (`exportProgress.${phaseKey}`)
|
||||
const failureMessage = countsErrorMessage (failure ?? state, locale)
|
||||
const cancellationMessage = countsErrorMessage (cancelError, locale)
|
||||
return <div className="space-y-3 py-2" aria-busy={busy}>
|
||||
<p>{label ('exportProgress.format', {
|
||||
format: message (`exportFormats.${payload.format}`).text })}</p>
|
||||
@@ -214,9 +265,14 @@ const CountsExportProgress = (
|
||||
? label ('exportProgress.processed', { count: state.processed })
|
||||
: label ('exportProgress.measured', {
|
||||
processed: state.processed, total: state.total ?? 0, percent })}</p>
|
||||
{downloadError != null && <p role="alert">
|
||||
{label (`downloadErrors.${downloadError}`)}</p>}
|
||||
{cancelError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
{(downloadError != null || state.status === 'failed') && <p role="alert"
|
||||
lang={failureMessage.language}>
|
||||
{failure != null || state.status === 'failed'
|
||||
? failureMessage.text
|
||||
: label (`downloadErrors.${downloadError}`)}
|
||||
{downloadError != null && <><br/>{label ('errors.stopped')}</>}</p>}
|
||||
{cancelError != null && <p role="alert" lang={cancellationMessage.language}>
|
||||
{cancellationMessage.text}</p>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする