Files
ddidderr 25bf56ff13 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.
2026-09-12 21:10:41 +02:00

315 lines
12 KiB
TypeScript

import {
bootstrapFrontend,
type FrontendCloseRequest,
} from '../src/lib/frontendBootstrap.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('close-listener registration failure destroys the empty window without mounting hooks', async () => {
const registrationFailure = new Error('registration failed');
const destructionStarted = deferred<void>();
const destruction = deferred<void>();
const order: string[] = [];
const reported: unknown[] = [];
let renders = 0;
let settled = false;
const bootstrap = bootstrapFrontend({
registerCloseRequested: () => Promise.reject(registrationFailure),
disposeOwners: () => {
order.push('dispose owners');
return Promise.resolve();
},
drainPersistence: () => {
order.push('drain persistence');
return Promise.resolve();
},
destroyWindow: () => {
order.push('destroy window');
destructionStarted.resolve();
return destruction.promise;
},
render: () => {
renders += 1;
},
reportFailure: (_message, error) => reported.push(error),
}).then(result => {
settled = true;
return result;
});
await destructionStarted.promise;
assertEquals(renders, 0, 'React and its hooks must remain unmounted');
assertEquals(settled, false, 'bootstrap must explicitly await window destruction');
assertEquals(
JSON.stringify(order),
JSON.stringify(['dispose owners', 'drain persistence', 'destroy window']),
'admissions must close and drain before destroying the empty window',
);
destruction.resolve();
assertEquals(await bootstrap, false, 'registration failure must report no render');
assertEquals(renders, 0, 'hooks must never mount after failure cleanup');
assertEquals(reported.length, 1, 'the registration failure must be handled once');
assertEquals(reported[0], registrationFailure, 'the original failure must be reported');
});
Deno.test('bootstrap handles failure of the fail-closed destroy action', async () => {
const registrationFailure = new Error('registration failed');
const destructionFailure = new Error('destroy failed');
const reported: unknown[] = [];
let renders = 0;
const rendered = await bootstrapFrontend({
registerCloseRequested: () => Promise.reject(registrationFailure),
disposeOwners: () => Promise.resolve(),
drainPersistence: () => Promise.resolve(),
destroyWindow: () => Promise.reject(destructionFailure),
render: () => {
renders += 1;
},
reportFailure: (_message, error) => reported.push(error),
});
assertEquals(rendered, false, 'a failed registration must never render');
assertEquals(renders, 0, 'destroy failure must not fall back to mounting hooks');
assertEquals(reported.length, 2, 'both terminal failures must be handled');
assertEquals(reported[0], registrationFailure, 'registration failure must be preserved');
assertEquals(reported[1], destructionFailure, 'destroy failure must be observed');
});
Deno.test('successful bootstrap mounts only after close registration resolves', async () => {
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
const registrationAcknowledged = deferred<() => void>();
let renders = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => {
registered.resolve(handler);
return registrationAcknowledged.promise;
},
disposeOwners: () => Promise.resolve(),
drainPersistence: () => Promise.resolve(),
destroyWindow: () => Promise.resolve(),
render: () => {
renders += 1;
},
reportFailure: () => {},
});
await registered.promise;
assertEquals(renders, 0, 'render must wait for registration acknowledgement');
registrationAcknowledged.resolve(() => {});
assertEquals(await bootstrap, true, 'successful registration should render the app');
assertEquals(renders, 1, 'the app should mount once');
});
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 order: string[] = [];
let renders = 0;
let prevented = 0;
let listenerCleanups = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => {
registered.resolve(handler);
return registrationAcknowledged.promise;
},
disposeOwners: () => {
order.push('dispose owners');
return Promise.resolve();
},
drainPersistence: () => {
order.push('drain persistence');
return Promise.resolve();
},
destroyWindow: () => {
order.push('destroy window');
return Promise.resolve();
},
render: () => {
renders += 1;
},
reportFailure: () => {},
});
const handler = await registered.promise;
const close = Promise.resolve(handler({
preventDefault: () => prevented += 1,
})).then(() => {
if (prevented === 0) order.push('tauri destroy');
});
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 () => {
listenerCleanups += 1;
});
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', '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[] = [];
let cleanups = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: () => {
order.push('register');
return Promise.resolve(async () => {
order.push('unlisten');
cleanups += 1;
finalizerStarted.resolve();
throw unlistenFailure;
});
},
disposeOwners: async () => {
order.push('dispose owners');
await ownerDrained.promise;
},
drainPersistence: async () => {
order.push('drain persistence');
drainsStarted.resolve();
await persistenceDrained.promise;
},
destroyWindow: () => {
order.push('destroy window');
return Promise.reject(destroyFailure);
},
render: () => {
order.push('render');
throw renderFailure;
},
reportFailure: (_message, error) => reported.push(error),
});
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']),
'render failure must synchronously start both frontend drains',
);
ownerDrained.resolve();
await Promise.resolve();
assertEquals(
order.includes('destroy window'),
false,
'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(
JSON.stringify(order),
JSON.stringify([
'register',
'render',
'dispose owners',
'drain persistence',
'unlisten',
'destroy window',
]),
'destruction must be the terminal finalizer',
);
assertEquals(reported.length, 3, 'render, cleanup, and destroy failures must be observed');
assertEquals(reported[0], renderFailure, 'the render failure must be reported first');
assertEquals(reported[1], unlistenFailure, 'listener cleanup rejection must be reported');
assertEquals(reported[2], destroyFailure, 'window destroy rejection must be reported');
});