fix(overlay): fall back to did-finish-load when ready-to-show never fires

A hidden overlay window stops producing frames once it is explicitly
hidden and then reloaded (the Yomitan content-script reload right after
startup), so ready-to-show never fires and the content-ready gate keeps
the window hidden forever behind the Overlay loading spinner. Mark
content ready from did-finish-load after a 1.5s grace period so the
overlay always becomes showable; ready-to-show still wins when it fires.
This commit is contained in:
2026-08-01 01:31:48 -07:00
parent d7188a2155
commit 18e0200934
3 changed files with 108 additions and 8 deletions
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed the overlay never loading (stuck on the "Overlay loading" OSD spinner) when the Yomitan content-script reload raced overlay window creation at startup, most visible when playing from the anime browser on Linux/Wayland: a hidden window stops painting after that reload, so the ready-to-show signal that gates showing the overlay never fired. Content-ready now falls back to did-finish-load after a short grace period.
+62 -1
View File
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ensureOverlayWindowLevel, updateOverlayWindowBounds } from './overlay-window';
import {
ensureOverlayWindowLevel,
updateOverlayWindowBounds,
scheduleOverlayContentReadyFallback,
OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS,
} from './overlay-window';
import {
handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
@@ -322,3 +327,59 @@ test('updateOverlayWindowBounds aligns Linux overlay content bounds to mpv geome
assert.deepEqual(calls, [{ x: 0, y: -14, width: 3440, height: 1454 }]);
});
test('scheduleOverlayContentReadyFallback marks content ready when ready-to-show never fired', () => {
const events: string[] = [];
const scheduled: Array<() => void> = [];
scheduleOverlayContentReadyFallback({
isContentReady: () => false,
isDestroyed: () => false,
markContentReady: () => events.push('mark'),
setTimeoutFn: (callback, delayMs) => {
events.push(`schedule:${delayMs}`);
scheduled.push(callback);
return null;
},
});
assert.deepEqual(events, [`schedule:${OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS}`]);
scheduled[0]?.();
assert.deepEqual(events, [`schedule:${OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS}`, 'mark']);
});
test('scheduleOverlayContentReadyFallback no-ops once content is already ready', () => {
const scheduled: Array<() => void> = [];
const events: string[] = [];
scheduleOverlayContentReadyFallback({
isContentReady: () => true,
isDestroyed: () => false,
markContentReady: () => events.push('mark'),
setTimeoutFn: (callback) => {
scheduled.push(callback);
return null;
},
});
scheduled[0]?.();
assert.deepEqual(events, []);
});
test('scheduleOverlayContentReadyFallback no-ops after the window is destroyed', () => {
const scheduled: Array<() => void> = [];
const events: string[] = [];
scheduleOverlayContentReadyFallback({
isContentReady: () => false,
isDestroyed: () => true,
markContentReady: () => events.push('mark'),
setTimeoutFn: (callback) => {
scheduled.push(callback);
return null;
},
});
scheduled[0]?.();
assert.deepEqual(events, []);
});
+42 -7
View File
@@ -77,6 +77,31 @@ export function updateOverlayWindowBounds(
});
}
// ready-to-show is the primary content-ready signal, but it never fires when the
// page navigates while the window is hidden: an explicit hide() ends the
// paintWhenInitiallyHidden grace, the hidden widget stops producing frames, and
// the new document never paints. The Yomitan content-script reload right after
// startup hits exactly that, and content-ready gates showing the window, so
// without a fallback the overlay deadlocks behind the "Overlay loading" OSD.
export const OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS = 1500;
export function scheduleOverlayContentReadyFallback(deps: {
isContentReady: () => boolean;
isDestroyed: () => boolean;
markContentReady: () => void;
setTimeoutFn?: (callback: () => void, delayMs: number) => unknown;
delayMs?: number;
}): void {
const setTimeoutFn =
deps.setTimeoutFn ?? ((callback: () => void, delayMs: number): unknown => setTimeout(callback, delayMs));
setTimeoutFn(() => {
if (deps.isContentReady() || deps.isDestroyed()) {
return;
}
deps.markContentReady();
}, deps.delayMs ?? OVERLAY_CONTENT_READY_FALLBACK_DELAY_MS);
}
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
if (process.platform === 'darwin') {
window.setAlwaysOnTop(true, 'screen-saver', 1);
@@ -149,10 +174,26 @@ export function createOverlayWindow(
logger.error('Page failed to load:', errorCode, errorDescription, validatedURL);
});
const markContentReady = (): void => {
if (isOverlayWindowContentReady(window)) {
return;
}
overlayWindowContentReady.add(window);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = true;
options.onWindowContentReady?.();
};
window.webContents.on('did-finish-load', () => {
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
options.onRuntimeOptionsChanged();
options.onWindowDidFinishLoad?.();
scheduleOverlayContentReadyFallback({
isContentReady: () => isOverlayWindowContentReady(window),
isDestroyed: () => window.isDestroyed(),
markContentReady,
});
});
window.webContents.on('page-title-updated', (event) => {
@@ -160,13 +201,7 @@ export function createOverlayWindow(
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
});
window.once('ready-to-show', () => {
overlayWindowContentReady.add(window);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = true;
options.onWindowContentReady?.();
});
window.once('ready-to-show', markContentReady);
if (kind === 'visible') {
window.webContents.on('devtools-opened', () => {