Merge branch 'master' into perf/onpush-libs-workspace

This commit is contained in:
4gray authored and GitHub committed 2026-10-06 14:13:01 +02:00
commit 545dcf548f
58 files changed
+1033 -144

No files matched your search

@@ -0,0 +1,8 @@
---
type: perf
area: electron
---
On Linux the desktop app's window no longer sometimes appears about a second
late at launch: it now opens as soon as the app has loaded, showing the
loading screen until the dashboard is ready.
+1 -1
View File
@@ -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
+32 -4
View File
@@ -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
@@ -324,8 +324,36 @@ 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 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: >-
node tools/performance/check-journey-ratchet.mjs
--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()
uses: actions/upload-artifact@v7
@@ -349,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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
@@ -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"
@@ -12,6 +12,8 @@ export interface JourneyRendererGateState {
readonly gatedEpochMs: number | null;
readonly gatedMethod: string | null;
readonly passThroughLoads: number;
/** `did-finish-load` events kept from the app's listeners on about:blank. */
readonly didFinishLoadHeldOnBlank: number;
/** `ready-to-show` events dropped while the window was on about:blank. */
readonly readyToShowHeldOnBlank: number;
readonly releasedEpochMs: number | null;
@@ -14,6 +14,7 @@ function gate(
blankLoadedEpochMs: 1_050,
errors: [],
gatedEpochMs: 1_020,
didFinishLoadHeldOnBlank: 1,
gatedMethod: 'loadFile',
passThroughLoads: 0,
readyToShowHeldOnBlank: 1,
@@ -22,6 +22,11 @@
* therefore drops `ready-to-show` while the window is on `about:blank`;
* Electron emits it again for the real document's first paint, because the
* window is still hidden, which is the moment production sees.
* The app also shows its window at the main frame's `did-finish-load`
* when that comes first, so the gate keeps the app's `did-finish-load`
* listeners (those registered before the gated load) away from the
* about:blank load too. Electron's own listener that resolves
* `loadURL(about:blank)` is registered later and still runs.
*
* With `ipcMain` passed in, the gate also keeps the listeners registered
* with `ipcMain.handle` for `TAPPED_IPC_CHANNELS`, so the test can call a
@@ -53,6 +58,38 @@ function holdReadyToShowWhileBlank(window, state) {
};
}
function holdDidFinishLoadWhileBlank(window, state) {
const contents = window.webContents;
if (
!contents ||
typeof contents.emit !== 'function' ||
typeof contents.rawListeners !== 'function'
) {
return;
}
const appListeners = contents.rawListeners('did-finish-load');
const originalEmit = contents.emit;
contents.emit = function gatedContentsEmit(eventName, ...args) {
if (eventName !== 'did-finish-load' || !isShowingBlank(window)) {
return originalEmit.call(this, eventName, ...args);
}
state.didFinishLoadHeldOnBlank += 1;
const attached = this.rawListeners(eventName);
const held = appListeners.filter((listener) =>
attached.includes(listener)
);
for (const listener of held) this.removeListener(eventName, listener);
try {
return originalEmit.call(this, eventName, ...args);
} finally {
// Raw listeners keep their `once` wrappers, so a re-added once
// listener still fires once for the real document.
for (const listener of held)
this.prependListener(eventName, listener);
}
};
}
function tapIpcHandlers(ipcMain, channels) {
const handlers = new Map();
const originalHandle = ipcMain.handle;
@@ -78,6 +115,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
errors: [],
gatedEpochMs: null,
gatedMethod: null,
didFinishLoadHeldOnBlank: 0,
passThroughLoads: 0,
readyToShowHeldOnBlank: 0,
releasedEpochMs: null,
@@ -128,6 +166,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
state.gatedEpochMs = now();
state.gatedMethod = method;
holdReadyToShowWhileBlank(this, state);
holdDidFinishLoadWhileBlank(this, state);
try {
await this.webContents.loadURL(BLANK_URL);
state.blankLoadedEpochMs = now();
@@ -4,6 +4,7 @@ import test from 'node:test';
interface GateState {
blankLoadedEpochMs: number | null;
didFinishLoadHeldOnBlank: number;
errors: string[];
gatedEpochMs: number | null;
gatedMethod: string | null;
@@ -155,13 +156,13 @@ test('records a failed about:blank navigation and still loads after release', as
function createEmittingBrowserWindow(log: string[]) {
class EmittingBrowserWindow extends EventEmitter {
url = '';
webContents = {
webContents = Object.assign(new EventEmitter(), {
getURL: () => this.url,
loadURL: async (url: string) => {
this.url = url;
log.push(`webContents.loadURL:${url}`);
},
};
});
async loadFile(file: string): Promise<void> {
this.url = `file:///${file}`;
log.push(`loadFile:${file}`);
@@ -201,6 +202,47 @@ test('holds ready-to-show while the window shows about:blank, then lets the real
assert.equal(api.state.readyToShowHeldOnBlank, 1);
});
test('keeps did-finish-load of about:blank from the app listeners, not from later ones', async () => {
const log: string[] = [];
const EmittingBrowserWindow = createEmittingBrowserWindow(log);
const api = gateModule.installJourneyRendererGate(
EmittingBrowserWindow as unknown as {
prototype: Record<string, unknown>;
},
{},
{ timeoutMs: 60_000 }
);
const window = new EmittingBrowserWindow();
// The app shows its window at the first did-finish-load.
window.webContents.once('did-finish-load', () =>
log.push('app:did-finish-load')
);
const load = window.loadFile('index.html');
await settle();
// Registered after the gated load, like Electron's own listener that
// resolves loadURL(about:blank).
window.webContents.on('did-finish-load', () =>
log.push('electron:did-finish-load')
);
window.webContents.emit('did-finish-load');
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
api.release();
await load;
window.webContents.emit('did-finish-load');
window.webContents.emit('did-finish-load');
assert.deepEqual(log, [
'webContents.loadURL:about:blank',
'electron:did-finish-load',
'loadFile:index.html',
'app:did-finish-load',
'electron:did-finish-load',
'electron:did-finish-load',
]);
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
});
test('taps ipcMain.handle for the counters channel and passes registrations through', async () => {
const registered: string[] = [];
const ipcMain: FakeIpcMain = {
@@ -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),
@@ -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;
@@ -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,
};
};
@@ -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',
},
@@ -112,6 +116,7 @@ function measurement(
blankLoadedEpochMs: 1_050,
errors: [],
gatedEpochMs: 1_020,
didFinishLoadHeldOnBlank: 1,
gatedMethod: 'loadFile',
passThroughLoads: 0,
readyToShowHeldOnBlank: 1,
@@ -185,6 +190,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,
@@ -208,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',
},
@@ -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,
@@ -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'], {
@@ -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
),
@@ -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,
@@ -27,8 +27,9 @@ const setWindowState = (
);
/**
* The app creates its window with `show: false` and shows it on
* `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`.
* The app creates its window with `show: false` and shows it at
* `ready-to-show` or `did-finish-load`, whichever comes first; a `hide()`
* sent earlier would be undone by that `show()`.
*/
async function waitUntilShown(app: UnautomatedElectronApp): Promise<void> {
await expect
+33
View File
@@ -75,11 +75,14 @@ type MockMainWindow = {
maximize: jest.Mock<void, []>;
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
removeListener: jest.Mock<void, [string, (...args: unknown[]) => void]>;
setFullScreen: jest.Mock<void, [boolean]>;
setMenu: jest.Mock<void, [unknown]>;
show: jest.Mock<void, []>;
webContents: {
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
removeListener: jest.Mock<void, [string, (...args: unknown[]) => void]>;
openDevTools: jest.Mock<void, []>;
setWindowOpenHandler: jest.Mock<void, [unknown]>;
getZoomLevel: jest.Mock<number, []>;
@@ -98,12 +101,18 @@ function createMockMainWindow(): MockMainWindow {
maximize: jest.fn<void, []>(),
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
removeListener: jest.fn<void, [string, (...args: unknown[]) => void]>(),
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
setFullScreen: jest.fn<void, [boolean]>(),
setMenu: jest.fn<void, [unknown]>(),
show: jest.fn<void, []>(),
webContents: {
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
removeListener: jest.fn<
void,
[string, (...args: unknown[]) => void]
>(),
openDevTools: jest.fn<void, []>(),
setWindowOpenHandler: jest.fn<void, [unknown]>(),
getZoomLevel: jest.fn<number, []>().mockReturnValue(0),
@@ -366,6 +375,30 @@ describe('Electron app security helpers', () => {
expect(mainWindow.show).toHaveBeenCalledTimes(1);
});
it('shows the window at did-finish-load when ready-to-show has not come yet', () => {
storeStartupWindowMode('maximized');
const mainWindow = createWindowViaOnReady();
expect(BrowserWindow).toHaveBeenCalledWith(
expect.objectContaining({
show: false,
backgroundColor: '#1f1f23',
})
);
const [loadHandler] = mainWindow.webContents.once.mock.calls
.filter(([eventName]) => eventName === 'did-finish-load')
.map(([, handler]) => handler);
loadHandler();
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
expect(mainWindow.show).toHaveBeenCalledTimes(1);
// The later ready-to-show is a no-op.
fireReadyToShow(mainWindow);
expect(mainWindow.show).toHaveBeenCalledTimes(1);
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
});
it('creates the window fullscreen when the stored mode says so', () => {
storeStartupWindowMode('fullscreen');
+17 -8
View File
@@ -15,6 +15,10 @@ import {
trace,
traceStartupPhase,
} from './services/debug-trace';
import {
MAIN_WINDOW_BACKGROUND_COLOR,
showMainWindowWhenLoaded,
} from './services/main-window-first-show';
import { attachMainWindowPerformanceCounters } from './services/performance-counters';
import {
STARTUP_WINDOW_MODE,
@@ -512,6 +516,9 @@ export default class App {
width: width,
height: height,
show: false,
// The splash colour: the window can be shown before its first
// paint (main-window-first-show.ts).
backgroundColor: MAIN_WINDOW_BACKGROUND_COLOR,
webPreferences: getMainWindowWebPreferences(),
...savedWindowBounds,
// Fullscreen is a constructor option: the window is created
@@ -543,15 +550,17 @@ export default class App {
App.mainWindow.center();
}
// if main window is ready to show, close the splash window and show the main window
App.mainWindow.once('ready-to-show', () => {
// Shown at ready-to-show or did-finish-load, whichever comes first
// (see main-window-first-show.ts).
const mainWindow = App.mainWindow;
showMainWindowWhenLoaded(mainWindow, () => {
// maximize() on a hidden window shows it (Electron docs), so it
// has to wait for ready-to-show like show() does — any earlier
// and a blank window flashes before the renderer paints.
// waits for the document like show() does — any earlier and a
// blank window flashes before the splash is there.
if (startupWindowMode === 'maximized') {
App.mainWindow.maximize();
mainWindow.maximize();
}
App.mainWindow.show();
mainWindow.show();
// macOS ignores the constructor's `fullscreen` while the window
// is hidden — an NSWindow can only toggle fullscreen once it is
// on screen — so the request is repeated after show() wherever
@@ -562,9 +571,9 @@ export default class App {
// asking for it again.
if (
startupWindowMode === 'fullscreen' &&
!App.mainWindow.isFullScreen()
!mainWindow.isFullScreen()
) {
requestFullScreen(App.mainWindow, true);
requestFullScreen(mainWindow, true);
}
});
@@ -0,0 +1,71 @@
import { EventEmitter } from 'events';
import {
type FirstShowWindow,
showMainWindowWhenLoaded,
} from './main-window-first-show';
function createWindow(): FirstShowWindow &
EventEmitter & {
webContents: EventEmitter;
destroyed: boolean;
} {
const window = Object.assign(new EventEmitter(), {
destroyed: false,
webContents: new EventEmitter(),
isDestroyed(): boolean {
return window.destroyed;
},
});
return window;
}
describe('showMainWindowWhenLoaded', () => {
it('shows the window at did-finish-load when ready-to-show has not fired', () => {
// The Linux race: the hidden window gets no frame for its first
// paint, so ready-to-show (and the splash's animation frame) would
// wait about a second after the document has loaded.
const window = createWindow();
const show = jest.fn();
showMainWindowWhenLoaded(window, show);
window.webContents.emit('did-finish-load');
expect(show).toHaveBeenCalledTimes(1);
});
it('shows the window at ready-to-show when that comes first', () => {
const window = createWindow();
const show = jest.fn();
showMainWindowWhenLoaded(window, show);
window.emit('ready-to-show');
expect(show).toHaveBeenCalledTimes(1);
});
it('shows the window only once and detaches the other listener', () => {
const window = createWindow();
const show = jest.fn();
showMainWindowWhenLoaded(window, show);
window.webContents.emit('did-finish-load');
window.emit('ready-to-show');
// A reload loads the document again; the window is already shown.
window.webContents.emit('did-finish-load');
expect(show).toHaveBeenCalledTimes(1);
expect(window.listenerCount('ready-to-show')).toBe(0);
expect(window.webContents.listenerCount('did-finish-load')).toBe(0);
});
it('does not show a window that was destroyed before it loaded', () => {
const window = createWindow();
const show = jest.fn();
showMainWindowWhenLoaded(window, show);
window.destroyed = true;
window.emit('ready-to-show');
expect(show).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
/**
* When the hidden main window is first shown.
*
* The window is created with `show: false` and used to be shown on
* `ready-to-show` only. That event needs the window's first visually
* non-empty paint, and a hidden window does not always get a frame for it:
* on Linux under X11, when the startup scripts run before that frame, the
* next one comes about a second later. Until then nothing is on screen and
* the renderer gets no animation frames, so the splash that `main.ts`
* removes in a `requestAnimationFrame` stays even after the dashboard has
* rendered (J1 on the CI runner: about 450 ms later to the first card, in
* roughly one launch out of three, see docs/architecture/performance-journeys.md).
*
* The window is therefore shown at whichever comes first: `ready-to-show`
* or the main frame's `did-finish-load`. At `did-finish-load` the inline
* splash is parsed and styled, and the window's `backgroundColor` matches
* it, so showing before the first paint does not flash.
*/
/** Matches `#initial-splash` in `apps/web/src/index.html`. */
export const MAIN_WINDOW_BACKGROUND_COLOR = '#1f1f23';
type OnceEmitter = {
once(event: string, listener: () => void): unknown;
removeListener(event: string, listener: () => void): unknown;
};
export type FirstShowWindow = OnceEmitter & {
isDestroyed(): boolean;
readonly webContents: OnceEmitter;
};
/** Calls `show` once, at `ready-to-show` or `did-finish-load`, whichever comes first. */
export function showMainWindowWhenLoaded(
window: FirstShowWindow,
show: () => void
): void {
let shown = false;
const showOnce = (): void => {
if (shown) {
return;
}
shown = true;
window.removeListener('ready-to-show', showOnce);
window.webContents.removeListener('did-finish-load', showOnce);
if (!window.isDestroyed()) {
show();
}
};
window.once('ready-to-show', showOnce);
window.webContents.once('did-finish-load', showOnce);
}
@@ -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/)
@@ -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.
@@ -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
+1 -1
View File
@@ -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,
+193 -33
View File
@@ -89,7 +89,14 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
show a blank window and freeze its `ready-to-show` counter before its own
document exists. Electron emits the event again for the real document's
first paint because the window is still hidden, which is the moment
production sees. The gate also keeps the listener the app registers with
production sees. The app also shows its window at the main frame's
`did-finish-load` when that comes first (see
[When the window is shown](#when-the-window-is-shown)), so the gate keeps
the `did-finish-load` listeners registered before the gated load (the
app's) away from the `about:blank` load as well
(`evidence.rendererGateDidFinishLoadHeldOnBlank`, 1 per launch); Electron's
own listener that resolves `loadURL('about:blank')` is registered later
and still runs. The gate also keeps the listener the app registers with
`ipcMain.handle('performance:read-counters')`, so the test can call it from
the main process.
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
@@ -168,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
@@ -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
@@ -544,9 +627,10 @@ PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3).
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.
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
@@ -635,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. |
@@ -780,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`
@@ -859,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 <journey>/<counter>` (repeatable) restricts the check to the named
baselines. A script that measures one counter writes its own summary file
@@ -957,25 +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.
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.
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.
#### Enforced journey counters
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.
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:
| 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
@@ -1000,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
@@ -1019,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
@@ -1067,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
+7 -2
View File
@@ -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/<timestamp>/summary.json
pnpm run perf:journeys # J1 launch, J2 open-source and J3 playback journeys, one dist/performance/journeys/<timestamp>/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 <branch>` for a stacked branch). The weekly
`gh workflow run ci.yml --ref <branch>` 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 <branch>`,
which measures but opens no PR off `master`. Dispatch needs the workflow file
+13 -4
View File
@@ -510,15 +510,24 @@ Startup window mode (`Settings.startupWindowMode`, issue #1455):
3. `fullscreen` is the `BrowserWindow` constructor option: on Windows/Linux
the window is created hidden and enters fullscreen before its first
paint. macOS ignores the option while the window is hidden (an NSWindow
only toggles fullscreen once it is on screen), so `ready-to-show` repeats
only toggles fullscreen once it is on screen), so the first show repeats
the request with `setFullScreen(true)` right after `show()` wherever
`isFullScreen()` is still false — never unconditionally, or the
platforms that honoured the option would animate a second toggle. The
saved bounds stay spread into the options — they are the normal bounds
the window returns to, and the close handler keeps persisting
`getNormalBounds()`. `maximized` calls `maximize()` inside
`ready-to-show` right before `show()`, never earlier: `maximize()` on a
hidden window shows it, and a blank window would flash.
`getNormalBounds()`. `maximized` calls `maximize()` right before the
first `show()`, never earlier: `maximize()` on a hidden window shows it,
and a blank window would flash. That first show happens at
`ready-to-show` or the main frame's `did-finish-load`, whichever comes
first (`services/main-window-first-show.ts`): on Linux a hidden window
whose startup scripts ran before its first frame gets the next one about
a second later, so `ready-to-show` alone left the window off screen and
the splash's animation frame waiting. At `did-finish-load` the inline
splash is parsed, and the window's `backgroundColor` is the splash colour
(`MAIN_WINDOW_BACKGROUND_COLOR`, keep it in sync with `#initial-splash`
in `apps/web/src/index.html`), so showing before the first paint does
not flash.
4. `iptvnator --fullscreen` (read via `app.commandLine.hasSwitch`, so it can
sit anywhere in argv; the playlist-path extractor already skips every
`-`-prefixed argument) forces `fullscreen` for that launch only and is
+13 -13
View File
@@ -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.
@@ -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,
@@ -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)
@@ -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<ShakaErrorLike> | null | undefined) {
if (
error?.category !== SHAKA_ERROR_CATEGORY.NETWORK ||
@@ -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 {
@@ -231,8 +231,12 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
.overrideComponent(VideoPlayerComponent, {
set: {
imports: [],
template:
'<ng-template #fullscreenChannelPanel></ng-template>',
// The real template's channel-number overlay, so the
// OnPush timer test below can read the rendered state.
template: `<ng-template #fullscreenChannelPanel></ng-template>
@if (showChannelNumberOverlay()) {
<div class="channel-number-overlay">{{ channelNumberInput() }}</div>
}`,
},
})
.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]);
@@ -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()) {
<div class="channel-number-overlay">
<div class="channel-number-display">
{{ channelNumberInput }}
{{ channelNumberInput() }}
</div>
</div>
}
@@ -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<Settings> = {
readonly playerSettings = signal<Partial<Settings>>({
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 {
@@ -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 {
@@ -167,7 +167,7 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor
</div>
`,
styleUrls: ['./audio-player.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
MatButtonModule,
@@ -57,7 +57,7 @@ import { TranslateModule } from '@ngx-translate/core';
}
`,
],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatCheckboxModule,
@@ -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 {
@@ -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,
@@ -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 {
@@ -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,
@@ -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 {
+1 -1
View File
@@ -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",
+30 -11
View File
@@ -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
@@ -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';
@@ -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 });
}
/**
+10 -2
View File
@@ -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 <journey>/<counter>` (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(
@@ -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,
+102
View File
@@ -9,6 +9,108 @@
"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"
},
"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"
}
}
}
+1
View File
@@ -11,6 +11,7 @@
"inputs": [
"{projectRoot}/*.mjs",
"{projectRoot}/*.json",
"{workspaceRoot}/.github/workflows/ci.yml",
{ "externalDependencies": ["parse5"] }
],
"options": {
+2 -1
View File
@@ -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 });
}
/**
@@ -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();
+8
View File
@@ -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'],