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,
): Promise<boolean> => {
let closing: Promise<void> | undefined;
let publishCloseCleanup!: (cleanup: AsyncCleanup) => void;
const closeCleanupReady = new Promise<AsyncCleanup>(resolve => {
publishCloseCleanup = resolve;
});
let cleanupCloseListener: AsyncCleanup | undefined;
const finalizeWindow = (): Promise<void> => {
const drainFrontend = (): Promise<void> => {
if (closing !== undefined) return closing;
// Calling every operation closes its admission or starts its cleanup
// synchronously, before any asynchronous drain is awaited.
// Both calls close their admission synchronously before either drain is
// awaited. The close listener stays installed while these promises are
// pending so another native close request cannot bypass this boundary.
const ownerDrain = runHandled(
options,
'Failed to dispose frontend window owners:',
@@ -68,50 +66,53 @@ export const bootstrapFrontend = async (
'Failed to drain frontend persistence:',
options.drainPersistence,
);
const listenerDrain = runHandled(
options,
'Failed to unregister the window close listener:',
async () => {
const cleanup = await closeCleanupReady;
await cleanup();
},
);
closing = Promise.all([
ownerDrain,
persistenceDrain,
listenerDrain,
])
.then(() => runHandled(
options,
'Failed to destroy the frontend window:',
options.destroyWindow,
));
closing = Promise.all([ownerDrain, persistenceDrain]).then(() => {});
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 {
const cleanupCloseListener = await options.registerCloseRequested(event => {
cleanupCloseListener = await options.registerCloseRequested(event => {
if (closing !== undefined) {
// 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 finalizeWindow();
return closing;
}
return drainFrontend();
});
publishCloseCleanup(cleanupCloseListener);
} catch (error) {
publishCloseCleanup(() => {});
report(options, 'Failed to install the frontend close-drain boundary:', error);
// Nothing has rendered, but close both admissions in case a module-level
// owner was registered while loading the bundle, then explicitly await
// destruction of the empty webview.
await finalizeWindow();
await destroyAfterFailedBootstrap();
return false;
}
if (closing !== undefined) {
// A close request may arrive after the native listener is callable but
// before its registration promise acknowledges ownership. The active
// finalizer now owns the late cleanup; never mount React into a realm
// that has already begun closing.
// Tauri close callback now owns destruction after the drain; never mount
// React into a realm that has already begun closing.
await closing;
return false;
}
@@ -120,7 +121,7 @@ export const bootstrapFrontend = async (
options.render();
} catch (error) {
report(options, 'Failed to render the frontend:', error);
await finalizeWindow();
await destroyAfterFailedBootstrap();
return false;
}
return true;
@@ -118,13 +118,13 @@ Deno.test('successful bootstrap mounts only after close registration resolves',
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 registrationAcknowledged = deferred<() => void>();
const cleanupDone = deferred<void>();
const order: string[] = [];
let renders = 0;
let prevented = 0;
let listenerCleanups = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => {
@@ -149,43 +149,97 @@ Deno.test('close before registration acknowledgement owns late cleanup and suppr
reportFailure: () => {},
});
const close = (await registered.promise)({
preventDefault: () => {
prevented += 1;
},
const handler = await registered.promise;
const close = Promise.resolve(handler({
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');
registrationAcknowledged.resolve(async () => {
order.push('unlisten');
await cleanupDone.promise;
listenerCleanups += 1;
});
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;
assertEquals(await bootstrap, false, 'a pre-render close must report no mounted app');
assertEquals(renders, 0, 'a closing realm must never mount React');
assertEquals(listenerCleanups, 0, 'normal close leaves listener teardown to webview destruction');
assertEquals(
JSON.stringify(order),
JSON.stringify(['dispose owners', 'drain persistence', 'unlisten', 'destroy window']),
'late listener ownership must join the same close finalizer',
JSON.stringify(['dispose owners', 'drain persistence', 'tauri destroy']),
'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 () => {
const renderFailure = new Error('render failed');
const unlistenFailure = new Error('unlisten failed');
const destroyFailure = new Error('destroy failed');
const ownerDrained = deferred<void>();
const persistenceDrained = deferred<void>();
const drainsStarted = deferred<void>();
const finalizerStarted = deferred<void>();
const order: string[] = [];
const reported: unknown[] = [];
@@ -207,6 +261,7 @@ Deno.test('render failure drains and unregisters before handled window destructi
},
drainPersistence: async () => {
order.push('drain persistence');
drainsStarted.resolve();
await persistenceDrained.promise;
},
destroyWindow: () => {
@@ -220,12 +275,12 @@ Deno.test('render failure drains and unregisters before handled window destructi
reportFailure: (_message, error) => reported.push(error),
});
await finalizerStarted.promise;
assertEquals(cleanups, 1, 'the installed close listener must unregister exactly once');
await drainsStarted.promise;
assertEquals(cleanups, 0, 'listener cleanup must wait for frontend drains');
assertEquals(
JSON.stringify(order),
JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence', 'unlisten']),
'render failure must synchronously start every frontend finalizer',
JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence']),
'render failure must synchronously start both frontend drains',
);
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',
);
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(