fix(download): warn before the tab closes mid-download

Bunny and direct downloads are pulled through fetch() so we can save them
under our own filename. The browser does not treat that as a download, so
closing the tab discarded everything received so far without a word.

Register a reference counted beforeunload guard while those transfers are
in flight, and while a project manifest is being pulled file by file.
Browser owned downloads (same-origin proxy, the over-10GB fallback, asset
downloads) survive a tab close on their own and stay unguarded.
This commit is contained in:
2026-08-22 12:50:45 +03:00
parent 74e4b4353e
commit cba8163286
5 changed files with 200 additions and 0 deletions
@@ -22,6 +22,7 @@ import {
createDownloadProgressToast,
type DownloadProgressToastHandle,
} from '@/components/download-progress-toast';
import { beginUnloadGuard } from '@/lib/client/unload-guard';
function sanitizeDownloadFileName(value: string): string {
return value
@@ -94,6 +95,7 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
isDownloadingRef.current = true;
setActiveDownloadTarget(target);
let progressToast: DownloadProgressToastHandle | null = null;
let releaseUnloadGuard: (() => void) | null = null;
try {
let downloadUrl: string | null = null;
@@ -167,6 +169,9 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
title: `Downloading “${baseName}`,
description: 'Starting…',
});
// The bytes only exist in this tab until the blob is saved, so warn
// before the page goes away instead of losing the whole transfer.
releaseUnloadGuard = beginUnloadGuard();
const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`, (p) => {
progressToast?.update({
description: downloadProgressLabel(p),
@@ -195,6 +200,7 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
toast.error('Failed to start download');
}
} finally {
releaseUnloadGuard?.();
isDownloadingRef.current = false;
setActiveDownloadTarget(null);
}