fix(frontend): persist active run in sessionStorage — survive tab close/reopen
Problem: closing and reopening the translate job page (or tab close+reopen)
lost the progress bar because the in-memory store starts fresh. The backend
keeps translating, but the UI shows no progress.
Fix:
- Store: save {runId, jobId, isFullRun} to sessionStorage on startTranslationRun()
via _saveToSessionStorage()
- Store: add reconnectToRun() — re-establishes WS without resetting store state,
preserving any progress data already received
- Store: add getStoredActiveRun() — public reader for sessionStorage
- Store: clear sessionStorage via _clearSessionStorage() on terminal states
(added to _cleanup() called by WS terminal, max-reconnects, stop, reset)
- Page: add that checks getStoredActiveRun() after job loads
and calls reconnectToRun() to pick up the live progress
Edge cases handled:
- Store already connected to same run → skip (no duplicate WS)
- sessionStorage unavailable → silent catch
- SPA navigation → store already has the run, WS still connected → skip
- Page reload → sessionStorage has the run, reconnects
- Tab close + reopen → same as reload
- Run completes while away → sessionStorage cleared by _cleanup()
- Run already finished → getStoredActiveRun() returns null → skip
Build ✅ 698 tests ✅
This commit is contained in:
@@ -110,6 +110,28 @@ let _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
const MAX_RECONNECT = 3;
|
const MAX_RECONNECT = 3;
|
||||||
const RECONNECT_DELAY_MS = 10_000;
|
const RECONNECT_DELAY_MS = 10_000;
|
||||||
const RUN_TIMEOUT_MS = 600_000; // 10 minutes
|
const RUN_TIMEOUT_MS = 600_000; // 10 minutes
|
||||||
|
const SESSION_KEY = 'ss:translate:activeRun';
|
||||||
|
|
||||||
|
function _saveToSessionStorage(): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify({
|
||||||
|
runId: _state.runId,
|
||||||
|
jobId: _state.jobId,
|
||||||
|
isFullRun: _state.isFullRun,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearSessionStorage(): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(SESSION_KEY);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function clearOnCompleteCallback(): void {
|
export function clearOnCompleteCallback(): void {
|
||||||
_onCompleteCallback = null;
|
_onCompleteCallback = null;
|
||||||
@@ -132,6 +154,37 @@ export function startTranslationRun(runId: string, options: StartRunOptions = {}
|
|||||||
_onCompleteCallback = options.onComplete || null;
|
_onCompleteCallback = options.onComplete || null;
|
||||||
_reconnectCount = 0;
|
_reconnectCount = 0;
|
||||||
|
|
||||||
|
_saveToSessionStorage();
|
||||||
|
_connectWebSocket(runId);
|
||||||
|
_startTimeout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to a running translation run (e.g. after page reload).
|
||||||
|
* Unlike startTranslationRun, this does NOT clear the store first —
|
||||||
|
* it re-establishes the WS to receive live progress.
|
||||||
|
*/
|
||||||
|
export function reconnectToRun(runId: string, options: StartRunOptions = {}): void {
|
||||||
|
if (!runId) return;
|
||||||
|
|
||||||
|
// If already connected to this run, skip
|
||||||
|
if (_ws && _state.runId === runId) return;
|
||||||
|
|
||||||
|
stopTranslationRun();
|
||||||
|
|
||||||
|
_state = {
|
||||||
|
..._state,
|
||||||
|
runId,
|
||||||
|
uxState: 'running',
|
||||||
|
jobId: options.jobId || _state.jobId || null,
|
||||||
|
isFullRun: options.isFullRun || _state.isFullRun || false,
|
||||||
|
};
|
||||||
|
_notify();
|
||||||
|
|
||||||
|
_onCompleteCallback = options.onComplete || _onCompleteCallback;
|
||||||
|
_reconnectCount = 0;
|
||||||
|
|
||||||
|
_saveToSessionStorage();
|
||||||
_connectWebSocket(runId);
|
_connectWebSocket(runId);
|
||||||
_startTimeout();
|
_startTimeout();
|
||||||
}
|
}
|
||||||
@@ -164,6 +217,7 @@ function _clearReconnect(): void {
|
|||||||
function _cleanup(): void {
|
function _cleanup(): void {
|
||||||
_clearTimeout();
|
_clearTimeout();
|
||||||
_clearReconnect();
|
_clearReconnect();
|
||||||
|
_clearSessionStorage();
|
||||||
if (_ws) {
|
if (_ws) {
|
||||||
_ws.onclose = null;
|
_ws.onclose = null;
|
||||||
_ws.onerror = null;
|
_ws.onerror = null;
|
||||||
@@ -272,11 +326,25 @@ export function stopTranslationRun(): void {
|
|||||||
export function resetTranslationRun(): void {
|
export function resetTranslationRun(): void {
|
||||||
stopTranslationRun();
|
stopTranslationRun();
|
||||||
_reconnectCount = 0;
|
_reconnectCount = 0;
|
||||||
|
_clearSessionStorage();
|
||||||
_state = initialState;
|
_state = initialState;
|
||||||
_notify();
|
_notify();
|
||||||
_onCompleteCallback = null;
|
_onCompleteCallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read persisted active run info from sessionStorage (survives page reload). */
|
||||||
|
export function getStoredActiveRun(): { runId: string; jobId?: string; isFullRun?: boolean } | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (!data?.runId) return null;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function updateTranslationRunState(state: Partial<TranslationRunState>): void {
|
export function updateTranslationRunState(state: Partial<TranslationRunState>): void {
|
||||||
_state = { ..._state, ...state };
|
_state = { ..._state, ...state };
|
||||||
_notify();
|
_notify();
|
||||||
|
|||||||
@@ -89,8 +89,10 @@
|
|||||||
import {
|
import {
|
||||||
translationRunStore,
|
translationRunStore,
|
||||||
startTranslationRun,
|
startTranslationRun,
|
||||||
|
reconnectToRun,
|
||||||
resetTranslationRun,
|
resetTranslationRun,
|
||||||
clearOnCompleteCallback,
|
clearOnCompleteCallback,
|
||||||
|
getStoredActiveRun,
|
||||||
} from '$lib/stores/translationRun.svelte.js';
|
} from '$lib/stores/translationRun.svelte.js';
|
||||||
|
|
||||||
|
|
||||||
@@ -293,6 +295,7 @@
|
|||||||
// Watch for jobId changes — handles:
|
// Watch for jobId changes — handles:
|
||||||
// 1. Direct navigation to existing job page (environments loaded in onMount)
|
// 1. Direct navigation to existing job page (environments loaded in onMount)
|
||||||
// 2. Navigation after createJob (goto → param change, component reused)
|
// 2. Navigation after createJob (goto → param change, component reused)
|
||||||
|
// 3. Page reload — reconnect to any active run via sessionStorage
|
||||||
// Guards: only runs when environments are ready and it's an existing job
|
// Guards: only runs when environments are ready and it's an existing job
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (jobId && !isNewJob && environments.length > 0) {
|
if (jobId && !isNewJob && environments.length > 0) {
|
||||||
@@ -300,6 +303,27 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pick up active run after job loads (survives page reload / new tab)
|
||||||
|
$effect(() => {
|
||||||
|
if (!existingJob || !existingJob.id) return;
|
||||||
|
// Only run once when job first loads — avoid re-triggering on state changes
|
||||||
|
if (!environments.length) return;
|
||||||
|
|
||||||
|
const stored = getStoredActiveRun();
|
||||||
|
if (!stored || stored.jobId !== existingJob.id) return;
|
||||||
|
// Don't reconnect if store is already connected to this run
|
||||||
|
if (translationRunStore.value?.runId === stored.runId) return;
|
||||||
|
|
||||||
|
console.debug('[translate] picking up active run from sessionStorage', { runId: stored.runId });
|
||||||
|
isRunning = true;
|
||||||
|
isFullRun = stored.isFullRun || false;
|
||||||
|
runComplete = false;
|
||||||
|
reconnectToRun(stored.runId, {
|
||||||
|
jobId: existingJob.id,
|
||||||
|
isFullRun: stored.isFullRun || false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up store callback when page unmounts — the store keeps
|
// Clean up store callback when page unmounts — the store keeps
|
||||||
// polling so the global indicator in the layout still works.
|
// polling so the global indicator in the layout still works.
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user