From b315f8564d80a604f2bfdea76aaa9ef482585768 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:34:08 +0200 Subject: [PATCH 01/11] 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 * chore(perf): record the evidence PR of the J1 runtime baselines Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: 4gray --- .changes/electron-window-shown-on-load.md | 8 + .github/workflows/ci.yml | 15 ++ .../journeys/journey-renderer-gate-client.ts | 2 + .../journey-renderer-gate-client.spec.ts | 1 + .../src/performance/journey-renderer-gate.cjs | 39 +++++ .../performance/journey-renderer-gate.spec.ts | 46 +++++- .../performance/launch-journey-record.spec.ts | 2 + .../src/performance/launch-journey-record.ts | 2 + .../src/window-visibility.e2e.ts | 5 +- apps/electron-backend/src/app/app.spec.ts | 33 ++++ apps/electron-backend/src/app/app.ts | 25 ++- .../services/main-window-first-show.spec.ts | 71 +++++++++ .../app/services/main-window-first-show.ts | 52 ++++++ docs/architecture/performance-journeys.md | 149 +++++++++++++++--- docs/architecture/validation-map.md | 5 +- docs/architecture/workspace-shell.md | 17 +- tools/performance/journey-baselines.json | 18 +++ 17 files changed, 451 insertions(+), 39 deletions(-) create mode 100644 .changes/electron-window-shown-on-load.md create mode 100644 apps/electron-backend/src/app/services/main-window-first-show.spec.ts create mode 100644 apps/electron-backend/src/app/services/main-window-first-show.ts 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..984ec766a 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,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 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..205353e56 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-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" } } } From de2e1b19d7c56cdf856c20f0ef454c5b307acdb2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 6 Oct 2026 10:49:44 +0200 Subject: [PATCH 02/11] docs(website): publish player controls and library guides --- .../src/content/blog/library-organization-guide.mdx | 7 ++++--- apps/website/src/content/blog/player-controls-guide.mdx | 4 ++-- tools/testing/website-guides.test.mjs | 8 ++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/website/src/content/blog/library-organization-guide.mdx b/apps/website/src/content/blog/library-organization-guide.mdx index db4c04d76..a01bd14c6 100644 --- a/apps/website/src/content/blog/library-organization-guide.mdx +++ b/apps/website/src/content/blog/library-organization-guide.mdx @@ -1,16 +1,16 @@ --- title: "Keep Your Library Tidy: Sources, Favorites and Watched Titles" description: Review source availability, return from a favorite channel to its playlist, mark titles watched and simplify catalog views in IPTVnator without confusing hidden items with missing data. -pubDate: 2026-09-27 +pubDate: 2026-10-06 author: 4gray heroImage: /iptvnator/blog/feature-guides/screenshots/guide-library-watched-dark.png tags: - guide -draft: true +draft: false faq: - q: Does an unavailable source mean my channels were deleted? a: No. Availability describes a connection check. A timeout, a temporary outage or an unverified account is different from deleting a saved source or losing its channels. - - q: Does library watched state delete entries automatically? + - q: Does source cleanup delete entries automatically? a: No. The desktop cleanup dialog checks sources and lets you review the selection before deleting. Confirmed expired or disabled accounts can be preselected; uncertain results need review. - q: What happens when I delete a source? a: Its saved favorites, history and playback progress are removed with it. Downloaded files are kept. Export a playlist backup first if you want a restorable copy of supported source state. @@ -111,6 +111,7 @@ permanent labels are more useful than an extra row of posters. ## Related +- [Back up your playlists before removing sources](/iptvnator/blog/playlist-backup-restore-guide/) - [Movie and series metadata with TMDB](/iptvnator/blog/tmdb-metadata-guide/) - [Alternative movie sources](/iptvnator/blog/alternative-sources-guide/) - [Library changes in 0.24](/iptvnator/blog/v0-24-release-notes/) diff --git a/apps/website/src/content/blog/player-controls-guide.mdx b/apps/website/src/content/blog/player-controls-guide.mdx index 085b2a475..aa6686561 100644 --- a/apps/website/src/content/blog/player-controls-guide.mdx +++ b/apps/website/src/content/blog/player-controls-guide.mdx @@ -1,13 +1,13 @@ --- title: "Subtitles, Quality and Picture-in-Picture: Get More from the Player" description: Use IPTVnator's unified player controls to choose stream quality, load subtitles, adjust their timing and keep video visible in picture-in-picture. Learn why some options depend on the stream or player. -pubDate: 2026-09-27 +pubDate: 2026-10-06 author: 4gray heroImage: /iptvnator/blog/feature-guides/screenshots/guide-player-subtitles-dark.png tags: - guide - playback -draft: true +draft: false faq: - q: Why is there no quality menu? a: The menu appears when the stream exposes more than one video quality. A single video file or a channel with only one rendition has no alternatives for the player to select. diff --git a/tools/testing/website-guides.test.mjs b/tools/testing/website-guides.test.mjs index 97bd4e8f4..8ad84375a 100644 --- a/tools/testing/website-guides.test.mjs +++ b/tools/testing/website-guides.test.mjs @@ -15,6 +15,14 @@ const distRoot = new URL('../../dist/apps/website/', import.meta.url); const SITE = 'https://4gray.github.io/iptvnator'; const GUIDES = [ + { + slug: 'player-controls-guide', + screenshots: ['blog/feature-guides/screenshots/guide-player-subtitles-dark.png'], + }, + { + slug: 'library-organization-guide', + screenshots: ['blog/feature-guides/screenshots/guide-library-watched-dark.png'], + }, { slug: 'stable-nightly-updates-guide', screenshots: ['blog/feature-guides/screenshots/guide-update-channel-dark.png'], From 0a006cb02797a13c2d873e728bb6b254089cbfa3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:43:55 +0200 Subject: [PATCH 03/11] ci(perf): enforce the journey counters stable on master (#1829) * ci(perf): enforce the journey counters stable on master Promote the J1, J2 and J3 counters that were identical in all 55 measured iterations of the 11 master runs from 2026-10-03 to 2026-10-04 to journey-baselines.json, and check them in the Performance journeys job (still warn-only), in one step together with #1828's two validated J1 entries. None of the new ones has Principle 3 evidence, so each carries a "guard only, not validated" note that the checker prints with a failure. A performance-tools test keeps the job's --only list equal to the journey entries. Number formatting uses three decimals, the precision of the layout-shift scores. J2 renderer.layoutShiftScore is 0.233, not the window's 0.222: every master run from #1814 (page Back buttons in the header) on reads 0.233. Co-Authored-By: Claude Opus 5.5 * ci(perf): check the journey counters whenever a summary was written Review follow-up (Greptile): the check ran only after the composite action succeeded, so a failed job-summary report after a written summary.json skipped every baseline. It now runs unless the job was cancelled, as long as the action produced a summary path. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 25 ++- .github/workflows/performance-ratchet.yml | 2 +- docs/architecture/performance-journeys.md | 149 ++++++++++++------ docs/architecture/validation-map.md | 14 +- .../performance/check-baseline-direction.mjs | 3 +- tools/performance/check-journey-ratchet.mjs | 12 +- .../check-journey-ratchet.test.mjs | 68 ++++++++ tools/performance/journey-baselines.json | 84 ++++++++++ tools/performance/project.json | 1 + tools/performance/tighten-baselines.mjs | 3 +- tools/performance/tighten-baselines.test.mjs | 60 +++++++ 11 files changed, 357 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1a028afb..08f56981e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -327,12 +327,17 @@ jobs: 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 + # Only the counters identical in every measured iteration of + # recent master runs; their entries say whether a counter is + # validated against wall-clock or a guard only (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. tools/performance tests keep this list equal + # to the journey entries of journey-baselines.json. It also runs + # when a later step of the action (the job-summary report) failed + # after the summary was written, so the counters are still checked. + - name: Check the journey counters against the baselines + if: ${{ !cancelled() && steps.journeys.outputs.summary != '' }} env: SUMMARY: ${{ steps.journeys.outputs.summary }} run: >- @@ -340,6 +345,14 @@ jobs: --summary "$SUMMARY" --only launch/renderer.ipcCallsToFirstCard --only launch/renderer.domMutationsToFirstCard + --only launch/main.modulesRegisteredBeforeWindow + --only launch/renderer.layoutShiftScore + --only launch/renderer.layoutShiftScoreSettled + --only open-source/main.mockHttpRequestsToSettled + --only open-source/renderer.ipcCallsToFirstPage + --only open-source/renderer.layoutShiftScore + --only playback/renderer.httpRequestsToPlaying + --only playback/renderer.layoutShiftScore - name: Upload journey summaries if: always() diff --git a/.github/workflows/performance-ratchet.yml b/.github/workflows/performance-ratchet.yml index 39e3a0c4d..2f77794bb 100644 --- a/.github/workflows/performance-ratchet.yml +++ b/.github/workflows/performance-ratchet.yml @@ -240,7 +240,7 @@ jobs: git diff "$HEAD_SHA" HEAD -- "$baselines" echo '```' echo - echo "If \`master\` moved since \`$HEAD_SHA\`, make sure the Initial bytes ratchet job passes on this PR before merging." + echo "If \`master\` moved since \`$HEAD_SHA\`, make sure the Initial bytes ratchet and Performance journeys jobs pass on this PR before merging." } > "$BODY" gh api -X PATCH "repos/$REPOSITORY/pulls/$pr" -F "body=@$BODY" --silent echo "Pull request: ${GITHUB_SERVER_URL}/$REPOSITORY/pull/$pr" diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 984ec766a..d16fb4f50 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -627,10 +627,10 @@ runs before 2738bc28a had 30 of 36 iterations on the slow path (18 calls, 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. Two are enforced -(`renderer.ipcCallsToFirstCard` and `renderer.domMutationsToFirstCard`, see -[Ratchet](#ratchet)); the other runtime counters are evidence only. +values, so a new counter needs no schema change. A runtime baseline is added +once its counter is deterministic on the CI runner; the enforced ones and the +reasons for the others are under +[Enforced journey counters](#enforced-journey-counters). J3 adds the `journeys.playback` entry with the same shape and no schema version change: `counters` and `wallClock` hold only plain numbers, and its @@ -864,8 +864,9 @@ mutations come from the EPG timeline rendering about 240 programme blocks from the `get_simple_data_table` response before the first frame. Whether that response and its render land before `playing` is a race on a slower machine, so check the runner's `counterStability` before trusting the -mutation and request counts. No J3 baseline exists yet; J3 counters join the -ratchet once three runner runs agree. +mutation and request counts. On the runner `renderer.httpRequestsToPlaying` +and `renderer.layoutShiftScore` are enforced as guards; see +[Enforced journey counters](#enforced-journey-counters). ## `renderer.initialBytes` @@ -943,7 +944,10 @@ that file: - a measurement below its baseline passes and prints a "tighten" hint; - a measured counter without a baseline is noted, not failed; - checking nothing fails: an empty baselines file, or `--only` naming an - entry that does not exist, cannot exit 0. + entry that does not exist, cannot exit 0; +- an optional `note` (a string) is printed with the entry's failure; journey + entries use it to mark a guard that is not validated against wall-clock + (see [Enforced journey counters](#enforced-journey-counters)). `--only /` (repeatable) restricts the check to the named baselines. A script that measures one counter writes its own summary file @@ -1041,46 +1045,85 @@ 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. -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. +After the `Run the performance journeys` step, the job runs +`check-journey-ratchet.mjs --only …` on the summary that step wrote, for the +journey entries of `journey-baselines.json` (every entry except +`renderer.initialBytes`, which the `Initial bytes ratchet` job checks). A +`performance-tools` test keeps that `--only` list equal to those entries, so +a baseline cannot be added without being enforced. 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. -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. +#### Enforced journey counters -The other J1 counters in the same three runs: +Two J1 entries are validated (Principle 3) and carry no note: +`launch/renderer.ipcCallsToFirstCard` 15 and +`launch/renderer.domMutationsToFirstCard` 558 (#1828, `evidenceRun` +37192092882). #1828 removed the launch race (the window shown at +`did-finish-load`, see [When the window is shown](#when-the-window-is-shown)): +three dispatched runs on `master` read 15 / 558 in all 18 iterations, and +load to the first card went from about 940 ms to 466-528 ms. Slow-path +summaries (18 / 1,018 or more) fail the check. -| 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 | +A counter is enforced once it was identical in every measured iteration of +every recent `master` run. The other entries were identical in all 55 +measured iterations of the 11 `master` runs from 2026-10-03 08:25 to +2026-10-04 06:55 (CI runs 37109621784 to 37184230956), with `slack` 0: -`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. +| Entry | Value | Week (69 runs since 2026-09-27) | +| -------------------------------------------- | ----- | ------------------------------------------------------------------------- | +| `launch/main.modulesRegisteredBeforeWindow` | 2 | identical | +| `launch/renderer.layoutShiftScore` | 0 | identical | +| `launch/renderer.layoutShiftScoreSettled` | 0 | 0.235 in some iterations of 8 runs up to 2026-10-02 (hero flicker, #1782) | +| `open-source/main.mockHttpRequestsToSettled` | 1 | identical | +| `open-source/renderer.ipcCallsToFirstPage` | 17 | identical | +| `open-source/renderer.layoutShiftScore` | 0.233 | 0.221, then 0.222; 0.233 since #1814, never mixed within a run | +| `playback/renderer.httpRequestsToPlaying` | 2 | identical | +| `playback/renderer.layoutShiftScore` | 0.001 | identical | + +`open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233 +in every iteration of every `master` run from 84aef83a6 (#1814, page Back +buttons moved into the header) on, so its value is 0.233 with `evidenceRun` +37372780064 (b78224376). The 0.011 that #1814 added is not explained yet; +lowering it back is a separate change. + +Being deterministic is not the same as being validated. Principle 3 of the +plan promotes a counter to a guardrail once a PR has shown that lowering it +lowered the journey's wall-clock. None of these counters has that evidence +yet: `main.modulesRegisteredBeforeWindow` waits for the deferred IPC +registration (plan item C4), the layout-shift scores measure visual +stability rather than time, and no PR has moved a J2 or J3 counter. Each +entry therefore carries +`"note": "guard only, not validated: …"`. A guard stops a regression of a +deterministic number, and the checker prints the note with a failure; it +says nothing about whether lowering that number makes the journey faster. +When a PR shows that link, it drops the note and names the evidence in +`evidencePr`. `renderer.initialBytes` predates the note and carries none; +this document records no wall-clock change for it either. + +Not enforced, with the reason: + +- J1 `renderer.ipcSerialDepthToFirstCard` (6): bimodal on `master` until + #1828 (9 or 6) and identical in its three dispatched runs; a candidate + once `master` runs agree. +- J1 `main.sqlStatementsBeforeReadyToShow`: 93 or 95 even without the launch + race, because the download and recording recovery races `ready-to-show` + (plan item A2). +- Every `renderer.longTasks` (J1 2 or 1, J2 0 with one 1 earlier in the + week, J3 1 or 2): a long task is a task over 50 ms, so the count follows + runner speed, not work. +- Every `cdTicks` counter: the zoneless migration (plan item C6) changes + them. +- J2 `renderer.domMutationsToFirstPage`: 1,602 or 1,603 between runs of + recent commits. +- J3 `renderer.ipcCallsToPlaying` (4 or 5) and + `renderer.domMutationsToPlaying` (6,182, 6,183 or 6,199): not identical, + and the EPG rendering work changes the mutation count. +- J4: not measured yet. + +Runner counters differ from a Mac (the Linux-only `getWindowState` call, for +one), so take every journey baseline value from the runner only. ### Weekly tightening @@ -1105,7 +1148,11 @@ its job; its entries are then unmeasured in that run. A final job runs `check-baseline-direction.mjs` without `--allow-increase`, which both the script and the job check; - a lowered entry gets `updatedAt`, `measuredWith` and `evidenceRun` (the - workflow run URL); `evidencePr` is set to the tightening PR once it exists. + workflow run URL); `evidencePr` is set to the tightening PR once it exists; + every other field, including a `note`, is kept, so a guard stays marked as + not validated after it is lowered; +- a counter already at 0 is never lowered; a layout-shift score is lowered + to the three-decimal value the summary reports. When the file changed and the run is on `master`, the job pushes `automation/performance-ratchet` and opens (or updates) a pull request with @@ -1124,8 +1171,10 @@ dispatches workflows that exist on the default branch, so before the first merge of a new or renamed workflow add a temporary `push` trigger for the branch and drop it before review, as #1760 did. Review the pull request like a manual tightening: if `master` moved since the measured commit, the -`Initial bytes ratchet` job on the pull request is what shows that the new -value still holds (the concurrent-merge effect above). +`Initial bytes ratchet` job (for `renderer.initialBytes`) and the +`Performance journeys` job (for the journey counters) on the pull request +are what show that the new values still hold (the concurrent-merge effect +above). ## Charset parse benchmark @@ -1172,7 +1221,13 @@ reports slow imports of non-Latin playlists. 3. Cover the extraction and the failure modes with `node --test` and register the test file in `tools/performance/project.json`. 4. Validate the counter before it becomes a guardrail: one PR must show that - lowering it moved wall-clock in the same journey. + lowering it moved wall-clock in the same journey. A counter that is + deterministic but not validated may be enforced as a guard: its baseline + entry carries a `note` saying so (see + [Enforced journey counters](#enforced-journey-counters)). +5. A journey counter's baseline is enforced only when it is also in the + `--only` list of the `Performance journeys` job in `ci.yml`; the + `performance-tools` tests fail when the two differ. ## Adding a journey diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index bd2fe853e..4ee3ee303 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -276,7 +276,7 @@ pnpm nx build web pnpm run perf:initial-bytes # breakdown only pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline pnpm nx test performance-tools -pnpm run perf:journeys # J1 launch + J2 open-source journeys, one dist/performance/journeys//summary.json +pnpm run perf:journeys # J1 launch, J2 open-source and J3 playback journeys, one dist/performance/journeys//summary.json ``` `perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the @@ -285,7 +285,12 @@ bytes on the initial path (the J1 counter `renderer.initialBytes`). `tools/performance/journey-baselines.json`; baselines only move down. CI runs the same check in the `Initial bytes ratchet` job of `ci.yml` for PRs that target `master` and for `master` pushes (dispatch it with -`gh workflow run ci.yml --ref ` for a stacked branch). The weekly +`gh workflow run ci.yml --ref ` for a stacked branch). The +`Performance journeys` job checks the journey counters listed with `--only` +in its `Check the journey counters against the baselines` step against the +same file (warn-only); a new journey baseline +must be added to that list too, which `pnpm nx test performance-tools` +checks. The weekly `performance-ratchet.yml` workflow lowers baselines through a bot PR; validate a change to it with `gh workflow run performance-ratchet.yml --ref `, which measures but opens no PR off `master`. Dispatch needs the workflow file @@ -293,10 +298,7 @@ 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. 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 +written to the same summary file; 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/tools/performance/check-baseline-direction.mjs b/tools/performance/check-baseline-direction.mjs index 41816767c..e5a0ada85 100644 --- a/tools/performance/check-baseline-direction.mjs +++ b/tools/performance/check-baseline-direction.mjs @@ -49,8 +49,9 @@ function entries(baselines) { return flat; } +// Three decimals: journey summaries round layout-shift scores to three. function formatNumber(value) { - return value.toLocaleString('en-US', { maximumFractionDigits: 2 }); + return value.toLocaleString('en-US', { maximumFractionDigits: 3 }); } /** diff --git a/tools/performance/check-journey-ratchet.mjs b/tools/performance/check-journey-ratchet.mjs index b03bd70eb..3459b5506 100644 --- a/tools/performance/check-journey-ratchet.mjs +++ b/tools/performance/check-journey-ratchet.mjs @@ -14,6 +14,9 @@ * are lowered by hand, with the measured output as evidence, never raised. * - Checking nothing is a failure: an empty baselines file, or `--only` * naming an entry that does not exist, must not exit 0. + * - An optional `note` (a string) says why an entry is enforced, such as a + * guard whose link to wall-clock is not validated (Principle 3). It is + * printed with a failure, so the author sees what the counter stands for. * * `--only /` (repeatable) restricts the check to the named * baselines, so a script that measures one counter can check that counter @@ -34,10 +37,11 @@ export const DEFAULT_BASELINES_PATH = /** The PR label that lets check-baseline-direction.mjs accept a weakening. */ export const BASELINE_INCREASE_LABEL = 'perf-baseline-increase'; +// Three decimals: journey summaries round layout-shift scores to three. function formatNumber(value) { return Number.isInteger(value) ? value.toLocaleString('en-US') - : value.toLocaleString('en-US', { maximumFractionDigits: 2 }); + : value.toLocaleString('en-US', { maximumFractionDigits: 3 }); } function isPlainObject(value) { @@ -82,6 +86,9 @@ export function validateBaselines(baselines) { `Baseline ${label} has an invalid "slack" (must be an integer >= 0).` ); } + if (entry.note !== undefined && typeof entry.note !== 'string') { + throw new Error(`Baseline ${label} has a non-string "note".`); + } if ( entry.slack !== undefined && entry.toleranceRatio !== undefined @@ -185,8 +192,9 @@ export function compareToBaselines({ baselines, summary, only = [] }) { const limitText = describeLimit(entry, unit); if (measured > limit) { + const note = entry.note ? ` Note: ${entry.note}` : ''; result.failures.push( - `${label}: ${formatNumber(measured)}${unit} exceeds ${limitText} by ${formatNumber(measured - limit)}${unit}. Bring the value back down; baselines only move down. If the growth is a deliberate trade-off, raise the baseline in ${DEFAULT_BASELINES_PATH}, make the case in the PR, and ask a maintainer to add the ${BASELINE_INCREASE_LABEL} label.` + `${label}: ${formatNumber(measured)}${unit} exceeds ${limitText} by ${formatNumber(measured - limit)}${unit}. Bring the value back down; baselines only move down. If the growth is a deliberate trade-off, raise the baseline in ${DEFAULT_BASELINES_PATH}, make the case in the PR, and ask a maintainer to add the ${BASELINE_INCREASE_LABEL} label.${note}` ); } else if (entry.slack && measured > entry.value) { result.passed.push( diff --git a/tools/performance/check-journey-ratchet.test.mjs b/tools/performance/check-journey-ratchet.test.mjs index dd3a44cf7..2b4a04c08 100644 --- a/tools/performance/check-journey-ratchet.test.mjs +++ b/tools/performance/check-journey-ratchet.test.mjs @@ -21,6 +21,10 @@ const committedBaselinesPath = fileURLToPath( new URL('./journey-baselines.json', import.meta.url) ); +const ciWorkflowPath = fileURLToPath( + new URL('../../.github/workflows/ci.yml', import.meta.url) +); + const baselines = { version: 1, journeys: { @@ -357,6 +361,49 @@ test('rejects malformed baseline files', () => { }), /sets both "slack" and "toleranceRatio"/ ); + assert.throws( + () => + validateBaselines({ + journeys: { launch: { x: { value: 1, note: true } } }, + }), + /non-string "note"/ + ); +}); + +test('a failing entry prints its note; a fractional score is exact', () => { + const guarded = { + journeys: { + 'open-source': { + 'renderer.layoutShiftScore': { + value: 0.222, + unit: 'score', + slack: 0, + note: 'guard only, not validated', + }, + }, + }, + }; + const check = (score) => + compareToBaselines({ + baselines: guarded, + summary: { + journeys: { + 'open-source': { + counters: { 'renderer.layoutShiftScore': score }, + }, + }, + }, + }); + + assert.deepEqual(check(0.222).failures, []); + const above = check(0.223); + assert.equal(above.failures.length, 1); + assert.match( + above.failures[0], + /0\.223 score exceeds baseline 0\.222 by 0\.001 score/ + ); + assert.match(above.failures[0], /Note: guard only, not validated$/); + assert.equal(check(0.221).tightenable.length, 1); }); test('formats a summary line for both outcomes', () => { @@ -444,6 +491,27 @@ test('the committed baselines file is valid and every entry names its evidence f } }); +test('the Performance journeys job checks every journey-run baseline', async () => { + const committed = JSON.parse( + await readFile(committedBaselinesPath, 'utf8') + ); + const workflow = await readFile(ciWorkflowPath, 'utf8'); + const step = workflow.match( + /- name: Check the journey counters against the baselines\n([\s\S]*?)(?=\n\s*- name: )/ + ); + assert.ok(step, 'ci.yml must keep the journey counter check step'); + const only = [...step[1].matchAll(/--only (\S+)/g)].map((m) => m[1]); + // renderer.initialBytes is measured from the web build by the Initial + // bytes ratchet job (perf:initial-bytes:check); every other baseline comes + // from the journeys and must be enforced by this step, or it guards nothing. + const expected = Object.entries(committed.journeys) + .flatMap(([journey, entries]) => + Object.keys(entries).map((name) => `${journey}/${name}`) + ) + .filter((label) => label !== 'launch/renderer.initialBytes'); + assert.deepEqual([...only].sort(), [...expected].sort()); +}); + async function runCli(summary, extraBaselines = baselines) { const summaryPath = path.join( workDir, diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index 205353e56..6b5cd01ee 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -27,6 +27,90 @@ "evidencePr": 1828, "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37192092882", "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs" + }, + "main.modulesRegisteredBeforeWindow": { + "value": 2, + "unit": "phases", + "slack": 0, + "note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)", + "updatedAt": "2026-10-04", + "evidencePr": null, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04" + }, + "renderer.layoutShiftScore": { + "value": 0, + "unit": "score", + "slack": 0, + "note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)", + "updatedAt": "2026-10-04", + "evidencePr": null, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04" + }, + "renderer.layoutShiftScoreSettled": { + "value": 0, + "unit": "score", + "slack": 0, + "note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)", + "updatedAt": "2026-10-04", + "evidencePr": null, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04" + } + }, + "open-source": { + "main.mockHttpRequestsToSettled": { + "value": 1, + "unit": "requests", + "slack": 0, + "note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)", + "updatedAt": "2026-10-04", + "evidencePr": null, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04" + }, + "renderer.ipcCallsToFirstPage": { + "value": 17, + "unit": "calls", + "slack": 0, + "note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)", + "updatedAt": "2026-10-04", + "evidencePr": null, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04" + }, + "renderer.layoutShiftScore": { + "value": 0.233, + "unit": "score", + "slack": 0, + "note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)", + "updatedAt": "2026-10-06", + "evidencePr": null, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37372780064", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in every measured iteration of the 7 master runs from 84aef83a6 (#1814, which moved it from 0.222) to b78224376" + } + }, + "playback": { + "renderer.httpRequestsToPlaying": { + "value": 2, + "unit": "requests", + "slack": 0, + "note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)", + "updatedAt": "2026-10-04", + "evidencePr": null, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04" + }, + "renderer.layoutShiftScore": { + "value": 0.001, + "unit": "score", + "slack": 0, + "note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)", + "updatedAt": "2026-10-04", + "evidencePr": null, + "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956", + "measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04" } } } diff --git a/tools/performance/project.json b/tools/performance/project.json index 106390896..9f0e3936a 100644 --- a/tools/performance/project.json +++ b/tools/performance/project.json @@ -11,6 +11,7 @@ "inputs": [ "{projectRoot}/*.mjs", "{projectRoot}/*.json", + "{workspaceRoot}/.github/workflows/ci.yml", { "externalDependencies": ["parse5"] } ], "options": { diff --git a/tools/performance/tighten-baselines.mjs b/tools/performance/tighten-baselines.mjs index 0c82bf09e..8ae7786d6 100644 --- a/tools/performance/tighten-baselines.mjs +++ b/tools/performance/tighten-baselines.mjs @@ -54,8 +54,9 @@ function isPlainObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } +// Three decimals: journey summaries round layout-shift scores to three. function formatNumber(value) { - return value.toLocaleString('en-US', { maximumFractionDigits: 2 }); + return value.toLocaleString('en-US', { maximumFractionDigits: 3 }); } /** diff --git a/tools/performance/tighten-baselines.test.mjs b/tools/performance/tighten-baselines.test.mjs index 4c3542719..ccd3e5b25 100644 --- a/tools/performance/tighten-baselines.test.mjs +++ b/tools/performance/tighten-baselines.test.mjs @@ -93,6 +93,66 @@ test('a counter below its value in every run drops to the largest run', () => { assert.match(direction.lowered[0], /1,064 -> 1,059 bytes/); }); +test('a journey-run entry keeps its note and is lowered to three decimals', () => { + const score = { + value: 0.222, + unit: 'score', + slack: 0, + note: 'guard only, not validated', + updatedAt: '2026-10-04', + evidencePr: 1, + measuredWith: 'pnpm run perf:journeys', + }; + const baselines = { + version: 1, + journeys: { + ...baselinesFile().journeys, + 'open-source': { 'renderer.layoutShiftScore': score }, + playback: { 'renderer.layoutShiftScore': { ...score, value: 0 } }, + }, + }; + // The weekly job merges each runner's initial-bytes and journey summaries. + const runs = [0.221, 0.22, 0.221].map((value, index) => + mergeRunSummaries( + [ + bytesRun(1000), + { + journeys: { + 'open-source': { + counters: { 'renderer.layoutShiftScore': value }, + }, + playback: { + counters: { 'renderer.layoutShiftScore': 0 }, + }, + }, + }, + ], + `run ${index + 1}` + ) + ); + const result = tighten(baselines, runs); + assert.deepEqual( + result.baselines.journeys['open-source']['renderer.layoutShiftScore'], + { + ...score, + value: 0.221, + updatedAt: '2026-10-05', + measuredWith: + '.github/workflows/performance-ratchet.yml, max of 3 runs', + evidenceRun: RUN_URL, + } + ); + assert.deepEqual( + result.baselines.journeys.playback, + baselines.journeys.playback, + 'a counter already at 0 is kept' + ); + assert.match( + formatReport(result), + /\| `open-source\/renderer\.layoutShiftScore` \| 0\.222 score \| 0\.221 \| 0\.22 \| 0\.221 \| lowered to 0\.221 score \|/ + ); +}); + test('one run at or above the value keeps the baseline untouched', () => { for (const measured of [1000, 1010]) { const baselines = baselinesFile(); From e998d7418a60d02af91dd4b9d17cf4cdf6d7f7c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:59:54 +0200 Subject: [PATCH 04/11] chore(deps): bump the actions-minor-patch group across 1 directory with 2 updates (#1840) * chore(deps): bump the actions-minor-patch group across 1 directory with 2 updates Bumps the actions-minor-patch group with 2 updates in the / directory: [pnpm/action-setup](https://github.com/pnpm/action-setup) and [github/codeql-action](https://github.com/github/codeql-action). Updates `pnpm/action-setup` from 6.0.10 to 6.1.0 - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v6.0.10...v6.1.0) Updates `github/codeql-action` from 4.37.7 to 4.38.2 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.7...v4.38.2) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.38.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch - dependency-name: pnpm/action-setup dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] * test(packaging): allow pnpm/action-setup v6.1.0 in the Snap build workflow policy The bump moves every workflow to pnpm/action-setup@v6.1.0; the build workflow's allowlist pins the exact version and must move with it. Co-Authored-By: Claude Opus 5.5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .github/workflows/build-and-make.yaml | 2 +- .github/workflows/ci.yml | 8 ++++---- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/deploy-website.yml | 2 +- .github/workflows/e2e-tests.yaml | 4 ++-- .github/workflows/performance-ratchet.yml | 2 +- tools/packaging/snap-workflow-policy.test-helpers.mjs | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index e31a370fc..3036c9153 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -416,7 +416,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08f56981e..df41ce995 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: fetch-depth: 0 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -140,7 +140,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -310,7 +310,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -377,7 +377,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 74e397a8f..93a615c45 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -54,7 +54,7 @@ jobs: # commit checked out above (the modern default); JavaScript is # interpreted, so no build step is needed before analysis. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.7 + uses: github/codeql-action/init@v4.38.2 with: languages: ${{ matrix.language }} # Excludes the localhost dev/E2E mock servers from analysis; see the @@ -66,4 +66,4 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.7 + uses: github/codeql-action/analyze@v4.38.2 diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index a8cf6f076..f6e7dec04 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index d3fb6874b..42bbd8f98 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -67,7 +67,7 @@ jobs: - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -201,7 +201,7 @@ jobs: - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/performance-ratchet.yml b/.github/workflows/performance-ratchet.yml index 2f77794bb..857240120 100644 --- a/.github/workflows/performance-ratchet.yml +++ b/.github/workflows/performance-ratchet.yml @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index 28d9f5d84..f66e88544 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -72,7 +72,7 @@ const BUILD_ACTION_ALLOWLIST = Object.freeze([ 'actions/download-artifact@v8', 'actions/setup-node@v7', 'actions/upload-artifact@v7', - 'pnpm/action-setup@v6.0.10', + 'pnpm/action-setup@v6.1.0', 'softprops/action-gh-release@v3', ]); const VERIFY_JOB_ID = 'verify-snap'; From d73bb7ca16bb7d2dfff49a96f0d9c41a86f4873f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:00:33 +0200 Subject: [PATCH 05/11] chore(deps): bump shaka-player from 5.2.4 to 5.2.12 (#1836) * chore(deps): bump shaka-player from 5.2.4 to 5.2.12 Bumps [shaka-player](https://github.com/shaka-project/shaka-player) from 5.2.4 to 5.2.12. - [Release notes](https://github.com/shaka-project/shaka-player/releases) - [Changelog](https://github.com/shaka-project/shaka-player/blob/v5.2.12/CHANGELOG.md) - [Commits](https://github.com/shaka-project/shaka-player/compare/v5.2.4...v5.2.12) --- updated-dependencies: - dependency-name: shaka-player dependency-version: 5.2.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * test(playback): accept the Shaka 5.2.12 error contract 5.2.12 keeps the public error severities, categories, codes and request types of 5.2.4, so the version lock moves to v5.2.12 with the same accepted contract. Co-Authored-By: Claude Opus 5.5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- docs/architecture/embedded-inline-playback.md | 2 +- docs/architecture/m3u-playlist-module.md | 2 +- .../lib/diagnostics/shaka-error-contract.ts | 6 +-- .../shaka-playback-evidence.util.spec.ts | 2 +- .../shaka-playback-evidence.util.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 41 ++++++++++++++----- 7 files changed, 38 insertions(+), 19 deletions(-) diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index dca9795c3..3c19e5a58 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -695,7 +695,7 @@ external-player workflows; it is not copied from the HLS error payload into the evidence or technical details. HLS startup development logs are event-only: they do not include provider-supplied channel names or source URLs. -Shaka Player `5.2.4` errors cross a separate structured boundary before the +Shaka Player `5.2.12` errors cross a separate structured boundary before the HTML5 or ArtPlayer DASH session emits a diagnostic. Version-locked tests assert the installed Shaka version plus the public `Severity`, `Category`, and selected online-playback `Code` values used by the boundary. Evidence retains only diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 585ec4245..011fd5760 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1513,7 +1513,7 @@ does not change the saved player preference. Clear DASH needs this routing too. engine: lazy `import('shaka-player')` on first use (the module is a separate lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation queue + generation guard against channel-switch races. The DOM-free Shaka - `5.2.4` public-error boundary lives in `libs/playback/util`; it version-locks + `5.2.12` public-error boundary lives in `libs/playback/util`; it version-locks its allowlisted severity/category/code values, emits only structured sanitized `PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events, diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts index 5624dc09c..82c2ff85f 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts @@ -1,10 +1,10 @@ /** - * Public Shaka error values audited against the locked 5.2.4 runtime. + * Public Shaka error values audited against the locked 5.2.12 runtime. * * Keep the version assertion in the contract spec: a Shaka upgrade must stop * here for a new audit instead of silently accepting new error layouts. */ -export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.4'; +export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.12'; export const SHAKA_ERROR_SEVERITY = { RECOVERABLE: 1, @@ -123,7 +123,7 @@ export const SHAKA_ERROR_CODE = { MISSING_EME_SUPPORT: 6020, LOAD_INTERRUPTED: 7000, } as const; -/** Shaka 5.2.4 public NetworkingEngine request types used by diagnostics. */ +/** Shaka 5.2.12 public NetworkingEngine request types used by diagnostics. */ export const SHAKA_REQUEST_TYPE = { MANIFEST: 0, SEGMENT: 1, diff --git a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts index 2e0b61ca3..737e2b50b 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts @@ -65,7 +65,7 @@ describe('Shaka playback evidence', () => { } ); - it('matches the installed public Shaka 5.2.4 error contract', () => { + it('matches the installed public Shaka 5.2.12 error contract', () => { const installed = getInstalledShakaContract(); expect(installed.requestTypes).toEqual( expect.objectContaining(SHAKA_REQUEST_TYPE) diff --git a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts index 33a3e345d..3a924778c 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts @@ -51,7 +51,7 @@ export function createShakaPlaybackEvidence( return httpStatus === undefined ? evidence : { ...evidence, httpStatus }; } -/** Public error.data request-type slots in Shaka 5.2.4; no URL inspection. */ +/** Public error.data request-type slots in Shaka 5.2.12; no URL inspection. */ function getNetworkStage(error: Partial | null | undefined) { if ( error?.category !== SHAKA_ERROR_CATEGORY.NETWORK || diff --git a/package.json b/package.json index 77a6d364f..6a3fe916a 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "proxy-from-env": "2.1.0", "rxjs": "7.8.2", "saxes": "6.0.0", - "shaka-player": "5.2.4", + "shaka-player": "5.2.12", "shell-path": "3.1.0", "video.js": "8.24.0", "videojs-contrib-quality-levels": "4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 333c06d8b..93e5f04d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,8 +176,8 @@ importers: specifier: 6.0.0 version: 6.0.0 shaka-player: - specifier: 5.2.4 - version: 5.2.4 + specifier: 5.2.12 + version: 5.2.12 shell-path: specifier: 3.1.0 version: 3.1.0 @@ -1593,8 +1593,8 @@ packages: cpu: [x64] os: [win32] - '@bufbuild/protobuf@2.15.0': - resolution: {integrity: sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA==} + '@bufbuild/protobuf@2.16.0': + resolution: {integrity: sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==} '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} @@ -6276,6 +6276,10 @@ packages: resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} engines: {node: '>=14.14'} + fs-extra@11.4.1: + resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -8848,8 +8852,9 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shaka-player@5.2.4: - resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==} + shaka-player@5.2.12: + resolution: {integrity: sha512-vEsuVDI3+oLh+yqpjXexQZlg2XMUBI0VrqatDuKWacPaxAud6Uph5uCowUPLhUgNG5XKWCpoM4l1r8qYzWH+zg==} + engines: {node: '>=18'} shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} @@ -8947,6 +8952,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + source-map-support@0.5.19: resolution: {integrity: sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==} @@ -11328,7 +11337,7 @@ snapshots: '@bruits/satteri-win32-x64-msvc@0.10.5': optional: true - '@bufbuild/protobuf@2.15.0': + '@bufbuild/protobuf@2.16.0': optional: true '@capsizecss/unpack@4.0.0': @@ -11460,7 +11469,7 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3(supports-color@7.2.0) - fs-extra: 11.4.0 + fs-extra: 11.4.1 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: @@ -16032,6 +16041,13 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@11.4.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -19385,7 +19401,7 @@ snapshots: sass-embedded@1.100.0: dependencies: - '@bufbuild/protobuf': 2.15.0 + '@bufbuild/protobuf': 2.16.0 colorjs.io: 0.5.2 immutable: 5.1.9 rxjs: 7.8.2 @@ -19417,7 +19433,7 @@ snapshots: dependencies: chokidar: 5.0.0 immutable: 5.1.9 - source-map-js: 1.2.1 + source-map-js: 1.2.2 optionalDependencies: '@parcel/watcher': 2.6.0 optional: true @@ -19543,7 +19559,7 @@ snapshots: setprototypeof@1.2.0: {} - shaka-player@5.2.4: {} + shaka-player@5.2.12: {} shallow-clone@3.0.1: dependencies: @@ -19680,6 +19696,9 @@ snapshots: source-map-js@1.2.1: {} + source-map-js@1.2.2: + optional: true + source-map-support@0.5.19: dependencies: buffer-from: 1.1.2 From e315467c2d2a3a3a28b209664ac62a3e3d8d48f1 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:56:12 +0200 Subject: [PATCH 06/11] test(perf): record which nodes move in a journey's layout shift (#1845) * test(perf): record which nodes move in a journey's layout shift J2's renderer.layoutShiftScore went from 0.222 to 0.233 with #1814, and its evidence only held the recent-input / without-recent-input split, so the moved element could not be named from a summary. The probe now keeps the first 20 counted shifts (value, recent input, time since the journey start and the moved nodes, as J1's late shifts do), and J2 writes them to evidence.layoutShift.shifts. Co-Authored-By: Claude Opus 5.5 * test(perf): record the horizontal move of layout-shift sources J2's 0.233 shift on the runner moves main.workspace-content, the header search field and the header actions with deltaY and deltaHeight 0, so the move is horizontal and the probe could not show it. Sources now also carry deltaX and deltaWidth. Co-Authored-By: Claude Opus 5.5 * test(perf): count every layout shift next to the capped list Review follow-up (Greptile): the score counts every shift but the evidence lists only the first 20, so a reader could not tell that later shifts were omitted. The probe now keeps shiftCount, and J2 writes it beside evidence.layoutShift.shifts. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../journey-renderer-probe.spec.ts | 30 ++++++++++++ .../journey-renderer-probe.test-helpers.ts | 4 +- .../src/performance/journey-renderer-probe.ts | 49 +++++++++++++++++-- .../performance/launch-journey-record.spec.ts | 6 +++ .../open-source-journey-record.spec.ts | 35 +++++++++++++ .../performance/open-source-journey-record.ts | 11 +++++ .../playback-journey-record.spec.ts | 2 + docs/architecture/performance-journeys.md | 6 +-- 8 files changed, 133 insertions(+), 10 deletions(-) diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts index c42b35f59..37f86d4d5 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -341,6 +341,8 @@ test('keeps summing shifts without recent input after the first-card cutoff unti [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'lib-dashboard-rail[data-test-id="dashboard-favorites-rail"]', }, @@ -798,6 +800,13 @@ test('drops performance entries from before the click and keeps recent-input shi { entryType: 'layout-shift', hadRecentInput: true, + sources: [ + { + currentRect: { height: 40, width: 300, x: 48, y: 152 }, + node: fixture.card, + previousRect: { height: 40, width: 320, x: 0, y: 100 }, + }, + ], startTime: now(), value: 0.25, }, @@ -823,6 +832,25 @@ test('drops performance entries from before the click and keeps recent-input shi assert.equal(state.final, true); assert.equal(state.counters.layoutShiftScore, 0.125); assert.equal(state.counters.recentInputLayoutShiftScore, 0.25); + // Each counted shift keeps its nodes; the pre-click ones are not listed. + assert.deepEqual( + state.shifts.map((shift) => [ + shift.hadRecentInput, + shift.value, + shift.sources.map((source) => [ + source.deltaX, + source.deltaY, + source.deltaWidth, + ]), + ]), + [ + [true, 0.25, [[48, 52, -20]]], + [false, 0.125, []], + ] + ); + assert.equal(state.shiftCount, 2); + assert.ok(state.shifts.every((shift) => shift.sinceStartMs >= 0)); + assert.match(state.shifts[0]?.sources[0]?.node ?? '', /^[a-z-]+/); // J2 has no settle window: the observers close at the cutoff. assert.equal(state.settle.status, 'disabled'); assert.equal(state.counters.layoutShiftScoreSettled, 0); @@ -869,6 +897,8 @@ test('rejects a click start whose sentinel could not be sent', async () => { const started = { ...state, sentinel: { epochMs: 1, status: 'sent' as const }, + shiftCount: 0, + shifts: [], }; assert.throws( () => assertJourneyRendererProbeState(started), diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts index 799aeeb48..3fcf9142f 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts @@ -17,9 +17,9 @@ export interface FakeEntry { entryType: string; hadRecentInput?: boolean; sources?: { - currentRect: { height: number; y: number }; + currentRect: { height: number; width?: number; x?: number; y: number }; node: unknown; - previousRect: { height: number; y: number }; + previousRect: { height: number; width?: number; x?: number; y: number }; }[]; startTime: number; value?: number; diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts index 71a321a23..7ee9bac31 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -166,12 +166,24 @@ export interface JourneyRendererProbeCounters { recentInputLayoutShiftScore: number; } +/** A shift counted in `layoutShiftScore` or `recentInputLayoutShiftScore`. */ +export interface JourneyRendererProbeShift { + readonly hadRecentInput: boolean; + /** Entry start minus the journey start (navigation start for J1). */ + readonly sinceStartMs: number; + /** `tag.class[data-test-id]` and the move of each source. */ + readonly sources: JourneyRendererProbeLateShift['sources']; + readonly value: number; +} + export interface JourneyRendererProbeLateShift { /** Entry start minus the first-card terminal epoch. */ readonly afterFirstCardMs: number; - /** `tag.class[data-test-id]` and the vertical move of each source. */ + /** `tag.class[data-test-id]` and the move of each source. */ readonly sources: readonly { readonly deltaHeight: number; + readonly deltaWidth: number; + readonly deltaX: number; readonly deltaY: number; readonly node: string; }[]; @@ -237,6 +249,13 @@ export interface JourneyRendererProbeState { readonly epochMs: number | null; readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent'; }; + /** Every shift counted until the cutoff; `shifts` keeps the first 20. */ + shiftCount: number; + /** + * The first 20 shifts counted until the cutoff, with the nodes that + * moved, so a layout-shift score can be traced to its components. + */ + shifts: JourneyRendererProbeShift[]; /** `final` freezes the first-card counters; the settle window ends later. */ settle: { /** Mutation records under the settle root after the cutoff. */ @@ -329,6 +348,8 @@ export function journeyRendererProbeScript( preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, sentinel: { epochMs: null, status: 'not-sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 0, epochMs: null, @@ -389,6 +410,7 @@ export function journeyRendererProbeScript( for (const entry of entries) { const shift = entry as PerformanceEntry & { hadRecentInput?: boolean; + sources?: readonly LateShiftSource[]; value?: number; }; if ( @@ -397,6 +419,18 @@ export function journeyRendererProbeScript( ) { continue; } + state.shiftCount += 1; + if (state.shifts.length < 20) { + state.shifts.push({ + hadRecentInput: shift.hadRecentInput === true, + sinceStartMs: + performance.timeOrigin + + entry.startTime - + (state.start?.epochMs ?? performance.timeOrigin), + sources: (shift.sources ?? []).map(describeSource), + value: shift.value, + }); + } if (shift.hadRecentInput === true) { state.counters.recentInputLayoutShiftScore += shift.value; continue; @@ -530,10 +564,11 @@ export function journeyRendererProbeScript( state.idle.status = 'done'; }, idle.durationMs); }; + type ShiftRect = { height: number; width?: number; x?: number; y: number }; type LateShiftSource = { - currentRect?: { height: number; y: number }; + currentRect?: ShiftRect; node?: Node | null; - previousRect?: { height: number; y: number }; + previousRect?: ShiftRect; }; const describeSource = (source: LateShiftSource) => { const node = source.node; @@ -550,9 +585,13 @@ export function journeyRendererProbeScript( } const before = source.previousRect; const after = source.currentRect; + const delta = (key: keyof ShiftRect) => + before && after ? (after[key] ?? 0) - (before[key] ?? 0) : 0; return { - deltaHeight: before && after ? after.height - before.height : 0, - deltaY: before && after ? after.y - before.y : 0, + deltaHeight: delta('height'), + deltaWidth: delta('width'), + deltaX: delta('x'), + deltaY: delta('y'), node: label, }; }; 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 f2084d099..b5123cd3b 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 @@ -54,6 +54,8 @@ function measurement( preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, sentinel: { epochMs: 2_601, status: 'sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 37, epochMs: 3_180.06, @@ -64,6 +66,8 @@ function measurement( sources: [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'section.dashboard-rail', }, @@ -210,6 +214,8 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn- sources: [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'section.dashboard-rail', }, diff --git a/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts index 95cb2fb4d..f17f7b390 100644 --- a/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts @@ -59,6 +59,23 @@ function measurement( preStart: { domMutations: 4, lastMutationEpochMs: 9_100 }, schemaVersion: 1, sentinel: { epochMs: 10_080.5, status: 'sent' }, + shiftCount: 23, + shifts: [ + { + hadRecentInput: true, + sinceStartMs: 41.26, + sources: [ + { + deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, + deltaY: 52, + node: 'div.content[data-test-id="category-list"]', + }, + ], + value: 0.22106, + }, + ], settle: { domMutations: 0, epochMs: null, @@ -147,6 +164,24 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters }); assert.deepEqual(record.evidence['layoutShift'], { recentInput: 0.221, + // More shifts were counted than listed. + shiftCount: 23, + shifts: [ + { + hadRecentInput: true, + sinceStartMs: 41.3, + sources: [ + { + deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, + deltaY: 52, + node: 'div.content[data-test-id="category-list"]', + }, + ], + value: 0.2211, + }, + ], withoutRecentInput: 0, }); assert.deepEqual(record.evidence['httpRequestsByRoute'], { diff --git a/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts b/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts index 4d68bdcaa..ef9e61403 100644 --- a/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts @@ -184,6 +184,17 @@ export function toOpenSourceIterationRecord( recentInput: roundThousandth( renderer.counters.recentInputLayoutShiftScore ), + // Every counted shift; `shifts` lists the first 20. + shiftCount: renderer.shiftCount, + // The first 20 counted shifts and the nodes that moved. + shifts: renderer.shifts.map((shift) => + Object.freeze({ + hadRecentInput: shift.hadRecentInput, + sinceStartMs: roundTenth(shift.sinceStartMs), + sources: shift.sources, + value: Math.round(shift.value * 10_000) / 10_000, + }) + ), withoutRecentInput: roundThousandth( renderer.counters.layoutShiftScore ), diff --git a/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts index ee228c1c8..2b4246025 100644 --- a/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts @@ -74,6 +74,8 @@ function measurement( preStart: { domMutations: 53, lastMutationEpochMs: 8_900 }, schemaVersion: 1, sentinel: { epochMs: 10_350, status: 'sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 0, epochMs: null, diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index d16fb4f50..dfb8a2824 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -175,8 +175,8 @@ closed or closed before the cutoff. J2's probe has no settle window most 20): the time after the first card, the value and, for each source the browser attributes the shift to, the node (`tag.class[data-test-id]`; a component host such as `lib-dashboard-rail` takes its first child's test id) -and its vertical move. A late shift can therefore be traced to its component -from the summary alone. +and its move (`deltaX`, `deltaY`, `deltaWidth`, `deltaHeight`). A late shift +can therefore be traced to its component from the summary alone. First local measurement (macOS, 2026-09-29, `master` with #1738): all windows closed on `quiet`, `renderer.layoutShiftScore` stayed 0, and @@ -719,7 +719,7 @@ strings and stream paths carry credentials and are never stored. | `renderer.ipcCallsToFirstPage` | Bridge `start` trace events between the start and end sentinels, counted by a second `journey-main-ipc-capture.ts` instance installed with `startSentinelId`. Calls before the start marker are tallied separately (`callsBeforeStart`); a start marker that is missing, repeated or received after the end sentinel fails the iteration. | | `renderer.domMutationsToFirstPage` | `MutationRecord`s from the click until the terminal batch. Records produced before the click (hover, settling) are taken from the observer at the start and counted under `evidence.settle` instead. | | `renderer.cdTicksToFirstPage` | `ApplicationRef` ticks from the click until the terminal batch: the counter's running total read in the capture-phase click listener, before the app handles the click, subtracted from its value at the terminal batch (see [Change-detection ticks](#change-detection-ticks)). | -| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`. | +| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`, and `evidence.layoutShift.shifts` lists the first 20 counted shifts (`shiftCount` is the total) with their value, `hadRecentInput`, time since the click and the nodes that moved (`tag.class[data-test-id]` and their `deltaX`, `deltaY`, `deltaWidth` and `deltaHeight`, as J1's late shifts). | | `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the cutoff. The task that dispatches the click began before the event's timestamp and still counts; buffered J1 tasks that ended before the click are dropped. Evidence until it is shown to be stable on the CI runner, as for J1. | | `main.mockHttpRequestsToSettled` | Requests the proxy received from the click until, after the terminal batch, no new request had arrived for 1 s and none was in flight (a response slower than that, and what it triggers, stays inside the window). The window ends at the ledger position read by that accepted quiet sample; a request arriving after it was never seen in flight, so it goes to `evidence.httpRequestsAfterSettledByRoute` instead of the counter. The ledger is read 1 s after that sample, so that late traffic is actually observed. The window starts at the renderer's click stamp, the same boundary as every other J2 counter, not when Playwright began its actionability checks; the proxy stamps requests with the test process's wall clock, and both processes read the same host clock. Bounding by the terminal would compare the test process's clock with the renderer's, so the count up to the terminal epoch is evidence only (`evidence.httpRequestsToFirstPage`); `evidence.httpRequestsByRoute` names the requests. | From 3cc5af14926c6403e1fe6b2538f2b66f1d5babee Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:12:52 +0200 Subject: [PATCH 07/11] perf(playback): make the web players and M3U player OnPush (#1822) * perf(playback): make the web players and M3U player OnPush Plan item C6 step 3 for playback: the eight Eager components in libs/ui/playback (video.js, ArtPlayer, HTML5/hls/mpegts, audio player, web player view, VOD details, sidebar, external-player dialog) and the M3U video player and VOD detail switch to OnPush. The player libraries' events already reach the UI through the signal-backed controls adapter or outputs, and the players' DOM belongs to the libraries. The M3U video player rendered three plain fields written outside template events: the channel-number overlay, cleared by a 2 s debounce timer, and the player choice, written from an IndexedDB read and a settings effect. They are signals now, and a test checks that the overlay leaves the DOM when the timer fires. Co-Authored-By: Claude Opus 5.5 * test(playback): let the overlay's signal write schedule its own render Review follow-up (Greptile): the test forced a render with fixture.detectChanges() after the debounce timer, so it would pass even if the signal write stopped scheduling an OnPush render. It now runs the fixture with autoDetectChanges and only advances the fake timers; with plain fields under OnPush the overlay never renders and the test fails. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- docs/architecture/zoneless-migration.md | 26 ++++++------- .../m3u-vod-detail.component.ts | 2 +- ...-player.component.fullscreen-panel.spec.ts | 37 +++++++++++++++++-- .../video-player/video-player.component.html | 8 ++-- .../video-player/video-player.component.ts | 34 +++++++++-------- .../lib/art-player/art-player.component.ts | 2 +- .../audio-player/audio-player.component.ts | 2 +- .../external-player-info-dialog.component.ts | 2 +- .../html-video-player.component.ts | 2 +- .../video-player/sidebar/sidebar.component.ts | 2 +- .../lib/vjs-player/vjs-player.component.ts | 2 +- .../lib/vod-details/vod-details.component.ts | 2 +- .../web-player-view.component.ts | 2 +- 13 files changed, 78 insertions(+), 45 deletions(-) diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 84fdb1ede..63e70d481 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -97,14 +97,14 @@ files that still contain `ChangeDetectionStrategy.Eager`. - [x] `libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts` (idle audit root; also `EpgTrustConfirmDialogComponent`) - [x] `libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts` - [ ] `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` (`apps/remote-control-web` only) -- [ ] `libs/ui/playback/src/lib/art-player/art-player.component.ts` -- [ ] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` -- [ ] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts` -- [ ] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts` -- [ ] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts` -- [ ] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts` -- [ ] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` -- [ ] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` +- [x] `libs/ui/playback/src/lib/art-player/art-player.component.ts` +- [x] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` +- [x] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts` +- [x] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts` +- [x] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts` +- [x] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts` +- [x] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` +- [x] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` `libs/ui/playback` (8) goes with the playback PR, not the `libs/ui` one. @@ -131,8 +131,8 @@ files that still contain `ChangeDetectionStrategy.Eager`. - [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts` - [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts` - [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts` -- [ ] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts` -- [ ] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` +- [x] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts` +- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` - [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts` - [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` - [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` @@ -168,8 +168,8 @@ the field a signal (or a `computed`), or writes it through one. | Done | Site | What depends on the zone | Owning PR | | --- | --- | --- | --- | -| [ ] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback | -| [ ] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback | +| [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback | +| [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback | | [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist | | [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist | | [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist | @@ -188,7 +188,7 @@ before: with zone.js on they still matter. - [ ] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two `zone.run` calls around the window-close dialog (IPC `onWindowCloseRequested` and `beforeunload`). -- [ ] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`: +- [x] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`: `runOutsideAngular(() => setInterval(...))` for the position poll; `embedded-mpv-session-controller.position.spec.ts` asserts the call and changes with it. diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts index cd5b6adb8..c5d7fcb18 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts @@ -71,7 +71,7 @@ import { TranslatePipe, ], templateUrl: './m3u-vod-detail.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./m3u-vod-detail.component.scss'], }) export class M3uVodDetailComponent { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts index d8ebfef71..72af02b4e 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts @@ -231,8 +231,12 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { .overrideComponent(VideoPlayerComponent, { set: { imports: [], - template: - '', + // The real template's channel-number overlay, so the + // OnPush timer test below can read the rendered state. + template: ` + @if (showChannelNumberOverlay()) { +
{{ channelNumberInput() }}
+ }`, }, }) .compileComponents(); @@ -247,6 +251,33 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { fixture.destroy(); }); + // OnPush: the overlay hides from a 2 s timer, outside any template + // event, so the signal write itself must schedule the render. The test + // never forces one after the timer: a plain-field write would leave the + // overlay in the DOM. + it('hides the channel-number overlay when its debounce fires', async () => { + jest.useFakeTimers(); + try { + const overlay = () => + (fixture.nativeElement as HTMLElement).querySelector( + '.channel-number-overlay' + ); + fixture.autoDetectChanges(); + component.handleChannelNumberInput('2'); + await jest.advanceTimersByTimeAsync(50); + expect(overlay()?.textContent).toBe('2'); + + await jest.advanceTimersByTimeAsync(2000); + + expect(overlay()).toBeNull(); + expect(storeMock.dispatch).toHaveBeenCalledWith( + setActiveChannelDispatch(nextChannel) + ); + } finally { + jest.useRealTimers(); + } + }); + describe('FULLSCREEN_CHANNEL_PANEL host', () => { it.each([VideoPlayer.MPV, VideoPlayer.VLC])( 'withholds rows that would leave forced-inline DASH for %s', @@ -260,7 +291,7 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { url: 'http://localhost/next.mpd', }; player.set(externalPlayer); - component.playerSettings.player = externalPlayer; + component.playerSettings.set({ player: externalPlayer }); setActive(dashChannel); channels.set([dashChannel, sampleChannel, nextDashChannel]); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 481352fc8..274f29958 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -85,7 +85,7 @@ [playbackSessionKey]="playbackSessionKey()" [inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)" [volume]="volume()" - [playerOverride]="playerSettings.player ?? null" + [playerOverride]="playerSettings().player ?? null" (playbackStarted)="refreshVolumeFromBus()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) @@ -124,7 +124,7 @@ [playerOverride]=" activeChannelIsDash() ? dashPlayerOverride() - : (playerSettings.player ?? null) + : (playerSettings().player ?? null) " [volume]="volume()" [timelineSegments]="catchupTimelineSegments()" @@ -255,10 +255,10 @@ } } - @if (showChannelNumberOverlay) { + @if (showChannelNumberOverlay()) {
- {{ channelNumberInput }} + {{ channelNumberInput() }}
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index e10c4f119..592f5bd55 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -231,7 +231,7 @@ function isInsideScrollableRegion( { provide: EPG_GUIDE_SOURCE, useExisting: M3uEpgGuideSourceService }, ], templateUrl: './video-player.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './video-player.component.scss', }) export class VideoPlayerComponent @@ -713,9 +713,9 @@ export class VideoPlayerComponent ); /** Selected video player options */ - playerSettings: Partial = { + readonly playerSettings = signal>({ player: VideoPlayer.VideoJs, - }; + }); readonly isDesktop = this.runtime.isElectron; readonly supportsEpg = this.runtime.supportsEpg; @@ -731,8 +731,8 @@ export class VideoPlayerComponent ); /** Channel number input state */ - channelNumberInput = ''; - showChannelNumberOverlay = false; + readonly channelNumberInput = signal(''); + readonly showChannelNumberOverlay = signal(false); private channelNumberTimeout?: number; /** @@ -795,9 +795,9 @@ export class VideoPlayerComponent // React to settings changes effect(() => { - this.playerSettings = { + this.playerSettings.set({ player: this.settingsStore.player(), - }; + }); }); // Keep "now" fresh so EPG state re-evaluates over time. @@ -1191,10 +1191,10 @@ export class VideoPlayerComponent applySettings(): void { this.storage.get(STORE_KEY.Settings).subscribe((settings: unknown) => { if (settings && Object.keys(settings as Settings).length > 0) { - this.playerSettings = { + this.playerSettings.set({ player: (settings as Settings).player || VideoPlayer.VideoJs, - }; + }); } }); } @@ -1472,12 +1472,14 @@ export class VideoPlayerComponent } // Add digit to current input - this.channelNumberInput += digit; - this.showChannelNumberOverlay = true; + this.channelNumberInput.update((input) => input + digit); + this.showChannelNumberOverlay.set(true); // Set timeout to switch channel after 2 seconds of no input this.channelNumberTimeout = window.setTimeout(() => { - this.switchToChannelByNumber(parseInt(this.channelNumberInput, 10)); + this.switchToChannelByNumber( + parseInt(this.channelNumberInput(), 10) + ); this.clearChannelNumberInput(); }, 2000); } @@ -1547,8 +1549,8 @@ export class VideoPlayerComponent * Clear channel number input and hide overlay */ clearChannelNumberInput(): void { - this.channelNumberInput = ''; - this.showChannelNumberOverlay = false; + this.channelNumberInput.set(''); + this.showChannelNumberOverlay.set(false); if (this.channelNumberTimeout) { clearTimeout(this.channelNumberTimeout); this.channelNumberTimeout = undefined; @@ -1663,7 +1665,7 @@ export class VideoPlayerComponent return true; } - const player = this.playerSettings.player; + const player = this.playerSettings().player; return ( !this.isExternalPlayer(player) && player !== VideoPlayer.EmbeddedMpv ); @@ -1685,7 +1687,7 @@ export class VideoPlayerComponent return true; } - return !this.isExternalPlayer(this.playerSettings.player); + return !this.isExternalPlayer(this.playerSettings().player); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 208b36c4f..5710777a8 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -51,7 +51,7 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; ], providers: [WebVideoControlsAdapter], templateUrl: './art-player.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./art-player.component.scss'], }) export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index 34469d51a..b8464d100 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -167,7 +167,7 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor `, styleUrls: ['./audio-player.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ FormsModule, MatButtonModule, diff --git a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts index f9e3ab956..415a2ab28 100644 --- a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts +++ b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts @@ -57,7 +57,7 @@ import { TranslateModule } from '@ngx-translate/core'; } `, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatCheckboxModule, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index a0384a293..924e72e07 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -71,7 +71,7 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); SeriesPlaybackNavigationControlsComponent, ], providers: [WebVideoControlsAdapter], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 3e2cc0cfe..3f97a4dbe 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -20,7 +20,7 @@ import { ChannelListContainerComponent } from '@iptvnator/ui/components'; selector: 'app-sidebar', templateUrl: './sidebar.component.html', styleUrls: ['./sidebar.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ChannelListContainerComponent, MatIcon, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 6770f466e..2996a44cc 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -66,7 +66,7 @@ const debugVjsPlayer = createDevLogger('VjsPlayer'); SeriesPlaybackNavigationControlsComponent, ], providers: [WebVideoControlsAdapter], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 4d3b658e2..9e4e4f49f 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -82,7 +82,7 @@ import { createVodSimilarInPortals } from './vod-similar-in-portals.state'; selector: 'app-vod-details', templateUrl: './vod-details.component.html', styleUrls: ['../styles/detail-view.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CastCrewRowComponent, DetailActionButtonComponent, diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index a1c55b8f9..ab0ee9aff 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -91,7 +91,7 @@ import { resolveWebPlayerSharedControls } from './web-player-shared-controls'; useFactory: resolveWebPlayerSharedControls, }, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class WebPlayerViewComponent implements OnDestroy { From 2e321cb1bd772d80e9c8b1c6089a1c634bff1e6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:20:13 +0200 Subject: [PATCH 08/11] chore(deps): bump mpegts.js from 1.8.1 to 1.8.2 (#1835) * chore(deps): bump mpegts.js from 1.8.1 to 1.8.2 Bumps [mpegts.js](https://github.com/xqq/mpegts.js) from 1.8.1 to 1.8.2. - [Release notes](https://github.com/xqq/mpegts.js/releases) - [Commits](https://github.com/xqq/mpegts.js/compare/v1.8.1...v1.8.2) --- updated-dependencies: - dependency-name: mpegts.js dependency-version: 1.8.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * test(playback): accept the mpegts.js 1.8.2 error contract 1.8.2 keeps the public ErrorTypes and ErrorDetails exports unchanged, so the version lock moves to 1.8.2 with the same accepted contract. Co-Authored-By: Claude Opus 5.5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- docs/architecture/embedded-inline-playback.md | 2 +- .../diagnostics/mpegts-playback-evidence.util.spec.ts | 2 +- .../lib/diagnostics/mpegts-playback-evidence.util.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 11 +++++------ 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 3c19e5a58..8f98857af 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -749,7 +749,7 @@ allowlisted display name. `network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding. -mpegts.js `1.8.1` errors cross one shared structured boundary before the HTML5, +mpegts.js `1.8.2` errors cross one shared structured boundary before the HTML5, Video.js, or ArtPlayer owner emits a diagnostic. Version-locked tests compare the installed public `ErrorTypes` and `ErrorDetails` exports with the accepted contract. Evidence retains only an exact type/detail pair, terminal diff --git a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts index 4c9613bc7..d225076ed 100644 --- a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts @@ -21,7 +21,7 @@ const METADATA = createPlaybackSourceMetadata({ }); describe('mpegts.js playback evidence', () => { - it('locks the accepted public contract to mpegts.js 1.8.1', () => { + it('locks the accepted public contract to mpegts.js 1.8.2', () => { expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION); expect(mpegts.ErrorTypes).toEqual({ NETWORK_ERROR: MpegTsPlaybackEngineType.Network, diff --git a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts index e09a80088..dbae7fe68 100644 --- a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts +++ b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts @@ -11,7 +11,7 @@ import { MpegTsPlaybackStage, } from './mpegts-playback-evidence.model'; -export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.1'; +export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.2'; interface MpegTsPlaybackCause { readonly stage: MpegTsPlaybackStageValue; diff --git a/package.json b/package.json index 6a3fe916a..406ec03df 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "hls.js": "1.7.1", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2", "marked": "18.0.11", - "mpegts.js": "1.8.1", + "mpegts.js": "1.8.2", "ms": "2.1.3", "ngx-indexed-db": "22.0.0", "ngx-skeleton-loader": "11.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93e5f04d4..244eb6e6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,8 +155,8 @@ importers: specifier: 18.0.11 version: 18.0.11 mpegts.js: - specifier: 1.8.1 - version: 1.8.1 + specifier: 1.8.2 + version: 1.8.2 ms: specifier: 2.1.3 version: 2.1.3 @@ -7701,8 +7701,8 @@ packages: resolution: {integrity: sha512-blbA7XpAaOdC/PR0Tu8GiUxlx6CpBuRowQPYV7lhi9Yr9G9+dkPXIUjqjzc/NCn/uZZPopluaJlIYntIEI/VLw==} hasBin: true - mpegts.js@1.8.1: - resolution: {integrity: sha512-mQ2daxqBaS+QmBYTmJMDUGwAOw08534b08KSyqUGkjkhuBODNod15j8pnRfVEIQVWulKNe201/6UqILe4m4YRQ==} + mpegts.js@1.8.2: + resolution: {integrity: sha512-cZYMa5muASH55wrS6JK5IGkUqnCA96c0f+WdnW91jIK9s/N6NzjmTQDJNYg9A5u/G2Q5nj2b3w1Zwzze/9guxA==} engines: {node: '>=20.9.0'} mrmime@2.0.1: @@ -8854,7 +8854,6 @@ packages: shaka-player@5.2.12: resolution: {integrity: sha512-vEsuVDI3+oLh+yqpjXexQZlg2XMUBI0VrqatDuKWacPaxAud6Uph5uCowUPLhUgNG5XKWCpoM4l1r8qYzWH+zg==} - engines: {node: '>=18'} shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} @@ -18064,7 +18063,7 @@ snapshots: '@xmldom/xmldom': 0.8.15 global: 4.4.0 - mpegts.js@1.8.1: + mpegts.js@1.8.2: dependencies: es6-promise: 4.2.8 events: 3.3.0 From 77458b393194258e37340ff5e111eaa51affcdf3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:58:28 +0200 Subject: [PATCH 09/11] perf(web): make the app root and settings components OnPush (#1823) * perf(web): make the app root and settings components OnPush Plan item C6 step 3 for apps/web: the fifteen Eager components switch to OnPush, among them the app root and the update notification panel that the idle audit found re-rendering on every idle tick. Their template state is signals from the settings facades, signal inputs and the shared reactive settings form. The checklist flagged the backup import, which patches the form from a detached file input with no template event. A new spec patches only a value, which changes no form status, and confirms the OnPush general section still shows the new theme; it guards that path for the zoneless flag. Co-Authored-By: Claude Opus 5.5 * fix(settings): re-render OnPush sections when the form changes outside them Review follow-ups (Greptile, Codex): - The settings sections read form values in their templates (selected theme and cover size, epgField.value, form().value.player), and the parent changes the form outside their events: Discard and backup import patch it, the store hydrates it, the EPG file picker sets a control after an await. Under OnPush the section kept the old selection or EPG status. Each section now marks itself on its form's events (markSectionForCheckOnFormEvents). - The value-only patch test no longer forces detectChanges(); with the fixture rendering on its own it fails without the marking, and so does a new test for a control set outside the EPG section. - The zoneless guard counts only changeDetection metadata outside comments, so a comment naming the strategy is not an Eager component. Co-Authored-By: Claude Opus 5.5 * test(settings): guard the unsaved-changes bar after a save off the sections Review follow-up (Codex): Save marks the form pristine after an async store write, also on Backup, Reset or search, where no form section is rendered. The OnPush page re-renders anyway because pristine and valid read the form's state signals; the new test checks that on the Backup page without forcing a render (it waits for the scheduled one). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../performance/zoneless-migration.spec.ts | 31 ++++++++- ...app-update-notification-panel.component.ts | 3 +- apps/web/src/app/app.component.ts | 3 +- ...p-update-release-notes-dialog.component.ts | 3 +- .../settings-about-section.component.ts | 11 +++- .../settings-backup-section.component.ts | 3 +- .../settings-dashboard-section.component.ts | 10 ++- ...s-delete-all-playlists-dialog.component.ts | 3 +- .../settings-epg-section.component.ts | 11 +++- .../settings-general-section.component.ts | 11 +++- .../settings-playback-section.component.ts | 11 +++- ...ttings-remote-control-section.component.ts | 11 +++- .../settings-reset-section.component.ts | 3 +- .../settings/settings-section-form-render.ts | 30 +++++++++ .../settings-tmdb-section.component.ts | 5 +- ...ttings-unsaved-changes-dialog.component.ts | 3 +- .../settings/settings.component.form.spec.ts | 63 +++++++++++++++++++ .../src/app/settings/settings.component.ts | 3 +- docs/architecture/zoneless-migration.md | 41 ++++++------ 19 files changed, 210 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/app/settings/settings-section-form-render.ts diff --git a/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts b/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts index f9a67c9d7..3137c0f1f 100644 --- a/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts +++ b/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts @@ -70,9 +70,21 @@ function readEagerChecklist(): { open: string[]; done: string[] } { const sources = readSources(); +// Component metadata only: a comment or string that names the strategy is +// not an Eager component. +const eagerMetadata = /changeDetection\s*:\s*ChangeDetectionStrategy\.Eager\b/; + +function withoutComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); +} + +function isEagerComponent(text: string): boolean { + return eagerMetadata.test(withoutComments(text)); +} + test('the zoneless checklist lists exactly the components that are still Eager', () => { const eager = [...sources] - .filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager')) + .filter(([, text]) => isEagerComponent(text)) .map(([file]) => file) .sort(); const { open } = readEagerChecklist(); @@ -106,6 +118,23 @@ test('the guard skips test-only file names and keeps production ones', () => { } }); +test('a comment that names the Eager strategy is not an Eager component', () => { + assert.equal( + isEagerComponent( + '// was ChangeDetectionStrategy.Eager before C6\n' + + '/* changeDetection: ChangeDetectionStrategy.Eager */\n' + + '@Component({ changeDetection: ChangeDetectionStrategy.OnPush })' + ), + false + ); + assert.equal( + isEagerComponent( + '@Component({\n changeDetection: ChangeDetectionStrategy.Eager,\n})' + ), + true + ); +}); + test('ticked checklist entries name files that exist', () => { for (const file of readEagerChecklist().done) { assert.ok(sources.has(file), `${file} is ticked but does not exist`); diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts index a99e814ad..91afea93b 100644 --- a/apps/web/src/app/app-update-notification-panel.component.ts +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -101,8 +101,7 @@ import { AppUpdateInstallService } from './services/app-update-install.service'; } `, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` .app-update-notification { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 15c3df9c3..804899aa4 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -55,8 +55,7 @@ const debugAppComponent = createDevLogger('AppComponent'); @Component({ selector: 'app-root', templateUrl: './app.component.html', - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AppStartupStatusComponent, AppUpdateNotificationPanelComponent, diff --git a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts index 870e1c142..afacf4eff 100644 --- a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts +++ b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts @@ -178,8 +178,7 @@ function decorateReleaseNotesHtml(html: string): string { `, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` .release-notes-dialog { diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 9d6f1f6a0..028522a27 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -19,6 +19,7 @@ import { ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; import { UpdateChannelOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-about-section', @@ -32,8 +33,7 @@ import { UpdateChannelOption } from './settings.models'; ], templateUrl: './settings-about-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ':host { display: contents; }', '.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }', @@ -58,6 +58,13 @@ export class SettingsAboutSectionComponent { * setting. Absent in hosts that only render the version block. */ readonly form = input(null); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly updateChannelOptions = input([]); readonly buildCommitShort = computed(() => { diff --git a/apps/web/src/app/settings/settings-backup-section.component.ts b/apps/web/src/app/settings/settings-backup-section.component.ts index f383c6163..0af8aefba 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.ts +++ b/apps/web/src/app/settings/settings-backup-section.component.ts @@ -20,8 +20,7 @@ import { TranslateModule } from '@ngx-translate/core'; ], templateUrl: './settings-backup-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsBackupSectionComponent { diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.ts b/apps/web/src/app/settings/settings-dashboard-section.component.ts index 3cb1739b6..c39bdbca5 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.ts +++ b/apps/web/src/app/settings/settings-dashboard-section.component.ts @@ -9,6 +9,7 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-dashboard-section', @@ -21,10 +22,15 @@ import { TranslateModule } from '@ngx-translate/core'; ], templateUrl: './settings-dashboard-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsDashboardSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } } diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts index 87dd57aa2..9f4a0fea2 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts @@ -31,8 +31,7 @@ type SettingsDeleteSummaryItem = { selector: 'app-settings-delete-all-playlists-dialog', templateUrl: './settings-delete-all-playlists-dialog.component.html', styleUrls: ['./settings-delete-all-playlists-dialog.component.scss'], - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, MatButtonModule, diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index 8f07d4124..8152b24da 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -17,6 +17,7 @@ import { EpgViewMode } from '@iptvnator/shared/interfaces'; import { EpgSourceStatusComponent } from '@iptvnator/ui/epg'; import { TranslateModule } from '@ngx-translate/core'; import { EpgViewModeOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-epg-section', @@ -34,12 +35,18 @@ import { EpgViewModeOption } from './settings.models'; ], templateUrl: './settings-epg-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsEpgSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly epgUrl = input.required(); readonly isClearingEpgData = input(false); readonly canBrowseFiles = input(false); diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index b24d2afbc..b44971bf0 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -19,6 +19,7 @@ import { StartupWindowModeOption, ThemeOption, } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-general-section', @@ -33,12 +34,18 @@ import { ], templateUrl: './settings-general-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsGeneralSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly languageEnum = input.required(); readonly themeOptions = input.required(); readonly coverSizeOptions = input.required(); diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 6b0afd01f..5a3eee85d 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -20,6 +20,7 @@ import { reportsPlaybackFailures, } from '@iptvnator/shared/interfaces'; import { SettingsPlayerOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-playback-section', @@ -36,8 +37,7 @@ import { SettingsPlayerOption } from './settings.models'; ], templateUrl: './settings-playback-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsPlaybackSectionComponent { @@ -53,6 +53,13 @@ export class SettingsPlaybackSectionComponent { ].join('\n'); readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly players = input.required(); readonly streamFormatEnum = input.required(); readonly isDesktop = input(false); diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 37b6408ef..7d667f46a 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -14,6 +14,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; import { QRCodeComponent } from 'angularx-qrcode'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-remote-control-section', @@ -30,12 +31,18 @@ import { QRCodeComponent } from 'angularx-qrcode'; ], templateUrl: './settings-remote-control-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsRemoteControlSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly localIpAddresses = input.required(); readonly visibleQrCodeIp = input(null); diff --git a/apps/web/src/app/settings/settings-reset-section.component.ts b/apps/web/src/app/settings/settings-reset-section.component.ts index ae6d5226f..8396ee5c4 100644 --- a/apps/web/src/app/settings/settings-reset-section.component.ts +++ b/apps/web/src/app/settings/settings-reset-section.component.ts @@ -22,8 +22,7 @@ import { SettingsPlaylistDeleteSummary } from './settings.models'; ], templateUrl: './settings-reset-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsResetSectionComponent { diff --git a/apps/web/src/app/settings/settings-section-form-render.ts b/apps/web/src/app/settings/settings-section-form-render.ts new file mode 100644 index 000000000..f0c19729b --- /dev/null +++ b/apps/web/src/app/settings/settings-section-form-render.ts @@ -0,0 +1,30 @@ +import { ChangeDetectorRef, inject, type Signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import type { AbstractControl } from '@angular/forms'; +import { EMPTY, switchMap } from 'rxjs'; + +/** + * Marks an OnPush settings section for check on every event of its form. + * + * The sections read form values and states in their templates (selected + * theme, `epgField.value`, `form().value.player`), which are not signals. + * The parent changes the form outside the section's template events: Discard + * and backup import patch it, the store hydrates it, and the EPG file picker + * sets a control after an `await`. Without this the section keeps showing + * the previous value until some unrelated event marks it. `events` covers + * value, status, touched and pristine changes, including those of child + * controls, which bubble up to the group. + * + * Call it from a field initializer or the constructor. + */ +export function markSectionForCheckOnFormEvents( + form: Signal +): void { + const changeDetector = inject(ChangeDetectorRef); + toObservable(form) + .pipe( + switchMap((control) => control?.events ?? EMPTY), + takeUntilDestroyed() + ) + .subscribe(() => changeDetector.markForCheck()); +} diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 4b57acfba..0959308c7 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -16,6 +16,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslateModule } from '@ngx-translate/core'; import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; @@ -33,8 +34,7 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; ], templateUrl: './settings-tmdb-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` app-settings-tmdb-section { @@ -92,6 +92,7 @@ export class SettingsTmdbSectionComponent { readonly isClearing = signal(false); constructor() { + markSectionForCheckOnFormEvents(this.form); // Sizing the cache is a full table scan, but this component only // exists while its section page is open, so loading on construction // preserves the old "wait until the user is actually looking" diff --git a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts index c556f7d1b..c709a9e06 100644 --- a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts +++ b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts @@ -54,8 +54,7 @@ export interface SettingsUnsavedChangesDialogData { } `, ], - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, template: `

{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }} diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index 5cd4b2d3d..bf9b9b1d2 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -1,3 +1,4 @@ +import { FormArray, FormControl } from '@angular/forms'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; @@ -143,6 +144,25 @@ describe('SettingsComponent form', () => { }); }); + // The sections are OnPush and a Discard or backup import patches the + // form outside their template events, so the section must mark + // itself on the form's events. The fixture renders on its own here: + // a forced detectChanges() would hide a section that is not marked. + it('re-renders section selections after a value-only form patch', async () => { + const darkTheme = () => + (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="DARK_THEME"]' + ); + fixture.autoDetectChanges(); + await fixture.whenStable(); + expect(darkTheme()?.getAttribute('aria-checked')).toBe('false'); + + component.settingsForm.patchValue({ theme: Theme.DarkTheme }); + await fixture.whenStable(); + + expect(darkTheme()?.getAttribute('aria-checked')).toBe('true'); + }); + it('hydrates a shared web controls opt-out from the settings store', () => { settingsStore._setSettings({ webPlayerSharedControls: false, @@ -185,6 +205,26 @@ describe('SettingsComponent form', () => { expect(settingsStore.updateSettings).not.toHaveBeenCalled(); }); + // The native file picker sets the EPG control after an await, with + // no template event in the OnPush section; its status must follow. + it('shows the source status after a control is set outside the section', async () => { + setSettingsSection('epg'); + fixture.autoDetectChanges(); + const epgUrls = component.settingsForm.get('epgUrl') as FormArray; + epgUrls.push(new FormControl('')); + await fixture.whenStable(); + const status = () => + (fixture.nativeElement as HTMLElement).querySelector( + 'app-epg-source-status' + ); + expect(status()).toBeNull(); + + epgUrls.at(epgUrls.length - 1).setValue('/tmp/guide.xml'); + await fixture.whenStable(); + + expect(status()).not.toBeNull(); + }); + it('stages the EPG view mode without writing to the store until Save', () => { setSettingsSection('epg'); fixture.detectChanges(); @@ -314,6 +354,29 @@ describe('SettingsComponent form', () => { expect(unsavedBar()).toBeNull(); }); + // The page owns the bar and is OnPush, and Save marks the form + // pristine after an async store write, also on a page without a form + // section. `pristine` and `valid` read the form's state signals, so + // the page re-renders without a form subscription; no forced render + // here, so a regression shows. + it('hides after a save on a page without a form section', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + setSettingsSection('backup'); + fixture.autoDetectChanges(); + component.settingsForm.get('theme')?.setValue(Theme.DarkTheme); + component.settingsForm.markAsDirty(); + await fixture.whenStable(); + expect(unsavedBar()).not.toBeNull(); + + component.onSubmit(); + await fixture.whenStable(); + // The render the form event scheduled runs in the next macrotask. + await new Promise((resolve) => setTimeout(resolve)); + + expect(component.settingsForm.pristine).toBe(true); + expect(unsavedBar()).toBeNull(); + }); + it('discard reverts a staged cover size (regression: eager persist made it stick)', () => { const largeCoverButton = ( fixture.nativeElement as HTMLElement diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5f95e8b07..f43b3eb30 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -102,8 +102,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsSearchResultsComponent, SettingsTmdbSectionComponent, ], - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ SettingsAppUpdateFacade, SettingsBackupFacade, diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 63e70d481..4d7e804f7 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -63,25 +63,30 @@ must be ticked here. two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager` (or setting OnPush) once its template state is signals, signal inputs or explicitly marked. The guard spec compares the unticked entries with the -files that still contain `ChangeDetectionStrategy.Eager`. +files whose component metadata still sets +`changeDetection: ChangeDetectionStrategy.Eager` (comments do not count). +The settings sections read form values in their templates and the parent +patches the form outside their events (Discard, backup import, the EPG file +picker), so each marks itself on the form's `events` through +`markSectionForCheckOnFormEvents` (`apps/web/src/app/settings`). ### apps/web (15) -- [ ] `apps/web/src/app/app.component.ts` (idle audit root) -- [ ] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root) -- [ ] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts` -- [ ] `apps/web/src/app/settings/settings.component.ts` -- [ ] `apps/web/src/app/settings/settings-about-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-backup-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-dashboard-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts` -- [ ] `apps/web/src/app/settings/settings-epg-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-general-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-playback-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-remote-control-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-reset-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-tmdb-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts` +- [x] `apps/web/src/app/app.component.ts` (idle audit root) +- [x] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root) +- [x] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts` +- [x] `apps/web/src/app/settings/settings.component.ts` +- [x] `apps/web/src/app/settings/settings-about-section.component.ts` +- [x] `apps/web/src/app/settings/settings-backup-section.component.ts` +- [x] `apps/web/src/app/settings/settings-dashboard-section.component.ts` +- [x] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts` +- [x] `apps/web/src/app/settings/settings-epg-section.component.ts` +- [x] `apps/web/src/app/settings/settings-general-section.component.ts` +- [x] `apps/web/src/app/settings/settings-playback-section.component.ts` +- [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts` +- [x] `apps/web/src/app/settings/settings-reset-section.component.ts` +- [x] `apps/web/src/app/settings/settings-tmdb-section.component.ts` +- [x] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts` ### libs/ui (20 files, 21 components) @@ -176,7 +181,7 @@ the field a signal (or a `computed`), or writes it through one. | [ ] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal | | [ ] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal | | [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal | -| [ ] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize` | apps/web | +| [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; no fix needed: a value-only `patchValue` re-renders the OnPush sections (`settings.component.form.spec.ts` guards it) | apps/web | | [ ] | `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` | plain `isLoading`/`error`/`status` written after `await` and from a 2 s `setInterval` | only if `apps/remote-control-web` goes zoneless | ## Explicit zone and change-detector calls @@ -185,7 +190,7 @@ They keep working under zoneless (`NgZone` becomes `NoopNgZone`, so `run` and `runOutsideAngular` just call through). Remove them in the flip PR, not before: with zone.js on they still matter. -- [ ] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two +- [x] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two `zone.run` calls around the window-close dialog (IPC `onWindowCloseRequested` and `beforeunload`). - [x] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`: From e17ee53a22df226a0d941f50e1edaf423ce57252 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:31:15 +0200 Subject: [PATCH 10/11] perf(workspace): make the workspace shell Eager components OnPush (#1819) Plan item C6 step 3 for libs/workspace: the seven Eager components in workspace/shell/feature, including the workspace shell root the idle audit found re-rendering on every idle tick. Their templates read signals, signal inputs, computed values and template-event state only; the one plain field written outside the template (categoryLockTarget) is not rendered. They switch to OnPush without other changes. Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- docs/architecture/zoneless-migration.md | 14 +++++++------- .../workspace-command-palette.component.ts | 2 +- ...workspace-collection-context-panel.component.ts | 2 +- .../workspace-context-panel.component.ts | 2 +- .../workspace-settings-context-panel.component.ts | 2 +- ...orkspace-keyboard-shortcuts-dialog.component.ts | 2 +- .../workspace-shell/workspace-shell.component.ts | 2 +- .../workspace-sources.component.ts | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 4d7e804f7..0da8ce02d 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -119,13 +119,13 @@ picker), so each marks itself on the form's `events` through ### libs/workspace (7) -- [ ] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root) -- [ ] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root) +- [x] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts` ### libs/playlist (14) diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts index b0d8f3889..56833b25a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts @@ -45,7 +45,7 @@ interface WorkspaceCommandGroupSection { selector: 'app-workspace-command-palette', imports: [MatDialogModule, MatIconModule, TranslatePipe], templateUrl: './workspace-command-palette.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-command-palette.component.scss', }) export class WorkspaceCommandPaletteComponent implements AfterViewInit { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts index ca0f75f30..70f638bfb 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts @@ -54,7 +54,7 @@ import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/u /> `, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-context-panel.component.scss', }) export class WorkspaceCollectionContextPanelComponent { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index a2b87f5bd..ff449972a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -82,7 +82,7 @@ interface WorkspaceCategoryLike { WorkspaceContextErrorViewComponent, ], templateUrl: './workspace-context-panel.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-context-panel.component.scss', }) export class WorkspaceContextPanelComponent { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index 5a93f9125..cdfb1669c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -11,7 +11,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings selector: 'app-workspace-settings-context-panel', imports: [MatIconModule, RouterLink, RouterLinkActive, TranslateModule], styleUrls: ['./workspace-settings-context-panel.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, template: `

{{ 'SETTINGS.TITLE' | translate }}

diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts index 6140c36b0..07d5c036c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts @@ -19,7 +19,7 @@ export interface WorkspaceKeyboardShortcutsDialogData { selector: 'app-workspace-keyboard-shortcuts-dialog', imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe], templateUrl: './workspace-keyboard-shortcuts-dialog.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-keyboard-shortcuts-dialog.component.scss', }) export class WorkspaceKeyboardShortcutsDialogComponent { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts index a86114ed7..98e433cb5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts @@ -49,7 +49,7 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util'; ], templateUrl: './workspace-shell.component.html', styleUrl: './workspace-shell.component.scss', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ WorkspaceShellFacade, WorkspaceShellRouteStateService, diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts index 76c60d2a3..168d3924e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts @@ -61,7 +61,7 @@ interface SortOption { TranslatePipe, ], templateUrl: './workspace-sources.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-sources.component.scss', }) export class WorkspaceSourcesComponent { From 22a9c1f1e56eb2367dada17bee06591fbfb961ef Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:52:47 +0200 Subject: [PATCH 11/11] perf(web): add an opt-in zoneless change-detection build flag (#1824) * perf(web): add an opt-in zoneless change-detection build flag Plan item C6 step 4. app.config.ts takes its change-detection providers from environments/change-detection.providers.ts, which keeps provideZoneChangeDetection({ eventCoalescing: true }) for every existing build. The new electron-performance-zoneless and electron-e2e-zoneless web configurations are their base configuration plus one fileReplacements swap to provideZonelessChangeDetection(), so the journeys and the Electron E2E suite can run zoneless while nothing ships it. zone.js stays in the polyfills until the flip. A build-config test pins each *-zoneless configuration to its base plus the swap and refuses the swap anywhere else. Co-Authored-By: Claude Opus 5.5 * fix(epg): schedule the guide's post-render scroll without zone.js The programme guide jumps to now once the virtual list first renders rows, and focuses cells after keyboard scrolls, from afterNextRender hooks registered in CDK and RxJS callbacks. zone.js followed those callbacks with a tick; under zoneless change detection a render hook schedules no render, so the guide opened at midnight (epg-guide.e2e.ts on the zoneless build). The guide now marks itself when it registers one, which is harmless with zone.js. Co-Authored-By: Claude Opus 5.5 * docs(performance): record the zoneless flag measurements and E2E run Co-Authored-By: Claude Opus 5.5 * docs(performance): say the zoneless flag ran with the three implemented journeys Review follow-up (Greptile): J4 search is still planned, so the flag was validated with J1-J3 and the Electron E2E suite, not all four journeys. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../performance-build-config.spec.ts | 46 +++++++++++- apps/web/project.json | 38 ++++++++++ apps/web/src/app/app.config.ts | 10 +-- .../change-detection.providers.ts | 12 ++++ .../change-detection.providers.zoneless.ts | 11 +++ docs/architecture/zoneless-migration.md | 70 +++++++++++++++++-- .../src/lib/epg-guide/epg-guide.component.ts | 21 ++++-- 7 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/environments/change-detection.providers.ts create mode 100644 apps/web/src/environments/change-detection.providers.zoneless.ts diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 0ff7caa34..91a8701f4 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -10,6 +10,7 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe'; interface TargetConfiguration { configurations?: Record>; + defaultConfiguration?: string; dependsOn?: unknown; executor?: unknown; options?: Record; @@ -162,7 +163,12 @@ test('only the web performance build installs the tick counter the journeys read for (const [name, configuration] of Object.entries( webProject.targets['build'].configurations ?? {} )) { - if (name === 'electron-performance') continue; + if ( + name === 'electron-performance' || + name === 'electron-performance-zoneless' + ) { + continue; + } assert.doesNotMatch( JSON.stringify(configuration['fileReplacements'] ?? []), /environment\.performance/, @@ -171,6 +177,44 @@ test('only the web performance build installs the tick counter the journeys read } }); +// Plan item C6 measures zoneless change detection behind a build-time flag: +// each *-zoneless configuration is its base configuration plus one swap of +// the change-detection providers, and nothing else selects that swap. +test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => { + const configurations = webProject.targets['build'].configurations ?? {}; + const zonelessReplacement = { + replace: 'apps/web/src/environments/change-detection.providers.ts', + with: 'apps/web/src/environments/change-detection.providers.zoneless.ts', + }; + + for (const base of ['electron-performance', 'electron-e2e']) { + const baseConfiguration = configurations[base]; + const zoneless = configurations[`${base}-zoneless`]; + assert.ok(zoneless, `web:build must define ${base}-zoneless`); + assert.ok(baseConfiguration, `web:build must define ${base}`); + const { fileReplacements: baseReplacements, ...baseRest } = + baseConfiguration; + const { fileReplacements, ...rest } = zoneless; + assert.deepEqual(rest, baseRest, base); + assert.deepEqual(fileReplacements, [ + ...((baseReplacements as unknown[] | undefined) ?? []), + zonelessReplacement, + ]); + } + for (const [name, configuration] of Object.entries(configurations)) { + if (name.endsWith('-zoneless')) continue; + assert.doesNotMatch( + JSON.stringify(configuration['fileReplacements'] ?? []), + /change-detection\.providers/, + name + ); + } + assert.equal( + webProject.targets['build'].defaultConfiguration, + 'production' + ); +}); + test('the resolved web build cache output is the renderer directory', () => { const task = readResolvedWebBuildTask(); diff --git a/apps/web/project.json b/apps/web/project.json index 7244c4ac5..74c415311 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -143,12 +143,50 @@ } ] }, + "electron-performance-zoneless": { + "baseHref": "./", + "serviceWorker": false, + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false, + "removeSpecialComments": true + }, + "fonts": true + }, + "outputHashing": "all", + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/environment.ts", + "with": "apps/web/src/environments/environment.performance.ts" + }, + { + "replace": "apps/web/src/environments/change-detection.providers.ts", + "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" + } + ] + }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, "optimization": false, "extractLicenses": false, "sourceMap": true + }, + "electron-e2e-zoneless": { + "baseHref": "./", + "serviceWorker": false, + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/change-detection.providers.ts", + "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" + } + ] } }, "defaultConfiguration": "production" diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 1fc0d8950..edd657996 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -8,12 +8,7 @@ import { FullscreenOverlayContainer, OverlayContainer, } from '@angular/cdk/overlay'; -import { - ApplicationConfig, - inject, - importProvidersFrom, - provideZoneChangeDetection, -} from '@angular/core'; +import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core'; import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withComponentInputBinding } from '@angular/router'; @@ -39,6 +34,7 @@ import { } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../environments/environment'; +import { changeDetectionProviders } from '../environments/change-detection.providers'; import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; import { ExternalPlaybackService } from './services/external-playback.service'; @@ -110,7 +106,7 @@ export function DataFactory() { export const appConfig: ApplicationConfig = { providers: [ - provideZoneChangeDetection({ eventCoalescing: true }), + ...changeDetectionProviders, provideRouter(routes, withComponentInputBinding()), provideAnimations(), // CDK overlays (menus, tooltips, dialogs) live in a container under diff --git a/apps/web/src/environments/change-detection.providers.ts b/apps/web/src/environments/change-detection.providers.ts new file mode 100644 index 000000000..d0cf2caa0 --- /dev/null +++ b/apps/web/src/environments/change-detection.providers.ts @@ -0,0 +1,12 @@ +import { + EnvironmentProviders, + provideZoneChangeDetection, +} from '@angular/core'; + +// Change detection for every build: zone.js schedules the ticks. The +// *-zoneless build configurations replace this file with +// change-detection.providers.zoneless.ts while plan item C6 measures +// zoneless change detection; see docs/architecture/zoneless-migration.md. +export const changeDetectionProviders: EnvironmentProviders[] = [ + provideZoneChangeDetection({ eventCoalescing: true }), +]; diff --git a/apps/web/src/environments/change-detection.providers.zoneless.ts b/apps/web/src/environments/change-detection.providers.zoneless.ts new file mode 100644 index 000000000..30e71ff06 --- /dev/null +++ b/apps/web/src/environments/change-detection.providers.zoneless.ts @@ -0,0 +1,11 @@ +import { + EnvironmentProviders, + provideZonelessChangeDetection, +} from '@angular/core'; + +// Swapped in for change-detection.providers.ts by the *-zoneless build +// configurations only. zone.js stays in the polyfills until the flip, so +// Angular logs NG0914 in these builds; nothing patches through it. +export const changeDetectionProviders: EnvironmentProviders[] = [ + provideZonelessChangeDetection(), +]; diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 0da8ce02d..08dddfcec 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -50,9 +50,10 @@ must be ticked here. playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`), `apps/web`. Each reports the tick counters before and after and runs the affected unit and E2E tests. -4. [ ] `provideZonelessChangeDetection()` behind a build-time - `fileReplacements` flag, off by default; all four journeys and the - Electron E2E suite run with it on. +4. [x] `provideZonelessChangeDetection()` behind a build-time + `fileReplacements` flag, off by default; the three implemented journeys + (J1-J3; J4 is still planned) and the Electron E2E suite run with it on + (see [Zoneless flag](#zoneless-flag)). 5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines (`renderer.cdTicksIdle30s` and any counter that becomes deterministic once the zone.js race is gone). @@ -181,7 +182,8 @@ the field a signal (or a `computed`), or writes it through one. | [ ] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal | | [ ] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal | | [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal | -| [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; no fix needed: a value-only `patchValue` re-renders the OnPush sections (`settings.component.form.spec.ts` guards it) | apps/web | +| [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; each section now marks itself on its form's `events` (`markSectionForCheckOnFormEvents`), which `settings.component.form.spec.ts` guards without a forced render | apps/web | +| [x] | `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts` (jump to now, keyboard focus) | `afterNextRender` registered from CDK/RxJS callbacks; zone.js followed them with a tick, zoneless schedules no render, so the guide opened at midnight. It now marks itself when it registers the hook. Found by `epg-guide.e2e.ts` on the zoneless build | flag | | [ ] | `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` | plain `isLoading`/`error`/`status` written after `await` and from a 2 s `setInterval` | only if `apps/remote-control-web` goes zoneless | ## Explicit zone and change-detector calls @@ -251,6 +253,66 @@ their events arrive over IPC. `apps/web/src/setup-jest.ts` (no project, tsconfig or Jest config uses them). +## Zoneless flag + +`app.config.ts` takes its change-detection providers from +`apps/web/src/environments/change-detection.providers.ts` +(`provideZoneChangeDetection({ eventCoalescing: true })`). The +`electron-performance-zoneless` and `electron-e2e-zoneless` web +configurations are their base configuration plus one `fileReplacements` +swap to `change-detection.providers.zoneless.ts` +(`provideZonelessChangeDetection()`); a test in +`performance-build-config.spec.ts` pins that and refuses the swap in any +other configuration. zone.js stays in the polyfills, so these builds log +NG0914 in dev mode and nothing schedules through the zone. The Electron app +loads the renderer from `dist/apps/web`, so rebuilding only the web app +switches an existing Electron build: + +```bash +pnpm nx run electron-backend:build-performance # or build-e2e +pnpm nx run web:build:electron-performance-zoneless # or electron-e2e-zoneless +cd apps/electron-backend-e2e +../../node_modules/.bin/playwright test --config=playwright.journeys.config.ts +../../node_modules/.bin/playwright test --grep-invert packaged +``` + +Do not run `pnpm run perf:journeys` or `pnpm nx run electron-backend-e2e:e2e` +afterwards: their build dependencies restore the zone.js renderer. + +First measurement (macOS, 2026-10-04, the six OnPush PRs merged locally on +the flag branch; the same integration build measured with the flag off and +on, five iterations each): + +| Counter | flag off | flag on | +| --- | --- | --- | +| `renderer.cdTicksToFirstCard` | 22, 20, 22, 21, 21 | 7, 7, 7, 7, 7 | +| `renderer.cdTicksIdle30s` | 4, 4, 4, 4, 4 | 3, 3, 3, 3, 3 | +| `renderer.cdTicksToFirstPage` | 22 (all) | 8 (all) | +| `renderer.cdTicksToPlaying` | 16, 15, 15, 17, 15 | 6, 8, 10, 10, 7 | +| DOM mutations J1 / J2 / J3 | 553 / 1,603 / 6,182 | 553 / 1,603 / 6,182 (one J3 iteration 6,199, as on master) | +| `spawnToFirstCardMs` p50 | 3,071 | 828 | +| `clickToFirstPageMs` p50 | 82.8 | 80.4 | +| `clickToLoadedMetadataMs` / `clickToPlayingMs` p50 | 94.8 / 268.7 | 158.3 / 409.3 | + +J1 and J2 tick counts become deterministic without the zone.js one-tick +race, and the DOM mutations are unchanged, so nothing renders differently. +J3's tick count still varies with player events and its wall-clock +numbers rose locally; the machine was shared with other runs (load 26 to 58 +during these two runs, master itself read 363.8 ms `clickToPlayingMs` p50 +earlier the same day), so judge J3 on the CI runner before the flip. + +Electron E2E suite on `electron-e2e-zoneless` (all specs except the +packaged frame-copy ones): 203 passed, 7 skipped, 3 failed. `epg-guide` +failed on every run and is fixed above; `playlist-auto-refresh` passed on +`--repeat-each=2`; `dash-clearkey` "reopens from recent and favorites" is the +known local flake (it fails as often on master). The IPC-driven paths +passed zoneless: external-player launch states and the MPV/VLC DASH +fallbacks in `dash-clearkey`, the MPV double-click gate in `settings`, +`remote-control`, `picture-in-picture` and `stream-info`. Their state reaches +the renderer over IPC into signals and never ran in the zone. Embedded MPV +playback itself is covered only by the packaged frame-copy E2E, which these +runs left out; run it on a packaged zoneless build before the flip. + ## Measuring a PR Build `electron-performance` and run the journeys as described in diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index dfed6d379..c6ba666d3 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -6,6 +6,7 @@ import { DatePipe } from '@angular/common'; import { afterNextRender, ChangeDetectionStrategy, + ChangeDetectorRef, Component, computed, DestroyRef, @@ -83,6 +84,7 @@ export class EpgGuideComponent implements OnDestroy { private readonly translate = inject(TranslateService); private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); + private readonly changeDetector = inject(ChangeDetectorRef); private readonly hostElement = inject>(ElementRef); readonly close = output(); @@ -185,8 +187,7 @@ export class EpgGuideComponent implements OnDestroy { activeRow: () => this.activeRowIndex(), ensureLoaded: (channels) => this.programsService.ensureLoaded(channels), setScrollLeft: (left) => this.view.scrollLeft.set(left), - afterRender: (callback) => - afterNextRender(callback, { injector: this.injector }), + afterRender: (callback) => this.afterNextGuideRender(callback), }); private readonly dialogs = new EpgGuideDialogController( @@ -237,15 +238,23 @@ export class EpgGuideComponent implements OnDestroy { this.viewportController.whenRowsRendered( viewport, this.destroyRef, - () => - afterNextRender(() => this.jumpNow(false), { - injector: this.injector, - }) + () => this.afterNextGuideRender(() => this.jumpNow(false)) ); }); }); } + /** + * Run `callback` after the next render. The callers register from CDK + * and RxJS callbacks, outside any template event: zone.js used to follow + * those with a tick, but without it registering a render hook schedules + * no render, so the guide also marks itself for one. + */ + private afterNextGuideRender(callback: () => void): void { + afterNextRender(callback, { injector: this.injector }); + this.changeDetector.markForCheck(); + } + ngOnDestroy(): void { this.view.destroy(); this.search.destroy();