mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(electron): show the main window once its document has loaded; enforce J1 IPC and mutation counters (#1828)
* fix(electron): show the main window once its document has loaded; enforce J1 IPC and mutation counters Re-lands #1788, which merged into #1782's branch after #1782 had already reached master, so none of it is on master. The hidden main window was shown on ready-to-show only. On Linux under X11, when the startup scripts run before the window's first frame, the next frame comes about a second later: nothing is on screen and the splash's requestAnimationFrame waits, so J1's first card came ~940 ms after load instead of ~480 ms in most runner launches (18 bridge calls / 1,031-1,033 DOM mutations instead of 15 / 558). The window is now shown at ready-to-show or the main frame's did-finish-load, whichever comes first, with the splash colour as its background so showing before the first paint does not flash. The journey gate keeps the app's did-finish-load listeners away from its about:blank detour, as it already does for ready-to-show. Three dispatched runs on this branch (37192092882, 37192097790, 37192103151) read 15 calls and 558 mutations in all 18 iterations, stable: true. Both become baselines (slack 0), and the Performance journeys job checks them with check-journey-ratchet.mjs --only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(perf): record the evidence PR of the J1 runtime baselines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
17 files changed
+451
-39
No files matched your search
@@ -75,11 +75,14 @@ type MockMainWindow = {
|
||||
maximize: jest.Mock<void, []>;
|
||||
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
removeListener: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
setFullScreen: jest.Mock<void, [boolean]>;
|
||||
setMenu: jest.Mock<void, [unknown]>;
|
||||
show: jest.Mock<void, []>;
|
||||
webContents: {
|
||||
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
removeListener: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
openDevTools: jest.Mock<void, []>;
|
||||
setWindowOpenHandler: jest.Mock<void, [unknown]>;
|
||||
getZoomLevel: jest.Mock<number, []>;
|
||||
@@ -98,12 +101,18 @@ function createMockMainWindow(): MockMainWindow {
|
||||
maximize: jest.fn<void, []>(),
|
||||
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
removeListener: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
setFullScreen: jest.fn<void, [boolean]>(),
|
||||
setMenu: jest.fn<void, [unknown]>(),
|
||||
show: jest.fn<void, []>(),
|
||||
webContents: {
|
||||
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
removeListener: jest.fn<
|
||||
void,
|
||||
[string, (...args: unknown[]) => void]
|
||||
>(),
|
||||
openDevTools: jest.fn<void, []>(),
|
||||
setWindowOpenHandler: jest.fn<void, [unknown]>(),
|
||||
getZoomLevel: jest.fn<number, []>().mockReturnValue(0),
|
||||
@@ -366,6 +375,30 @@ describe('Electron app security helpers', () => {
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the window at did-finish-load when ready-to-show has not come yet', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
show: false,
|
||||
backgroundColor: '#1f1f23',
|
||||
})
|
||||
);
|
||||
const [loadHandler] = mainWindow.webContents.once.mock.calls
|
||||
.filter(([eventName]) => eventName === 'did-finish-load')
|
||||
.map(([, handler]) => handler);
|
||||
loadHandler();
|
||||
|
||||
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The later ready-to-show is a no-op.
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates the window fullscreen when the stored mode says so', () => {
|
||||
storeStartupWindowMode('fullscreen');
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
trace,
|
||||
traceStartupPhase,
|
||||
} from './services/debug-trace';
|
||||
import {
|
||||
MAIN_WINDOW_BACKGROUND_COLOR,
|
||||
showMainWindowWhenLoaded,
|
||||
} from './services/main-window-first-show';
|
||||
import { attachMainWindowPerformanceCounters } from './services/performance-counters';
|
||||
import {
|
||||
STARTUP_WINDOW_MODE,
|
||||
@@ -512,6 +516,9 @@ export default class App {
|
||||
width: width,
|
||||
height: height,
|
||||
show: false,
|
||||
// The splash colour: the window can be shown before its first
|
||||
// paint (main-window-first-show.ts).
|
||||
backgroundColor: MAIN_WINDOW_BACKGROUND_COLOR,
|
||||
webPreferences: getMainWindowWebPreferences(),
|
||||
...savedWindowBounds,
|
||||
// Fullscreen is a constructor option: the window is created
|
||||
@@ -543,15 +550,17 @@ export default class App {
|
||||
App.mainWindow.center();
|
||||
}
|
||||
|
||||
// if main window is ready to show, close the splash window and show the main window
|
||||
App.mainWindow.once('ready-to-show', () => {
|
||||
// Shown at ready-to-show or did-finish-load, whichever comes first
|
||||
// (see main-window-first-show.ts).
|
||||
const mainWindow = App.mainWindow;
|
||||
showMainWindowWhenLoaded(mainWindow, () => {
|
||||
// maximize() on a hidden window shows it (Electron docs), so it
|
||||
// has to wait for ready-to-show like show() does — any earlier
|
||||
// and a blank window flashes before the renderer paints.
|
||||
// waits for the document like show() does — any earlier and a
|
||||
// blank window flashes before the splash is there.
|
||||
if (startupWindowMode === 'maximized') {
|
||||
App.mainWindow.maximize();
|
||||
mainWindow.maximize();
|
||||
}
|
||||
App.mainWindow.show();
|
||||
mainWindow.show();
|
||||
// macOS ignores the constructor's `fullscreen` while the window
|
||||
// is hidden — an NSWindow can only toggle fullscreen once it is
|
||||
// on screen — so the request is repeated after show() wherever
|
||||
@@ -562,9 +571,9 @@ export default class App {
|
||||
// asking for it again.
|
||||
if (
|
||||
startupWindowMode === 'fullscreen' &&
|
||||
!App.mainWindow.isFullScreen()
|
||||
!mainWindow.isFullScreen()
|
||||
) {
|
||||
requestFullScreen(App.mainWindow, true);
|
||||
requestFullScreen(mainWindow, true);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
type FirstShowWindow,
|
||||
showMainWindowWhenLoaded,
|
||||
} from './main-window-first-show';
|
||||
|
||||
function createWindow(): FirstShowWindow &
|
||||
EventEmitter & {
|
||||
webContents: EventEmitter;
|
||||
destroyed: boolean;
|
||||
} {
|
||||
const window = Object.assign(new EventEmitter(), {
|
||||
destroyed: false,
|
||||
webContents: new EventEmitter(),
|
||||
isDestroyed(): boolean {
|
||||
return window.destroyed;
|
||||
},
|
||||
});
|
||||
return window;
|
||||
}
|
||||
|
||||
describe('showMainWindowWhenLoaded', () => {
|
||||
it('shows the window at did-finish-load when ready-to-show has not fired', () => {
|
||||
// The Linux race: the hidden window gets no frame for its first
|
||||
// paint, so ready-to-show (and the splash's animation frame) would
|
||||
// wait about a second after the document has loaded.
|
||||
const window = createWindow();
|
||||
const show = jest.fn();
|
||||
showMainWindowWhenLoaded(window, show);
|
||||
|
||||
window.webContents.emit('did-finish-load');
|
||||
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the window at ready-to-show when that comes first', () => {
|
||||
const window = createWindow();
|
||||
const show = jest.fn();
|
||||
showMainWindowWhenLoaded(window, show);
|
||||
|
||||
window.emit('ready-to-show');
|
||||
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the window only once and detaches the other listener', () => {
|
||||
const window = createWindow();
|
||||
const show = jest.fn();
|
||||
showMainWindowWhenLoaded(window, show);
|
||||
|
||||
window.webContents.emit('did-finish-load');
|
||||
window.emit('ready-to-show');
|
||||
// A reload loads the document again; the window is already shown.
|
||||
window.webContents.emit('did-finish-load');
|
||||
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
expect(window.listenerCount('ready-to-show')).toBe(0);
|
||||
expect(window.webContents.listenerCount('did-finish-load')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not show a window that was destroyed before it loaded', () => {
|
||||
const window = createWindow();
|
||||
const show = jest.fn();
|
||||
showMainWindowWhenLoaded(window, show);
|
||||
|
||||
window.destroyed = true;
|
||||
window.emit('ready-to-show');
|
||||
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* When the hidden main window is first shown.
|
||||
*
|
||||
* The window is created with `show: false` and used to be shown on
|
||||
* `ready-to-show` only. That event needs the window's first visually
|
||||
* non-empty paint, and a hidden window does not always get a frame for it:
|
||||
* on Linux under X11, when the startup scripts run before that frame, the
|
||||
* next one comes about a second later. Until then nothing is on screen and
|
||||
* the renderer gets no animation frames, so the splash that `main.ts`
|
||||
* removes in a `requestAnimationFrame` stays even after the dashboard has
|
||||
* rendered (J1 on the CI runner: about 450 ms later to the first card, in
|
||||
* roughly one launch out of three, see docs/architecture/performance-journeys.md).
|
||||
*
|
||||
* The window is therefore shown at whichever comes first: `ready-to-show`
|
||||
* or the main frame's `did-finish-load`. At `did-finish-load` the inline
|
||||
* splash is parsed and styled, and the window's `backgroundColor` matches
|
||||
* it, so showing before the first paint does not flash.
|
||||
*/
|
||||
|
||||
/** Matches `#initial-splash` in `apps/web/src/index.html`. */
|
||||
export const MAIN_WINDOW_BACKGROUND_COLOR = '#1f1f23';
|
||||
|
||||
type OnceEmitter = {
|
||||
once(event: string, listener: () => void): unknown;
|
||||
removeListener(event: string, listener: () => void): unknown;
|
||||
};
|
||||
|
||||
export type FirstShowWindow = OnceEmitter & {
|
||||
isDestroyed(): boolean;
|
||||
readonly webContents: OnceEmitter;
|
||||
};
|
||||
|
||||
/** Calls `show` once, at `ready-to-show` or `did-finish-load`, whichever comes first. */
|
||||
export function showMainWindowWhenLoaded(
|
||||
window: FirstShowWindow,
|
||||
show: () => void
|
||||
): void {
|
||||
let shown = false;
|
||||
const showOnce = (): void => {
|
||||
if (shown) {
|
||||
return;
|
||||
}
|
||||
shown = true;
|
||||
window.removeListener('ready-to-show', showOnce);
|
||||
window.webContents.removeListener('did-finish-load', showOnce);
|
||||
if (!window.isDestroyed()) {
|
||||
show();
|
||||
}
|
||||
};
|
||||
window.once('ready-to-show', showOnce);
|
||||
window.webContents.once('did-finish-load', showOnce);
|
||||
}
|
||||
Reference in new issue
Block a user