diff --git a/.changes/electron-window-shown-on-load.md b/.changes/electron-window-shown-on-load.md new file mode 100644 index 000000000..49dc2ef01 --- /dev/null +++ b/.changes/electron-window-shown-on-load.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 967d2f03a..e1a028afb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts index 91387fc9d..61c0b2957 100644 --- a/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts +++ b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts @@ -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; diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts index e203d4540..2cbae397d 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts @@ -14,6 +14,7 @@ function gate( blankLoadedEpochMs: 1_050, errors: [], gatedEpochMs: 1_020, + didFinishLoadHeldOnBlank: 1, gatedMethod: 'loadFile', passThroughLoads: 0, readyToShowHeldOnBlank: 1, diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs index be80ae151..baca328c4 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs @@ -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(); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts index a6e44978d..8eb336dee 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts @@ -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 { 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; + }, + {}, + { 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 = { diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts index 02bfe3d21..f2084d099 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts @@ -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, diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts index 2349648fe..f0ec0b053 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts @@ -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, diff --git a/apps/electron-backend-e2e/src/window-visibility.e2e.ts b/apps/electron-backend-e2e/src/window-visibility.e2e.ts index ae323c541..777101efd 100644 --- a/apps/electron-backend-e2e/src/window-visibility.e2e.ts +++ b/apps/electron-backend-e2e/src/window-visibility.e2e.ts @@ -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 { await expect diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 7e597144d..f8f12e5bd 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -75,11 +75,14 @@ type MockMainWindow = { maximize: jest.Mock; on: jest.Mock void]>; once: jest.Mock void]>; + removeListener: jest.Mock void]>; setFullScreen: jest.Mock; setMenu: jest.Mock; show: jest.Mock; webContents: { on: jest.Mock void]>; + once: jest.Mock void]>; + removeListener: jest.Mock void]>; openDevTools: jest.Mock; setWindowOpenHandler: jest.Mock; getZoomLevel: jest.Mock; @@ -98,12 +101,18 @@ function createMockMainWindow(): MockMainWindow { maximize: jest.fn(), on: jest.fn void]>(), once: jest.fn void]>(), + removeListener: jest.fn void]>(), isDestroyed: jest.fn().mockReturnValue(false), setFullScreen: jest.fn(), setMenu: jest.fn(), show: jest.fn(), webContents: { on: jest.fn void]>(), + once: jest.fn void]>(), + removeListener: jest.fn< + void, + [string, (...args: unknown[]) => void] + >(), openDevTools: jest.fn(), setWindowOpenHandler: jest.fn(), getZoomLevel: jest.fn().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'); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 09b927e6c..0573c4bba 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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); } }); diff --git a/apps/electron-backend/src/app/services/main-window-first-show.spec.ts b/apps/electron-backend/src/app/services/main-window-first-show.spec.ts new file mode 100644 index 000000000..9ce24d815 --- /dev/null +++ b/apps/electron-backend/src/app/services/main-window-first-show.spec.ts @@ -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(); + }); +}); diff --git a/apps/electron-backend/src/app/services/main-window-first-show.ts b/apps/electron-backend/src/app/services/main-window-first-show.ts new file mode 100644 index 000000000..b7c0ff38d --- /dev/null +++ b/apps/electron-backend/src/app/services/main-window-first-show.ts @@ -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); +} diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 25ab024cf..830ffb2d5 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -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,66 @@ 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. + ### Summary schema ```json @@ -545,8 +618,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 +1031,45 @@ 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` (559 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` 36928706097, and were identical and `stable: true` in all +three dispatched runs of the fix that removed the launch race (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 diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index c380f3ee4..bd2fe853e 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -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, diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 44f4d2771..af2bc2a68 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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 diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index 56c319574..4ebb484cc 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -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-01", + "evidencePr": 1788, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/36928706097", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs" + }, + "renderer.domMutationsToFirstCard": { + "value": 559, + "unit": "mutations", + "slack": 0, + "updatedAt": "2026-10-01", + "evidencePr": 1788, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/36928706097", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs" } } }