diff --git a/crates/lanspread-tauri-deno-ts/src/lib/frontendBootstrap.ts b/crates/lanspread-tauri-deno-ts/src/lib/frontendBootstrap.ts index cf73c0b..a74725e 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/frontendBootstrap.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/frontendBootstrap.ts @@ -48,16 +48,14 @@ export const bootstrapFrontend = async ( options: FrontendBootstrapOptions, ): Promise => { let closing: Promise | undefined; - let publishCloseCleanup!: (cleanup: AsyncCleanup) => void; - const closeCleanupReady = new Promise(resolve => { - publishCloseCleanup = resolve; - }); + let cleanupCloseListener: AsyncCleanup | undefined; - const finalizeWindow = (): Promise => { + const drainFrontend = (): Promise => { 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 => { + 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 => { - event.preventDefault(); - return finalizeWindow(); + 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 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; diff --git a/crates/lanspread-tauri-deno-ts/tests/frontendBootstrap.test.ts b/crates/lanspread-tauri-deno-ts/tests/frontendBootstrap.test.ts index 9193300..4bef6a2 100644 --- a/crates/lanspread-tauri-deno-ts/tests/frontendBootstrap.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/frontendBootstrap.test.ts @@ -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>(); const registrationAcknowledged = deferred<() => void>(); - const cleanupDone = deferred(); 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>(); + const ownerDrained = deferred(); + const persistenceDrained = deferred(); + 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(); const persistenceDrained = deferred(); + const drainsStarted = deferred(); const finalizerStarted = deferred(); 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(