fix(frontend): close the window on the first request

Tauri's onCloseRequested wrapper already waits for the asynchronous handler and
destroys the window when that handler returns without preventing the event. The
launcher instead prevented every first request, unregistered the active listener
inside its own callback, and relied on an explicit destroy. If that callback did
not complete, the first click only started peer shutdown and a second click
bypassed the removed listener.

Let the first close request follow Tauri's automatic destruction path after
frontend owners and persistence have drained. Keep the listener installed during
that drain, and prevent only repeated requests so they share the first close
without scheduling duplicate destruction. Bootstrap and render failures still
unregister the listener and destroy their empty or failed webview explicitly.

Test Plan:
- `just frontend-test` -- passed (99 tests)
- `just build` -- passed
- `just fmt` -- passed
- `git diff --check` -- passed
- Native GUI close timing was not available in the automated UI surface.
This commit is contained in:
2026-09-12 21:10:41 +02:00
parent 0dfacfa661
commit 25bf56ff13
2 changed files with 116 additions and 58 deletions
@@ -48,16 +48,14 @@ export const bootstrapFrontend = async (
options: FrontendBootstrapOptions, options: FrontendBootstrapOptions,
): Promise<boolean> => { ): Promise<boolean> => {
let closing: Promise<void> | undefined; let closing: Promise<void> | undefined;
let publishCloseCleanup!: (cleanup: AsyncCleanup) => void; let cleanupCloseListener: AsyncCleanup | undefined;
const closeCleanupReady = new Promise<AsyncCleanup>(resolve => {
publishCloseCleanup = resolve;
});
const finalizeWindow = (): Promise<void> => { const drainFrontend = (): Promise<void> => {
if (closing !== undefined) return closing; if (closing !== undefined) return closing;
// Calling every operation closes its admission or starts its cleanup // Both calls close their admission synchronously before either drain is
// synchronously, before any asynchronous drain is awaited. // awaited. The close listener stays installed while these promises are
// pending so another native close request cannot bypass this boundary.
const ownerDrain = runHandled( const ownerDrain = runHandled(
options, options,
'Failed to dispose frontend window owners:', 'Failed to dispose frontend window owners:',
@@ -68,50 +66,53 @@ export const bootstrapFrontend = async (
'Failed to drain frontend persistence:', 'Failed to drain frontend persistence:',
options.drainPersistence, options.drainPersistence,
); );
const listenerDrain = runHandled(
options,
'Failed to unregister the window close listener:',
async () => {
const cleanup = await closeCleanupReady;
await cleanup();
},
);
closing = Promise.all([ closing = Promise.all([ownerDrain, persistenceDrain]).then(() => {});
ownerDrain,
persistenceDrain,
listenerDrain,
])
.then(() => runHandled(
options,
'Failed to destroy the frontend window:',
options.destroyWindow,
));
return closing; return closing;
}; };
const destroyAfterFailedBootstrap = async (): Promise<void> => {
await drainFrontend();
if (cleanupCloseListener !== undefined) {
await runHandled(
options,
'Failed to unregister the window close listener:',
cleanupCloseListener,
);
}
await runHandled(
options,
'Failed to destroy the frontend window:',
options.destroyWindow,
);
};
try { try {
const cleanupCloseListener = await options.registerCloseRequested(event => { cleanupCloseListener = await options.registerCloseRequested(event => {
event.preventDefault(); if (closing !== undefined) {
return finalizeWindow(); // The first handler remains responsible for Tauri's automatic
// destruction. Repeated clicks share its drain and must not
// schedule another destroy after that same promise resolves.
event.preventDefault();
return closing;
}
return drainFrontend();
}); });
publishCloseCleanup(cleanupCloseListener);
} catch (error) { } catch (error) {
publishCloseCleanup(() => {});
report(options, 'Failed to install the frontend close-drain boundary:', error); report(options, 'Failed to install the frontend close-drain boundary:', error);
// Nothing has rendered, but close both admissions in case a module-level // Nothing has rendered, but close both admissions in case a module-level
// owner was registered while loading the bundle, then explicitly await // owner was registered while loading the bundle, then explicitly await
// destruction of the empty webview. // destruction of the empty webview.
await finalizeWindow(); await destroyAfterFailedBootstrap();
return false; return false;
} }
if (closing !== undefined) { if (closing !== undefined) {
// A close request may arrive after the native listener is callable but // A close request may arrive after the native listener is callable but
// before its registration promise acknowledges ownership. The active // before its registration promise acknowledges ownership. The active
// finalizer now owns the late cleanup; never mount React into a realm // Tauri close callback now owns destruction after the drain; never mount
// that has already begun closing. // React into a realm that has already begun closing.
await closing; await closing;
return false; return false;
} }
@@ -120,7 +121,7 @@ export const bootstrapFrontend = async (
options.render(); options.render();
} catch (error) { } catch (error) {
report(options, 'Failed to render the frontend:', error); report(options, 'Failed to render the frontend:', error);
await finalizeWindow(); await destroyAfterFailedBootstrap();
return false; return false;
} }
return true; return true;
@@ -118,13 +118,13 @@ Deno.test('successful bootstrap mounts only after close registration resolves',
assertEquals(renders, 1, 'the app should mount once'); assertEquals(renders, 1, 'the app should mount once');
}); });
Deno.test('close before registration acknowledgement owns late cleanup and suppresses render', async () => { Deno.test('close before registration acknowledgement lets Tauri destroy after the drain', async () => {
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>(); const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
const registrationAcknowledged = deferred<() => void>(); const registrationAcknowledged = deferred<() => void>();
const cleanupDone = deferred<void>();
const order: string[] = []; const order: string[] = [];
let renders = 0; let renders = 0;
let prevented = 0; let prevented = 0;
let listenerCleanups = 0;
const bootstrap = bootstrapFrontend({ const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => { registerCloseRequested: handler => {
@@ -149,43 +149,97 @@ Deno.test('close before registration acknowledgement owns late cleanup and suppr
reportFailure: () => {}, reportFailure: () => {},
}); });
const close = (await registered.promise)({ const handler = await registered.promise;
preventDefault: () => { const close = Promise.resolve(handler({
prevented += 1; preventDefault: () => prevented += 1,
}, })).then(() => {
if (prevented === 0) order.push('tauri destroy');
}); });
assertEquals(prevented, 1, 'the pre-acknowledgement close must be intercepted'); assertEquals(prevented, 0, 'the first close must remain eligible for Tauri destruction');
assertEquals(renders, 0, 'React must remain unmounted while registration is unresolved'); assertEquals(renders, 0, 'React must remain unmounted while registration is unresolved');
registrationAcknowledged.resolve(async () => { registrationAcknowledged.resolve(async () => {
order.push('unlisten'); listenerCleanups += 1;
await cleanupDone.promise;
}); });
await Promise.resolve();
assertEquals(renders, 0, 'registration acknowledgement must not render after close began');
assertEquals(
order.includes('destroy window'),
false,
'the late listener cleanup must drain before destruction',
);
cleanupDone.resolve();
await close; await close;
assertEquals(await bootstrap, false, 'a pre-render close must report no mounted app'); assertEquals(await bootstrap, false, 'a pre-render close must report no mounted app');
assertEquals(renders, 0, 'a closing realm must never mount React'); assertEquals(renders, 0, 'a closing realm must never mount React');
assertEquals(listenerCleanups, 0, 'normal close leaves listener teardown to webview destruction');
assertEquals( assertEquals(
JSON.stringify(order), JSON.stringify(order),
JSON.stringify(['dispose owners', 'drain persistence', 'unlisten', 'destroy window']), JSON.stringify(['dispose owners', 'drain persistence', 'tauri destroy']),
'late listener ownership must join the same close finalizer', 'Tauri must destroy the first close after frontend state has drained',
); );
}); });
Deno.test('a repeated close cannot bypass the first close drain', async () => {
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
const ownerDrained = deferred<void>();
const persistenceDrained = deferred<void>();
let renders = 0;
let firstPrevented = 0;
let secondPrevented = 0;
let tauriDestroys = 0;
let explicitDestroys = 0;
let listenerCleanups = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => {
registered.resolve(handler);
return Promise.resolve(() => {
listenerCleanups += 1;
});
},
disposeOwners: () => ownerDrained.promise,
drainPersistence: () => persistenceDrained.promise,
destroyWindow: () => {
explicitDestroys += 1;
return Promise.resolve();
},
render: () => {
renders += 1;
},
reportFailure: () => {},
});
const handler = await registered.promise;
assertEquals(await bootstrap, true, 'the test window should mount before close');
const firstClose = Promise.resolve(handler({
preventDefault: () => firstPrevented += 1,
})).then(() => {
if (firstPrevented === 0) tauriDestroys += 1;
});
const secondClose = Promise.resolve(handler({
preventDefault: () => secondPrevented += 1,
})).then(() => {
if (secondPrevented === 0) tauriDestroys += 1;
});
assertEquals(firstPrevented, 0, 'the first close must own automatic destruction');
assertEquals(secondPrevented, 1, 'a repeated close must remain intercepted');
assertEquals(tauriDestroys, 0, 'destruction must wait for both frontend drains');
assertEquals(listenerCleanups, 0, 'the active close listener must remain installed');
ownerDrained.resolve();
await Promise.resolve();
assertEquals(tauriDestroys, 0, 'persistence must also settle before destruction');
persistenceDrained.resolve();
await Promise.all([firstClose, secondClose]);
assertEquals(renders, 1, 'the mounted application should not render again during close');
assertEquals(tauriDestroys, 1, 'only the first close may trigger Tauri destruction');
assertEquals(explicitDestroys, 0, 'normal close must use the Tauri close callback path');
assertEquals(listenerCleanups, 0, 'webview destruction owns normal listener teardown');
});
Deno.test('render failure drains and unregisters before handled window destruction', async () => { Deno.test('render failure drains and unregisters before handled window destruction', async () => {
const renderFailure = new Error('render failed'); const renderFailure = new Error('render failed');
const unlistenFailure = new Error('unlisten failed'); const unlistenFailure = new Error('unlisten failed');
const destroyFailure = new Error('destroy failed'); const destroyFailure = new Error('destroy failed');
const ownerDrained = deferred<void>(); const ownerDrained = deferred<void>();
const persistenceDrained = deferred<void>(); const persistenceDrained = deferred<void>();
const drainsStarted = deferred<void>();
const finalizerStarted = deferred<void>(); const finalizerStarted = deferred<void>();
const order: string[] = []; const order: string[] = [];
const reported: unknown[] = []; const reported: unknown[] = [];
@@ -207,6 +261,7 @@ Deno.test('render failure drains and unregisters before handled window destructi
}, },
drainPersistence: async () => { drainPersistence: async () => {
order.push('drain persistence'); order.push('drain persistence');
drainsStarted.resolve();
await persistenceDrained.promise; await persistenceDrained.promise;
}, },
destroyWindow: () => { destroyWindow: () => {
@@ -220,12 +275,12 @@ Deno.test('render failure drains and unregisters before handled window destructi
reportFailure: (_message, error) => reported.push(error), reportFailure: (_message, error) => reported.push(error),
}); });
await finalizerStarted.promise; await drainsStarted.promise;
assertEquals(cleanups, 1, 'the installed close listener must unregister exactly once'); assertEquals(cleanups, 0, 'listener cleanup must wait for frontend drains');
assertEquals( assertEquals(
JSON.stringify(order), JSON.stringify(order),
JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence', 'unlisten']), JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence']),
'render failure must synchronously start every frontend finalizer', 'render failure must synchronously start both frontend drains',
); );
ownerDrained.resolve(); ownerDrained.resolve();
@@ -236,6 +291,8 @@ Deno.test('render failure drains and unregisters before handled window destructi
'window destruction must wait for persistence as well as owner cleanup', 'window destruction must wait for persistence as well as owner cleanup',
); );
persistenceDrained.resolve(); persistenceDrained.resolve();
await finalizerStarted.promise;
assertEquals(cleanups, 1, 'the installed close listener must unregister exactly once');
assertEquals(await bootstrap, false, 'a render failure must be fully handled'); assertEquals(await bootstrap, false, 'a render failure must be fully handled');
assertEquals( assertEquals(