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>
This commit is contained in:
4grayandClaude Opus 5.5 authored and 4gray committed 2026-10-05 22:56:10 +02:00
1 parent b782243760
commit 37ee8813a7
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.
+15
View File
@@ -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
+33
View File
@@ -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');
+17 -8
View File
@@ -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);
}
+127 -22
View File
@@ -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
+4 -1
View File
@@ -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,
+13 -4
View File
@@ -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
+18
View File
@@ -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": null,
"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": null,
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37192092882",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs"
}
}
}