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:
2026-06-02 15:22:30 +03:00
parent ad62f02ad8
commit 01e0d1c529
2 changed files with 92 additions and 0 deletions

View File

@@ -110,6 +110,28 @@ let _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
const MAX_RECONNECT = 3;
const RECONNECT_DELAY_MS = 10_000;
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 {
_onCompleteCallback = null;
@@ -132,6 +154,37 @@ export function startTranslationRun(runId: string, options: StartRunOptions = {}
_onCompleteCallback = options.onComplete || null;
_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);
_startTimeout();
}
@@ -164,6 +217,7 @@ function _clearReconnect(): void {
function _cleanup(): void {
_clearTimeout();
_clearReconnect();
_clearSessionStorage();
if (_ws) {
_ws.onclose = null;
_ws.onerror = null;
@@ -272,11 +326,25 @@ export function stopTranslationRun(): void {
export function resetTranslationRun(): void {
stopTranslationRun();
_reconnectCount = 0;
_clearSessionStorage();
_state = initialState;
_notify();
_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 {
_state = { ..._state, ...state };
_notify();

View File

@@ -89,8 +89,10 @@
import {
translationRunStore,
startTranslationRun,
reconnectToRun,
resetTranslationRun,
clearOnCompleteCallback,
getStoredActiveRun,
} from '$lib/stores/translationRun.svelte.js';
@@ -293,6 +295,7 @@
// Watch for jobId changes — handles:
// 1. Direct navigation to existing job page (environments loaded in onMount)
// 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
$effect(() => {
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
// polling so the global indicator in the layout still works.
onDestroy(() => {