fix: warn before closing the tab while an upload is in flight

Uploads stream from the page, so closing the tab loses a nearly-finished
one with no warning. The beforeunload handler only existed behind
prompt_user_when_navigation_away_from_puter, which is off by default.

Install it unconditionally and have it consult window.active_uploads,
the registry the progress code already maintains. The feature flag keeps
its open-window behavior.

Uploads now register in active_uploads before the first await in the
init callback: an upload failing while the progress window was still
opening used to run its delete ahead of the insert, leaving a phantom
entry that would have made the tab unclosable.
This commit is contained in:
Nariman Jelveh
2026-09-15 13:07:43 -07:00
parent b136c56cb5
commit a1f7577acb
5 changed files with 103 additions and 13 deletions
+6 -3
View File
@@ -1320,6 +1320,9 @@ const TabFiles = {
thumbnailGenerator: createUploadThumbnailGenerator(),
init: async (operation_id, xhr) => {
opid = operation_id;
// register before the first await, so a failure while the progress
// window is still opening can't delete the entry before it exists
window.active_uploads[opid] = 0;
// create upload progress window
upload_progress_window = await UIWindowProgress({
title: i18n('upload'),
@@ -1331,8 +1334,6 @@ const TabFiles = {
xhr.abort();
},
});
// add to active_uploads
window.active_uploads[opid] = 0;
},
// start
start: async function () {
@@ -4806,6 +4807,9 @@ const TabFiles = {
thumbnailGenerator: createUploadThumbnailGenerator(),
init: async (operation_id, xhr) => {
opid = operation_id;
// register before the first await, so a failure while the progress
// window is still opening can't delete the entry before it exists
window.active_uploads[opid] = 0;
upload_progress_window = await UIWindowProgress({
title: i18n('upload'),
icon: window.icons['app-icon-uploader.svg'],
@@ -4816,7 +4820,6 @@ const TabFiles = {
xhr.abort();
},
});
window.active_uploads[opid] = 0;
},
start: async function () {
upload_progress_window.set_status('Uploading');
+3 -2
View File
@@ -2378,6 +2378,9 @@ window.upload_items = async function (items, dest_path) {
// init
init: async (operation_id, xhr) => {
opid = operation_id;
// register before the first await, so a failure while the progress
// window is still opening can't delete the entry before it exists
window.active_uploads[opid] = 0;
// create upload progress window
upload_progress_window = await UIWindowProgress({
title: i18n('upload'),
@@ -2389,8 +2392,6 @@ window.upload_items = async function (items, dest_path) {
xhr.abort();
},
});
// add to active_uploads
window.active_uploads[opid] = 0;
},
// start
start: async function () {
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Whether closing the tab right now would destroy work in progress, so the
* browser should put its "leave site?" dialog in the way first.
*
* Uploads stream from this page, so they die with it a nearly-finished
* upload has to start over. Downloads are handed to the browser's own
* downloader and outlive the tab, so they don't count.
*
* @param {{ activeUploadCount?: number, openWindowCount?: number, promptOnOpenWindows?: boolean }} state
* @returns {boolean}
*/
export const should_confirm_before_unload = ({
activeUploadCount = 0,
openWindowCount = 0,
promptOnOpenWindows = false,
} = {}) => {
if ( activeUploadCount > 0 ) return true;
return promptOnOpenWindows && openWindowCount > 0;
};
/**
* `beforeunload` handler reading the live globals. Returning a truthy value is
* what triggers the dialog; its wording is the browser's, not ours.
*
* @returns {true|undefined}
*/
export const confirm_before_unload = () => {
const confirm = should_confirm_before_unload({
activeUploadCount: Object.keys(window.active_uploads ?? {}).length,
// Explorer windows hold nothing unsaved, so they don't warrant a prompt.
openWindowCount: $('.window:not(.window[data-app="explorer"])').length,
promptOnOpenWindows: Boolean(window.feature_flags?.prompt_user_when_navigation_away_from_puter),
});
return confirm ? true : undefined;
};
export default confirm_before_unload;
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { should_confirm_before_unload } from './confirmBeforeUnload.js';
describe('should_confirm_before_unload', () => {
it('lets a quiet page close without a dialog', () => {
expect(should_confirm_before_unload()).toBe(false);
});
it('warns while an upload is still streaming', () => {
expect(should_confirm_before_unload({ activeUploadCount: 1 })).toBe(true);
});
it('warns during an upload even with the open-window prompt off', () => {
// The bug: a nearly-finished upload used to die silently on close.
expect(should_confirm_before_unload({
activeUploadCount: 2,
openWindowCount: 0,
promptOnOpenWindows: false,
})).toBe(true);
});
it('stops warning once the last upload settles', () => {
expect(should_confirm_before_unload({ activeUploadCount: 0 })).toBe(false);
});
it('ignores open windows unless the feature flag asks for it', () => {
expect(should_confirm_before_unload({ openWindowCount: 3 })).toBe(false);
expect(should_confirm_before_unload({
openWindowCount: 3,
promptOnOpenWindows: true,
})).toBe(true);
});
});
+4 -8
View File
@@ -41,6 +41,7 @@ import UIWindowSessionList from './UI/UIWindowSessionList.js';
import UIWindowSignup from './UI/UIWindowSignup.js';
import UIWindowRecoverPassword from './UI/UIWindowRecoverPassword.js';
import { PROCESS_RUNNING } from './definitions.js';
import confirm_before_unload from './helpers/confirmBeforeUnload.js';
import create_access_token from './helpers/createAccessToken.js';
import create_gui_token from './helpers/createGuiToken.js';
import {
@@ -2500,14 +2501,9 @@ window.initgui = async function (options) {
}
}
// if there is at least one window open (only non-Explorer windows), ask user for confirmation when navigating away from puter
if (window.feature_flags.prompt_user_when_navigation_away_from_puter) {
window.onbeforeunload = function () {
if ($('.window:not(.window[data-app="explorer"])').length > 0) {
return true;
}
};
}
// ask the user to confirm before leaving while an upload is still in flight
// (and, behind the feature flag, while any non-Explorer window is open)
window.onbeforeunload = confirm_before_unload;
// -------------------------------------------------------------------------------------
// `login` event handler