fix(overlay): recycle Windows modal windows after close

- Refresh the hidden modal renderer between Windows sessions
- Add regression coverage and stabilize launcher completion testing
This commit is contained in:
2026-08-16 01:10:07 -07:00
parent a02c33dac4
commit 82f6b4705a
5 changed files with 104 additions and 40 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
type: fixed type: fixed
area: overlay area: overlay
- Dedicated overlay modals are prewarmed and reused on macOS and Windows so shortcuts open them promptly on the first press. On macOS, these modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change. - Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning. - Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning.
+45 -4
View File
@@ -1010,8 +1010,7 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
}); });
for (const platform of ['darwin', 'win32'] as const) { test('modal reopen reuses the warm window and shows it immediately on macOS', () => {
test(`modal reopen reuses the warm window and shows it immediately on ${platform}`, () => {
const modalWindow = createMockWindow(); const modalWindow = createMockWindow();
let createCalls = 0; let createCalls = 0;
@@ -1026,7 +1025,7 @@ for (const platform of ['darwin', 'win32'] as const) {
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {}, setModalWindowBounds: () => {},
}, },
{ platform }, { platform: 'darwin' },
); );
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
@@ -1047,7 +1046,49 @@ for (const platform of ['darwin', 'win32'] as const) {
assert.equal(modalWindow.isVisible(), true); assert.equal(modalWindow.isVisible(), true);
assert.equal(modalWindow.getShowCount(), 2); assert.equal(modalWindow.getShowCount(), 2);
}); });
}
test('modal reopen on Windows uses a fresh prewarmed interactive window', () => {
const firstWindow = createMockWindow();
const replacementWindow = createMockWindow();
let currentModal = firstWindow;
let createCalls = 0;
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => currentModal as never,
createModalWindow: () => {
createCalls += 1;
currentModal = replacementWindow;
return replacementWindow as never;
},
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{ platform: 'win32' },
);
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
runtime.notifyOverlayModalOpened('runtime-options');
runtime.handleOverlayModalClosed('runtime-options');
assert.equal(firstWindow.isDestroyed(), true);
assert.equal(currentModal, replacementWindow);
assert.equal(replacementWindow.isVisible(), false);
assert.equal(createCalls, 1);
const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
restoreOnModalClose: 'session-help',
});
assert.equal(sent, true);
assert.equal(createCalls, 1);
assert.equal(replacementWindow.isVisible(), true);
assert.equal(replacementWindow.ignoreMouseEvents, false);
assert.deepEqual(replacementWindow.sent, [['session-help:open']]);
});
test('modal reopen on the warm window notifies state change for each lifecycle', () => { test('modal reopen on the warm window notifies state change for each lifecycle', () => {
const modalWindow = createMockWindow(); const modalWindow = createMockWindow();
+10 -3
View File
@@ -87,7 +87,8 @@ export function createOverlayModalRuntimeService(
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>(); const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>(); const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>();
const platform = options.platform ?? process.platform; const platform = options.platform ?? process.platform;
const keepModalWindowWarm = platform === 'darwin' || platform === 'win32'; const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
const reuseModalWindowAfterClose = platform === 'darwin';
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus; const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle => const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
@@ -181,7 +182,7 @@ export function createOverlayModalRuntimeService(
}; };
const primeModalWindow = (): boolean => { const primeModalWindow = (): boolean => {
if (!keepModalWindowWarm) { if (!shouldPrimeModalWindow) {
return false; return false;
} }
const modalWindow = resolveModalWindow(); const modalWindow = resolveModalWindow();
@@ -515,13 +516,19 @@ export function createOverlayModalRuntimeService(
if (restoreVisibleOverlayOnModalClose.size === 0) { if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
if (modalWindow && !modalWindow.isDestroyed()) { if (modalWindow && !modalWindow.isDestroyed()) {
if (keepModalWindowWarm) { if (reuseModalWindowAfterClose) {
modalWindow.setIgnoreMouseEvents(true, { forward: true }); modalWindow.setIgnoreMouseEvents(true, { forward: true });
modalWindow.hide(); modalWindow.hide();
markModalWindowPrimed(modalWindow); markModalWindowPrimed(modalWindow);
} else { } else {
modalWindow.destroy(); modalWindow.destroy();
modalWindowPrimedForImmediateShow = false; modalWindowPrimedForImmediateShow = false;
// Reusing a transparent click-through BrowserWindow can leave later modal sessions
// non-interactive on Windows. Recycle the renderer after every close, then warm its
// replacement so the next shortcut still opens promptly.
if (platform === 'win32') {
primeModalWindow();
}
} }
} }
mainWindowMousePassthroughForcedByModal = false; mainWindowMousePassthroughForcedByModal = false;
+17 -1
View File
@@ -31,6 +31,20 @@ function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; command
return { spawn, children, commands }; return { spawn, children, commands };
} }
async function waitForResult<T>(promise: Promise<T>, timeoutMs = 3000): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error('Timed out waiting for result.')), timeoutMs);
}),
]);
} finally {
if (timeout !== null) clearTimeout(timeout);
}
}
test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => { test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => {
const { spawn, children, commands } = makeSpawn(); const { spawn, children, commands } = makeSpawn();
const events: SyncProgressEvent[] = []; const events: SyncProgressEvent[] = [];
@@ -96,7 +110,9 @@ test('runSyncLauncher settles after exit when close never arrives', async () =>
// so `close` never fires. // so `close` never fires.
child.emit('exit', 1, null); child.emit('exit', 1, null);
const result = await handle.done; // Keep the isolated Bun test process alive while the production drain timer
// remains unref'ed, and fail instead of hanging if the result never settles.
const result = await waitForResult(handle.done);
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.match(result.error ?? '', /remote refused/); assert.match(result.error ?? '', /remote refused/);
}); });
+1 -1
View File
@@ -66,7 +66,7 @@ export function runSyncLauncher(options: {
spawn?: SyncLauncherSpawn; spawn?: SyncLauncherSpawn;
timeoutMs?: number; timeoutMs?: number;
}): SyncLauncherRunHandle { }): SyncLauncherRunHandle {
const spawn = const spawn: SyncLauncherSpawn =
options.spawn ?? options.spawn ??
((command, args) => { ((command, args) => {
// The child must boot as a full Electron app (its entry handles // The child must boot as a full Electron app (its entry handles