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
@@ -652,3 +652,69 @@ describe('useDownloadActions repeated clicks', () => {
expect(clicked).toHaveLength(1);
});
});
describe('useDownloadActions guarding the tab', () => {
function fireBeforeUnload(): BeforeUnloadEvent {
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent;
window.dispatchEvent(event);
return event;
}
// Closing the tab used to throw away a half-pulled file without a word,
// because the browser has no idea a fetch-driven download is running.
it('warns before the tab closes while the bytes are being pulled', async () => {
const pending = deferred<unknown>();
const harness = renderDownload();
fetchMock.mockImplementation((url: string) => {
if (typeof url === 'string' && url.includes('prepare=1')) {
return Promise.resolve(prepareResponse(true, { data: {} }));
}
return pending.promise;
});
let started: Promise<void> | undefined;
await act(async () => {
started = harness.result.current.startDownload();
// Let the prepare call settle so the byte fetch is the pending one.
await Promise.resolve();
});
expect(fireBeforeUnload().defaultPrevented).toBe(true);
await act(async () => {
pending.resolve(fileResponse());
await started;
});
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
it('releases the guard when the download fails', async () => {
downloadResponse = fileResponse({ ok: false });
const harness = renderDownload();
await act(async () => {
await harness.result.current.startDownload();
});
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
// A same-origin proxy download is handed to the browser, which keeps going
// after the tab closes, so nothing should block the unload there.
it('does not warn for a browser-owned download', async () => {
const harness = renderDownload({
activeVersion: makeVersion({
providerId: 'r2',
originalUrl: '/api/upload/video/abc.mp4',
}),
});
await act(async () => {
await harness.result.current.startDownload();
});
expect(clicked).toHaveLength(1);
expect(fireBeforeUnload().defaultPrevented).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
import { beginUnloadGuard, unloadGuardCount } from '@/lib/client/unload-guard';
let addSpy: MockInstance<typeof window.addEventListener>;
let removeSpy: MockInstance<typeof window.removeEventListener>;
/** True when something cancelled the unload, which is what makes the browser
* show its "leave site?" dialog. */
function unloadWasBlocked(): boolean {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
function listenerCalls(spy: MockInstance<typeof window.addEventListener>): number {
return spy.mock.calls.filter(([type]) => type === 'beforeunload').length;
}
beforeEach(() => {
addSpy = vi.spyOn(window, 'addEventListener');
removeSpy = vi.spyOn(window, 'removeEventListener');
});
afterEach(() => {
vi.restoreAllMocks();
// A leaked guard would block the unload for the rest of the session, so a
// test that leaves one behind must fail here rather than in the next test.
expect(unloadGuardCount()).toBe(0);
});
describe('beginUnloadGuard', () => {
it('cancels the unload while a download holds it', () => {
const release = beginUnloadGuard();
const blocked = unloadWasBlocked();
release();
expect(blocked).toBe(true);
});
it('lets the page go once the download is released', () => {
beginUnloadGuard()();
expect(unloadWasBlocked()).toBe(false);
});
it('keeps the listener until the last concurrent download releases', () => {
const releaseA = beginUnloadGuard();
const releaseB = beginUnloadGuard();
expect(listenerCalls(addSpy)).toBe(1);
releaseA();
const stillBlocked = unloadWasBlocked();
releaseB();
expect(stillBlocked).toBe(true);
expect(listenerCalls(removeSpy)).toBe(1);
expect(unloadWasBlocked()).toBe(false);
});
// The download hook releases from a finally block, and a caller could hold
// the returned function longer; a double release must not drop a guard
// another download still holds.
it('ignores a second release', () => {
const releaseA = beginUnloadGuard();
const releaseB = beginUnloadGuard();
releaseA();
releaseA();
const stillBlocked = unloadWasBlocked();
releaseB();
expect(stillBlocked).toBe(true);
});
});