このコミットが含まれているのは:
2026-10-31 13:50:10 +09:00
コミット dba409c3e8
33個のファイルの変更、1490行の追加、236行の削除
+169 -113
ファイルの表示
@@ -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>
}