feat(ui:delta upload): warn user if refresh page during upload

This commit is contained in:
Edouard Vanbelle
2026-08-14 01:00:50 +02:00
parent e357caf43b
commit ef465b99de
3 changed files with 185 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
/**
* Interrupted-upload registry — small helper so a page reload during
* upload doesn't leave the user without a hint that work was in flight.
*
* Two coordinated behaviours:
*
* 1. `beforeunload` warning while any upload is active.
* Registered lazily: as soon as an upload starts we install a
* page-scope handler that triggers the browser's "Leave site?
* Changes may not be saved" prompt on refresh / tab close.
* Deliberate leave (user clicks Leave) proceeds normally.
*
* 2. `sessionStorage`-backed "interrupted uploads" register.
* Every `uploadBatch` writes a record while it runs, clears it on
* completion. If a reload happens mid-flight, the record survives
* into the next page load and `readAndClearInterrupted` surfaces
* it so the layout can toast: "Uploads were interrupted — re-drop
* the files to resume (already-uploaded chunks reuse)."
*
* `sessionStorage` (not `localStorage`) on purpose: entries clear
* when the tab closes entirely, so a "closed the tab an hour ago"
* user isn't nagged. Only a same-tab reload preserves them.
*/
/** Key under which the interrupted-uploads register is stored. */
const STORAGE_KEY = 'oxi:upload:interrupted';
/** One record per active `uploadBatch()` invocation. */
export interface InterruptedRecord {
/** UI-facing description — filename for singleton uploads, "N files" for batches. */
description: string;
/** Where the batch was targeted. `null` = drive root. */
folderId: string | null;
/** UNIX ms — used as the identity key that matches start/finish
* calls so multiple concurrent batches don't step on each other. */
startedAt: number;
}
// ── Page-scope beforeunload guard ────────────────────────────────────
let activeBatches = 0;
function beforeUnloadHandler(e: BeforeUnloadEvent): void {
// Spec-compliant trigger for the browser's "Leave site?" dialog.
// Modern Chrome/Firefox/Safari all honor preventDefault(). The
// browser shows its own confirmation copy — we can't customize it.
e.preventDefault();
}
/** Register a live upload batch so the beforeunload guard is active while
* it runs. Balanced by `releaseUploadGuard` in the batch's finally. */
export function acquireUploadGuard(): void {
if (typeof window === 'undefined') return;
if (activeBatches === 0) {
window.addEventListener('beforeunload', beforeUnloadHandler);
}
activeBatches++;
}
/** Match to `acquireUploadGuard`; when the last active batch releases,
* the beforeunload listener is removed so unrelated navigations don't
* trigger the browser's "leave site?" prompt. */
export function releaseUploadGuard(): void {
if (typeof window === 'undefined') return;
activeBatches = Math.max(0, activeBatches - 1);
if (activeBatches === 0) {
window.removeEventListener('beforeunload', beforeUnloadHandler);
}
}
// ── sessionStorage register ──────────────────────────────────────────
function readAll(): InterruptedRecord[] {
if (typeof sessionStorage === 'undefined') return [];
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as InterruptedRecord[]) : [];
} catch {
return [];
}
}
function writeAll(records: InterruptedRecord[]): void {
if (typeof sessionStorage === 'undefined') return;
try {
if (records.length === 0) sessionStorage.removeItem(STORAGE_KEY);
else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(records));
} catch {
/* quota exceeded / disabled — silent; the register is best-effort. */
}
}
/** Add a record when a batch starts. Returns a handle to pass back to
* `markUploadFinished` on completion / failure — that way multiple
* concurrent batches don't step on each other's entries. */
export function markUploadStarted(description: string, folderId: string | null): number {
const record: InterruptedRecord = {
description,
folderId,
startedAt: Date.now()
};
const all = readAll();
all.push(record);
writeAll(all);
return record.startedAt;
}
/** Remove the record when the batch completes (success or failure). Uses
* the `markUploadStarted` return value as the identity key. */
export function markUploadFinished(startedAt: number): void {
const all = readAll();
const idx = all.findIndex((r) => r.startedAt === startedAt);
if (idx >= 0) {
all.splice(idx, 1);
writeAll(all);
}
}
/** Read and clear the register — called by the layout on mount. Returns
* what was there. Empties the register in the same call so a subsequent
* refresh doesn't re-notify. */
export function readAndClearInterrupted(): InterruptedRecord[] {
const all = readAll();
writeAll([]);
return all;
}
+28
View File
@@ -15,6 +15,8 @@
import { hashUrlToPath } from '$lib/utils/hashRedirect';
import { killLegacyServiceWorker } from '$lib/utils/killLegacyServiceWorker';
import { getOidcProviders, type OidcProviders } from '$lib/api/endpoints/auth';
import { readAndClearInterrupted } from '$lib/upload/interruption';
import { t } from '$lib/i18n/index.svelte';
let { children } = $props();
@@ -156,6 +158,32 @@
// loading state) is already in the DOM behind it.
document.getElementById('app-splash')?.remove();
// If a page reload interrupted one or more uploads mid-flight, the
// registry in sessionStorage carries breadcrumbs into the next mount.
// Toast a resume hint — already-uploaded chunks reuse via delta's
// negotiate stage, so re-dropping the same file is fast, not from
// scratch. Reading the register clears it so a subsequent reload
// doesn't re-notify.
const interrupted = readAndClearInterrupted();
if (interrupted.length > 0) {
const one = interrupted.length === 1;
const msg = one
? t(
'files.upload_interrupted_one',
{ description: interrupted[0].description },
`Upload interrupted: ${interrupted[0].description}. Re-drop to resume — already-uploaded chunks are reused.`
)
: t(
'files.upload_interrupted_many',
{ n: interrupted.length },
`${interrupted.length} uploads interrupted. Re-drop the files to resume — already-uploaded chunks are reused.`
);
// 10 s dwell — same pattern as the other long-copy toasts
// (see profile SSO error). Info kind because the situation is
// recoverable and the user only needs to know how to resume.
ui.notify(msg, 'info', 10000);
}
// Redirect old `#/...` bookmarks to the new path before anything else.
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
// hashUrlToPath returns a dynamic in-app path string; resolve() is typed
@@ -30,6 +30,12 @@
resolveOwnedHashes,
tryDeltaUpload
} from '$lib/api/endpoints/deltaUpload';
import {
acquireUploadGuard,
markUploadFinished,
markUploadStarted,
releaseUploadGuard
} from '$lib/upload/interruption';
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
@@ -710,6 +716,17 @@
async function uploadBatch(files: File[]) {
if (files.length === 0) return;
uploading = true;
// Arm the reload-guard + persist a "batch in flight" marker so a
// page refresh mid-upload (a) prompts the browser's "Leave site?"
// dialog and (b) leaves a breadcrumb the layout picks up on the
// next mount → toasts a "uploads were interrupted, re-drop to
// resume (chunks reuse)" hint.
acquireUploadGuard();
const batchDescription =
files.length === 1
? files[0].name
: t('files.n_files_batch', { n: files.length }, `${files.length} files`);
const batchHandle = markUploadStarted(batchDescription, currentId);
const nid = ui.startProgress(
t('files.uploading_n', { done: 0, total: files.length }, `Uploading 0/${files.length} files…`)
);
@@ -760,6 +777,8 @@
ui.finishProgress(nid, errorMessage(err), 'error');
} finally {
uploading = false;
markUploadFinished(batchHandle);
releaseUploadGuard();
}
}
@@ -1539,6 +1558,14 @@
async function uploadTree(entries: { file: File; relativePath: string }[]) {
if (entries.length === 0) return;
uploading = true;
// Same reload-guard + interrupted-uploads breadcrumb as uploadBatch —
// the browser prompts on refresh, and if the user reloads anyway
// the layout picks up the marker on next mount and toasts.
acquireUploadGuard();
const treeHandle = markUploadStarted(
t('files.n_files_batch', { n: entries.length }, `${entries.length} files`),
currentId
);
// Same bell progress notification as uploadBatch, so folder uploads show
// live progress + a final result instead of staying silent until the end.
const nid = ui.startProgress(
@@ -1594,6 +1621,8 @@
ui.finishProgress(nid, errorMessage(err), 'error');
} finally {
uploading = false;
markUploadFinished(treeHandle);
releaseUploadGuard();
}
}