mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -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
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: electron
|
||||
---
|
||||
|
||||
On Linux the desktop app's window no longer sometimes appears about a second
|
||||
late at launch: it now opens as soon as the app has loaded, showing the
|
||||
loading screen until the dashboard is ready.
|
||||
@@ -324,8 +324,23 @@ jobs:
|
||||
# Electron dependency check, the xvfb run, the summary lookup and
|
||||
# the job-summary report; shared with performance-ratchet.yml.
|
||||
- name: Run the performance journeys
|
||||
id: journeys
|
||||
uses: ./.github/actions/performance-journeys
|
||||
|
||||
# Only the J1 counters shown deterministic on this runner; the
|
||||
# other journey measurements stay evidence (see Ratchet in
|
||||
# docs/architecture/performance-journeys.md). Here and not in
|
||||
# the composite action, so the weekly tightening still measures
|
||||
# a run that would fail it.
|
||||
- name: Check the J1 runtime counters against the baselines
|
||||
env:
|
||||
SUMMARY: ${{ steps.journeys.outputs.summary }}
|
||||
run: >-
|
||||
node tools/performance/check-journey-ratchet.mjs
|
||||
--summary "$SUMMARY"
|
||||
--only launch/renderer.ipcCallsToFirstCard
|
||||
--only launch/renderer.domMutationsToFirstCard
|
||||
|
||||
- name: Upload journey summaries
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface JourneyRendererGateState {
|
||||
readonly gatedEpochMs: number | null;
|
||||
readonly gatedMethod: string | null;
|
||||
readonly passThroughLoads: number;
|
||||
/** `did-finish-load` events kept from the app's listeners on about:blank. */
|
||||
readonly didFinishLoadHeldOnBlank: number;
|
||||
/** `ready-to-show` events dropped while the window was on about:blank. */
|
||||
readonly readyToShowHeldOnBlank: number;
|
||||
readonly releasedEpochMs: number | null;
|
||||
|
||||
@@ -14,6 +14,7 @@ function gate(
|
||||
blankLoadedEpochMs: 1_050,
|
||||
errors: [],
|
||||
gatedEpochMs: 1_020,
|
||||
didFinishLoadHeldOnBlank: 1,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
* therefore drops `ready-to-show` while the window is on `about:blank`;
|
||||
* Electron emits it again for the real document's first paint, because the
|
||||
* window is still hidden, which is the moment production sees.
|
||||
* The app also shows its window at the main frame's `did-finish-load`
|
||||
* when that comes first, so the gate keeps the app's `did-finish-load`
|
||||
* listeners (those registered before the gated load) away from the
|
||||
* about:blank load too. Electron's own listener that resolves
|
||||
* `loadURL(about:blank)` is registered later and still runs.
|
||||
*
|
||||
* With `ipcMain` passed in, the gate also keeps the listeners registered
|
||||
* with `ipcMain.handle` for `TAPPED_IPC_CHANNELS`, so the test can call a
|
||||
@@ -53,6 +58,38 @@ function holdReadyToShowWhileBlank(window, state) {
|
||||
};
|
||||
}
|
||||
|
||||
function holdDidFinishLoadWhileBlank(window, state) {
|
||||
const contents = window.webContents;
|
||||
if (
|
||||
!contents ||
|
||||
typeof contents.emit !== 'function' ||
|
||||
typeof contents.rawListeners !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const appListeners = contents.rawListeners('did-finish-load');
|
||||
const originalEmit = contents.emit;
|
||||
contents.emit = function gatedContentsEmit(eventName, ...args) {
|
||||
if (eventName !== 'did-finish-load' || !isShowingBlank(window)) {
|
||||
return originalEmit.call(this, eventName, ...args);
|
||||
}
|
||||
state.didFinishLoadHeldOnBlank += 1;
|
||||
const attached = this.rawListeners(eventName);
|
||||
const held = appListeners.filter((listener) =>
|
||||
attached.includes(listener)
|
||||
);
|
||||
for (const listener of held) this.removeListener(eventName, listener);
|
||||
try {
|
||||
return originalEmit.call(this, eventName, ...args);
|
||||
} finally {
|
||||
// Raw listeners keep their `once` wrappers, so a re-added once
|
||||
// listener still fires once for the real document.
|
||||
for (const listener of held)
|
||||
this.prependListener(eventName, listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function tapIpcHandlers(ipcMain, channels) {
|
||||
const handlers = new Map();
|
||||
const originalHandle = ipcMain.handle;
|
||||
@@ -78,6 +115,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
errors: [],
|
||||
gatedEpochMs: null,
|
||||
gatedMethod: null,
|
||||
didFinishLoadHeldOnBlank: 0,
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 0,
|
||||
releasedEpochMs: null,
|
||||
@@ -128,6 +166,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
state.gatedEpochMs = now();
|
||||
state.gatedMethod = method;
|
||||
holdReadyToShowWhileBlank(this, state);
|
||||
holdDidFinishLoadWhileBlank(this, state);
|
||||
try {
|
||||
await this.webContents.loadURL(BLANK_URL);
|
||||
state.blankLoadedEpochMs = now();
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
|
||||
interface GateState {
|
||||
blankLoadedEpochMs: number | null;
|
||||
didFinishLoadHeldOnBlank: number;
|
||||
errors: string[];
|
||||
gatedEpochMs: number | null;
|
||||
gatedMethod: string | null;
|
||||
@@ -155,13 +156,13 @@ test('records a failed about:blank navigation and still loads after release', as
|
||||
function createEmittingBrowserWindow(log: string[]) {
|
||||
class EmittingBrowserWindow extends EventEmitter {
|
||||
url = '';
|
||||
webContents = {
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
getURL: () => this.url,
|
||||
loadURL: async (url: string) => {
|
||||
this.url = url;
|
||||
log.push(`webContents.loadURL:${url}`);
|
||||
},
|
||||
};
|
||||
});
|
||||
async loadFile(file: string): Promise<void> {
|
||||
this.url = `file:///${file}`;
|
||||
log.push(`loadFile:${file}`);
|
||||
@@ -201,6 +202,47 @@ test('holds ready-to-show while the window shows about:blank, then lets the real
|
||||
assert.equal(api.state.readyToShowHeldOnBlank, 1);
|
||||
});
|
||||
|
||||
test('keeps did-finish-load of about:blank from the app listeners, not from later ones', async () => {
|
||||
const log: string[] = [];
|
||||
const EmittingBrowserWindow = createEmittingBrowserWindow(log);
|
||||
const api = gateModule.installJourneyRendererGate(
|
||||
EmittingBrowserWindow as unknown as {
|
||||
prototype: Record<string, unknown>;
|
||||
},
|
||||
{},
|
||||
{ timeoutMs: 60_000 }
|
||||
);
|
||||
const window = new EmittingBrowserWindow();
|
||||
// The app shows its window at the first did-finish-load.
|
||||
window.webContents.once('did-finish-load', () =>
|
||||
log.push('app:did-finish-load')
|
||||
);
|
||||
const load = window.loadFile('index.html');
|
||||
await settle();
|
||||
// Registered after the gated load, like Electron's own listener that
|
||||
// resolves loadURL(about:blank).
|
||||
window.webContents.on('did-finish-load', () =>
|
||||
log.push('electron:did-finish-load')
|
||||
);
|
||||
window.webContents.emit('did-finish-load');
|
||||
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
|
||||
|
||||
api.release();
|
||||
await load;
|
||||
window.webContents.emit('did-finish-load');
|
||||
window.webContents.emit('did-finish-load');
|
||||
|
||||
assert.deepEqual(log, [
|
||||
'webContents.loadURL:about:blank',
|
||||
'electron:did-finish-load',
|
||||
'loadFile:index.html',
|
||||
'app:did-finish-load',
|
||||
'electron:did-finish-load',
|
||||
'electron:did-finish-load',
|
||||
]);
|
||||
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
|
||||
});
|
||||
|
||||
test('taps ipcMain.handle for the counters channel and passes registrations through', async () => {
|
||||
const registered: string[] = [];
|
||||
const ipcMain: FakeIpcMain = {
|
||||
|
||||
@@ -112,6 +112,7 @@ function measurement(
|
||||
blankLoadedEpochMs: 1_050,
|
||||
errors: [],
|
||||
gatedEpochMs: 1_020,
|
||||
didFinishLoadHeldOnBlank: 1,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
@@ -185,6 +186,7 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
|
||||
'main.startupPhases': 9,
|
||||
});
|
||||
assert.equal(record.evidence['rendererGateReadyToShowHeldOnBlank'], 1);
|
||||
assert.equal(record.evidence['rendererGateDidFinishLoadHeldOnBlank'], 1);
|
||||
assert.deepEqual(record.evidence['epochs'], {
|
||||
firstCard: 2_600.04,
|
||||
firstCardPaint: 2_650,
|
||||
|
||||
@@ -185,6 +185,8 @@ export function toLaunchIterationRecord(
|
||||
mainCountersAtRead: mainCounters.counters,
|
||||
rendererGateReadyToShowHeldOnBlank:
|
||||
measurement.gate.readyToShowHeldOnBlank,
|
||||
rendererGateDidFinishLoadHeldOnBlank:
|
||||
measurement.gate.didFinishLoadHeldOnBlank,
|
||||
ipcCallsByMethod: ipc.callsByMethod,
|
||||
ipcSerialDepth: serialDepth,
|
||||
ipcTimelineAmbiguousCompletions: ipc.ambiguousTimelineCompletions,
|
||||
|
||||
@@ -27,8 +27,9 @@ const setWindowState = (
|
||||
);
|
||||
|
||||
/**
|
||||
* The app creates its window with `show: false` and shows it on
|
||||
* `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`.
|
||||
* The app creates its window with `show: false` and shows it at
|
||||
* `ready-to-show` or `did-finish-load`, whichever comes first; a `hide()`
|
||||
* sent earlier would be undone by that `show()`.
|
||||
*/
|
||||
async function waitUntilShown(app: UnautomatedElectronApp): Promise<void> {
|
||||
await expect
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -89,7 +89,14 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
|
||||
show a blank window and freeze its `ready-to-show` counter before its own
|
||||
document exists. Electron emits the event again for the real document's
|
||||
first paint because the window is still hidden, which is the moment
|
||||
production sees. The gate also keeps the listener the app registers with
|
||||
production sees. The app also shows its window at the main frame's
|
||||
`did-finish-load` when that comes first (see
|
||||
[When the window is shown](#when-the-window-is-shown)), so the gate keeps
|
||||
the `did-finish-load` listeners registered before the gated load (the
|
||||
app's) away from the `about:blank` load as well
|
||||
(`evidence.rendererGateDidFinishLoadHeldOnBlank`, 1 per launch); Electron's
|
||||
own listener that resolves `loadURL('about:blank')` is registered later
|
||||
and still runs. The gate also keeps the listener the app registers with
|
||||
`ipcMain.handle('performance:read-counters')`, so the test can call it from
|
||||
the main process.
|
||||
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
|
||||
@@ -199,6 +206,12 @@ the playlist inventory has loaded. After the fix (macOS, 2026-09-30): both
|
||||
counters were 0 in all 12 iterations of two runs, every window closed on
|
||||
`quiet` and `lateShifts` was empty.
|
||||
|
||||
On the runner the flicker was only visible on J1's fast path: on the slow
|
||||
path the window got its first frame only after the hero had already
|
||||
changed, so the settle window opened after the shifts (see
|
||||
[When the window is shown](#when-the-window-is-shown)). With both fixes,
|
||||
all 18 iterations of three runner runs read 0 (`stable: true`).
|
||||
|
||||
#### Idle window
|
||||
|
||||
After the settle point J1 leaves the dashboard alone for
|
||||
@@ -469,6 +482,76 @@ waits for the playlist migrations, the inventory read and
|
||||
`reconcileEpgSources`. No baseline yet: the counter is promoted only after a
|
||||
PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3).
|
||||
|
||||
### When the window is shown
|
||||
|
||||
J1 on the CI runner was bimodal from the first runner measurements (#1717)
|
||||
until 2026-10-01: 6 of 14 `master` runs between 2026-09-30 and 2026-10-01
|
||||
mixed two paths. On the slow path the first card came with 18 bridge
|
||||
calls and 1,018 DOM mutations, about 940 ms after the load event. On the
|
||||
fast path it came with 15 calls and 559 mutations, 280-500 ms after it.
|
||||
The race also marked `renderer.ipcSerialDepthToFirstCard` (9 vs 6),
|
||||
`renderer.cdTicksToFirstCard` (31 vs 21), `renderer.cdTicksIdle30s`,
|
||||
`main.sqlStatementsBeforeReadyToShow` (119 vs 93) and
|
||||
`renderer.layoutShiftScoreSettled` as `stable: false`.
|
||||
|
||||
The three extra calls (`downloadsGetDefaultFolder` and two
|
||||
`dbGetGlobalRecentlyAdded`, after `dbGetAllGlobalFavorites`) were not what
|
||||
the card waited for. They only had time to finish before the card. What
|
||||
ordered the card was when the hidden window got a frame. In every one of
|
||||
the 48 iterations of those eight runs (two of them #1782's), `ready-to-show`
|
||||
came within 180 ms of the load event on the fast path (usually about 15 ms),
|
||||
and 4-5 ms after the first card on the slow path. The app showed its window only on
|
||||
`ready-to-show`, and `main.ts` removes the splash in a
|
||||
`requestAnimationFrame`, which the journey's end condition waits for. On
|
||||
the slow path the dashboard had rendered and its data had arrived, but the
|
||||
window was still hidden, no frame came, and the splash stayed.
|
||||
|
||||
A minimal Electron 43.3.0 app under Xvfb in a Debian container reproduces
|
||||
it deterministically. It has the same hidden window, splash and
|
||||
`requestAnimationFrame` removal, plus a 3.5 MB module script before the
|
||||
first frame. Its window got no frame for about a second after load, and the
|
||||
`requestAnimationFrame` and `ready-to-show` both landed at about 1.25 s, in
|
||||
5 of 5 launches. Without the large script, `ready-to-show` came at load. A
|
||||
`backgroundColor` alone changed nothing. Showing the window at
|
||||
`did-finish-load` made the `requestAnimationFrame` run on time in 5 of 5.
|
||||
#1782's skeleton gates do not touch this ordering: its own run 36917107231
|
||||
still had one fast iteration among slow ones.
|
||||
|
||||
The fix is in the app, so it applies to users and not only to the
|
||||
journey. `apps/electron-backend/src/app/services/main-window-first-show.ts`
|
||||
shows the window at `ready-to-show` or the main frame's `did-finish-load`,
|
||||
whichever comes first. The window's `backgroundColor` is the splash colour,
|
||||
so showing it before the first paint does not flash. `ready-to-show` still
|
||||
fires after the early show (on the runner 10-190 ms after load), so
|
||||
`main.sqlStatementsBeforeReadyToShow` keeps its meaning.
|
||||
|
||||
Validation (Principle 3, the same journey on the same runner): three
|
||||
dispatched runs of the fix (36928706097, 36928716010, 36928725392) and the
|
||||
run of the commit that added the baselines (36930457538) took the fast path
|
||||
in all 24 iterations, with 15 calls and 559 mutations each.
|
||||
|
||||
| Runs | Slow iterations | `spawnToFirstCardMs.p50` | load → card |
|
||||
| ----------------------------------------------------------- | --------------- | ------------------------- | ----------------------------- |
|
||||
| `master` and #1782, 2026-09-30 to 10-01 (8 runs, see above) | 29 of 40 | 1,478-1,613 ms (one 760) | ~940 ms slow, 280-500 ms fast |
|
||||
| this fix (4 runs) | 0 of 20 | 988, 1,139, 923, 1,205 ms | 360-515 ms |
|
||||
|
||||
The eight earlier runs are `master` 36768881838, 36814964563, 36842198653,
|
||||
36861129953, 36861409057 and 36915979562, and #1782's 36816552353 and
|
||||
36917107231. The runner's own speed moves `spawnToDidFinishLoadMs.p50` between 430 and
|
||||
710 ms from run to run, so compare load → card rather than absolute numbers.
|
||||
The one fast master run (36915979562, P50 760 ms) had a fast runner and four
|
||||
fast iterations.
|
||||
|
||||
The fix first merged (#1788) into #1782's branch after #1782 had already
|
||||
reached `master`, so it landed again on its own. Measured again on `master`
|
||||
at bc5a7fcbf, which by then carried the redesigned dashboard hero (#1792):
|
||||
three dispatched runs (37192092882, 37192097790, 37192103151) took the fast
|
||||
path in all 18 iterations, with 15 calls and 558 mutations each, 466-528 ms
|
||||
from load to the first card and `spawnToFirstCardMs.p50` 1,149, 1,122 and
|
||||
1,170 ms. `master` without the fix was still bimodal then: its last six push
|
||||
runs before 2738bc28a had 30 of 36 iterations on the slow path (18 calls,
|
||||
1,031-1,033 mutations, about 940 ms from load to the card).
|
||||
|
||||
### Summary schema
|
||||
|
||||
```json
|
||||
@@ -545,8 +628,9 @@ numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
|
||||
`tools/performance/journey-baselines.json`. The summary writer checks only
|
||||
that every measured iteration reports the same counter names with finite
|
||||
values, so a new counter needs no schema change. A J1 runtime baseline is added
|
||||
once its counter is deterministic on the CI runner; the launch counters are
|
||||
not yet (see [Ratchet](#ratchet)), so the summary is evidence only.
|
||||
once its counter is deterministic on the CI runner. Two are enforced
|
||||
(`renderer.ipcCallsToFirstCard` and `renderer.domMutationsToFirstCard`, see
|
||||
[Ratchet](#ratchet)); the other runtime counters are evidence only.
|
||||
|
||||
J3 adds the `journeys.playback` entry with the same shape and no schema
|
||||
version change: `counters` and `wallClock` hold only plain numbers, and its
|
||||
@@ -957,25 +1041,46 @@ Pushes to `master` and manual dispatches always run it. The job is warn-only (`c
|
||||
weeks (plan item B3): a regression marks the job failed without failing the
|
||||
workflow. Making it required is a maintainer decision.
|
||||
|
||||
No J1 runtime counter is enforced yet. Three dispatched runs on 2026-09-27
|
||||
(CI runs 36271875209, 36271879955 and 36271884616) reported the same summary
|
||||
values, `renderer.ipcCallsToFirstCard` 16 and
|
||||
`renderer.domMutationsToFirstCard` 939, but the third run marked both
|
||||
`stable: false`: its warm-up and one measured iteration reached the first
|
||||
card in about 750 ms with 13 bridge calls and 576 mutations, the others in
|
||||
about 1,400 ms with 16 and 939. The three extra calls
|
||||
(`downloadsGetDefaultFolder` and two `dbGetGlobalRecentlyAdded`) land before
|
||||
or after the first card depending on that race, so neither counter is
|
||||
promoted until the race is understood and the counters are deterministic.
|
||||
`renderer.layoutShiftScore` (0) and `renderer.longTasks` (2) were identical
|
||||
in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from
|
||||
1,401 to 1,674 ms. All four stay evidence for now. Runner counters also
|
||||
differ from a Mac (12 and 571 there, the fast path without the Linux-only
|
||||
`getWindowState` call), so take J1 baseline values from the runner only.
|
||||
`renderer.layoutShiftScoreSettled` has no baseline either: the runner read
|
||||
it as `stable: false` because the dashboard hero flicker it reported was a
|
||||
race there (see [Settle window](#settle-window)). That flicker is fixed; add
|
||||
the runner's number once runner runs read it as `stable` too.
|
||||
The job enforces two J1 runtime counters: `renderer.ipcCallsToFirstCard`
|
||||
(15 calls) and `renderer.domMutationsToFirstCard` (558 mutations). After the
|
||||
`Run the performance journeys` step it runs
|
||||
`check-journey-ratchet.mjs --only launch/renderer.ipcCallsToFirstCard --only launch/renderer.domMutationsToFirstCard`
|
||||
on the summary that step wrote. Both entries have `slack` 0 and
|
||||
`evidenceRun` 37192092882, and were identical and `stable: true` in all
|
||||
three dispatched runs of the fix that removed the launch race on `master`
|
||||
(see
|
||||
[When the window is shown](#when-the-window-is-shown)). The step is in the
|
||||
job, not in the composite action, so the weekly tightening still measures a
|
||||
run that would fail it. While the job is warn-only, a regression fails the
|
||||
job and not the workflow. The two summaries of #1782 before that fix (18
|
||||
and 1,018) fail the check.
|
||||
|
||||
Until that fix, J1 had two paths on the runner and no runtime counter could
|
||||
be enforced. Three dispatched runs on 2026-09-27 (36271875209, 36271879955
|
||||
and 36271884616) already showed both paths (16 calls / 939 mutations against
|
||||
13 / 576 at the time), and later `master` runs mixed them more often.
|
||||
|
||||
The other J1 counters in the same three runs:
|
||||
|
||||
| Counter | Value | `stable` in all three runs |
|
||||
| ------------------------------------- | ----- | ------------------------------------------------------------------------- |
|
||||
| `main.modulesRegisteredBeforeWindow` | 2 | yes |
|
||||
| `renderer.ipcSerialDepthToFirstCard` | 6 | yes (was unstable through the race) |
|
||||
| `renderer.cdTicksIdle30s` | 4 | yes (was unstable through the race) |
|
||||
| `renderer.layoutShiftScore` | 0 | yes |
|
||||
| `renderer.layoutShiftScoreSettled` | 0 | yes (#1782's hero fix plus this one) |
|
||||
| `renderer.longTasks` | 2 | yes |
|
||||
| `renderer.cdTicksToFirstCard` | 21 | no: one iteration of 36928725392 read 22 (and one of 36930457538 read 20) |
|
||||
| `main.sqlStatementsBeforeReadyToShow` | 95 | no: 93 or 95 in every run |
|
||||
|
||||
`renderer.cdTicksToFirstCard` keeps the one-tick race described under
|
||||
[change detection](#change-detection-ticks), which the Mac shows too (20 or
|
||||
21). `main.sqlStatementsBeforeReadyToShow` keeps the download and recording
|
||||
recovery racing `ready-to-show` (plan item A2). The six stable counters are
|
||||
candidates for further baselines once more runs agree. Wall-clock entries
|
||||
stay evidence. Runner counters still differ from a Mac (14 calls and 554
|
||||
mutations there; the missing call is the Linux-only `getWindowState`), so
|
||||
take J1 baseline values from the runner only.
|
||||
|
||||
### Weekly tightening
|
||||
|
||||
|
||||
@@ -293,7 +293,10 @@ on `master`; before that, see the temporary-trigger note under Weekly
|
||||
tightening in the performance journeys document. `perf:journeys` builds the `electron-performance` configuration and runs every
|
||||
journey spec against the Xtream mock: J1 launch, then J2 open-source (a
|
||||
second set of launches, each followed by the click on the portal card), both
|
||||
written to the same summary file; its probe specs run with
|
||||
written to the same summary file. The `Performance journeys` job of `ci.yml`
|
||||
(warn-only) checks `renderer.ipcCallsToFirstCard` and
|
||||
`renderer.domMutationsToFirstCard` of that summary against the baselines;
|
||||
its probe specs run with
|
||||
`pnpm nx run electron-backend-e2e:test-performance-harness`, which CI runs in
|
||||
the `Unit Tests and Typechecks` job of `ci.yml` on every run. The
|
||||
`electron-backend-e2e` command targets call `tsx` and `playwright` directly,
|
||||
|
||||
@@ -510,15 +510,24 @@ Startup window mode (`Settings.startupWindowMode`, issue #1455):
|
||||
3. `fullscreen` is the `BrowserWindow` constructor option: on Windows/Linux
|
||||
the window is created hidden and enters fullscreen before its first
|
||||
paint. macOS ignores the option while the window is hidden (an NSWindow
|
||||
only toggles fullscreen once it is on screen), so `ready-to-show` repeats
|
||||
only toggles fullscreen once it is on screen), so the first show repeats
|
||||
the request with `setFullScreen(true)` right after `show()` wherever
|
||||
`isFullScreen()` is still false — never unconditionally, or the
|
||||
platforms that honoured the option would animate a second toggle. The
|
||||
saved bounds stay spread into the options — they are the normal bounds
|
||||
the window returns to, and the close handler keeps persisting
|
||||
`getNormalBounds()`. `maximized` calls `maximize()` inside
|
||||
`ready-to-show` right before `show()`, never earlier: `maximize()` on a
|
||||
hidden window shows it, and a blank window would flash.
|
||||
`getNormalBounds()`. `maximized` calls `maximize()` right before the
|
||||
first `show()`, never earlier: `maximize()` on a hidden window shows it,
|
||||
and a blank window would flash. That first show happens at
|
||||
`ready-to-show` or the main frame's `did-finish-load`, whichever comes
|
||||
first (`services/main-window-first-show.ts`): on Linux a hidden window
|
||||
whose startup scripts ran before its first frame gets the next one about
|
||||
a second later, so `ready-to-show` alone left the window off screen and
|
||||
the splash's animation frame waiting. At `did-finish-load` the inline
|
||||
splash is parsed, and the window's `backgroundColor` is the splash colour
|
||||
(`MAIN_WINDOW_BACKGROUND_COLOR`, keep it in sync with `#initial-splash`
|
||||
in `apps/web/src/index.html`), so showing before the first paint does
|
||||
not flash.
|
||||
4. `iptvnator --fullscreen` (read via `app.commandLine.hasSwitch`, so it can
|
||||
sit anywhere in argv; the playlist-path extractor already skips every
|
||||
`-`-prefixed argument) forces `fullscreen` for that launch only and is
|
||||
|
||||
@@ -9,6 +9,24 @@
|
||||
"updatedAt": "2026-10-01",
|
||||
"evidencePr": 1775,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
},
|
||||
"renderer.ipcCallsToFirstCard": {
|
||||
"value": 15,
|
||||
"unit": "calls",
|
||||
"slack": 0,
|
||||
"updatedAt": "2026-10-04",
|
||||
"evidencePr": 1828,
|
||||
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37192092882",
|
||||
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs"
|
||||
},
|
||||
"renderer.domMutationsToFirstCard": {
|
||||
"value": 558,
|
||||
"unit": "mutations",
|
||||
"slack": 0,
|
||||
"updatedAt": "2026-10-04",
|
||||
"evidencePr": 1828,
|
||||
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37192092882",
|
||||
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user