test(perf): add a settled layout-shift counter to J1 (#1756)

* test(perf): add a settled layout-shift counter to the J1 launch journey

renderer.layoutShiftScore stops at the first-card cutoff, so the dashboard
shift fixed in #1738 (about 0.23, roughly 15 ms after the first card) read
as 0. renderer.layoutShiftScoreSettled sums the same non-input layout-shift
entries until the workspace content has been quiet for 500 ms, capped at
3 s after the cutoff. The first-card counter is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): attribute late layout shifts and record the first measurement

evidence.settle.lateShifts lists the counted shifts after the first-card
cutoff with the nodes the browser attributes them to. On master the settled
score is 0.236: the recent-sources rail moves up 316 px about 12 ms after
the first card and back down shortly after, a flicker #1738 did not cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): end the settle window at its scheduled deadline

A cap or quiet timer delayed by a busy main thread used the moment it ran
as the settle point, so shifts after the 3 s cap could enter the settled
counter. The settle point is now the deadline the timer was scheduled for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(perf): record the runner's settled layout-shift measurement

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-30 11:27:57 +02:00
1 parent 0bdad04356
commit 58c54a3286
6 files changed
+693 -24

No files matched your search

@@ -13,6 +13,9 @@ import {
JOURNEY_OPEN_SOURCE_PROBE_STATE_KEY,
JOURNEY_OPEN_SOURCE_START_SENTINEL_ID,
JOURNEY_PROBE_STATE_KEY,
JOURNEY_SETTLE_CAP_MS,
JOURNEY_SETTLE_QUIET_MS,
JOURNEY_SETTLE_ROOT_SELECTOR,
journeyRendererProbeScript,
type JourneyRendererProbeOptions,
type JourneyRendererProbeState,
@@ -22,6 +25,11 @@ interface FakeEntry {
duration?: number;
entryType: string;
hadRecentInput?: boolean;
sources?: {
currentRect: { height: number; y: number };
node: unknown;
previousRect: { height: number; y: number };
}[];
startTime: number;
value?: number;
}
@@ -45,7 +53,14 @@ interface Fixture {
const PAGE = `<!doctype html><html><head></head><body class="mat-app-background">
<div id="initial-splash" role="status"><span>IPTVnator</span></div>
<app-root></app-root></body></html>`;
<app-root><main class="workspace-content"></main></app-root></body></html>`;
/** Short settle timers so the launch fixtures do not wait 500 ms each. */
const FAST_SETTLE = {
capMs: 1_000,
quietMs: 30,
rootSelector: JOURNEY_SETTLE_ROOT_SELECTOR,
} as const;
function installFakePerformance(
window: JSDOM['window'],
@@ -114,7 +129,11 @@ function createFixture(
});
return createFixtureFromDom(
dom,
{ ...createLaunchJourneyProbeOptions(), ...optionOverrides },
{
...createLaunchJourneyProbeOptions(),
settle: FAST_SETTLE,
...optionOverrides,
},
bridge
);
}
@@ -171,16 +190,20 @@ const liveStates: (() => JourneyRendererProbeState)[] = [];
/**
* Waits `ms`, then until every probe that reached its terminal batch has also
* passed the post-paint cutoff (a rAF plus a timer). A fixed delay alone
* flakes when the harness runs all spec files in parallel.
* passed the post-paint cutoff (a rAF plus a timer) and closed its settle
* window. A fixed delay alone flakes when the harness runs all spec files in
* parallel.
*/
async function settle(ms = 40): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
const deadline = Date.now() + 2_000;
const deadline = Date.now() + 3_000;
while (
liveStates.some((read) => {
const state = read();
return state.terminal !== null && !state.final;
return (
state.terminal !== null &&
(!state.final || state.settle.status === 'pending')
);
}) &&
Date.now() < deadline
) {
@@ -193,7 +216,7 @@ function renderFirstCard(fixture: Fixture): void {
document.getElementById('initial-splash')?.remove();
const rail = document.createElement('section');
rail.setAttribute('data-test-id', 'dashboard-recent-sources-rail');
document.querySelector('app-root')?.append(rail);
document.querySelector('main.workspace-content')?.append(rail);
const card = document.createElement('div');
card.setAttribute('data-test-id', 'dashboard-recent-sources-rail-card');
rail.append(card);
@@ -361,6 +384,206 @@ test('sums layout shifts without recent input and counts long tasks over 50 ms u
assert.equal(fixture.state().counters.longTasks, 3);
});
function layoutShift(
startTime: number,
value: number,
hadRecentInput = false
): FakeEntry {
return { entryType: 'layout-shift', hadRecentInput, startTime, value };
}
async function waitFor(
predicate: () => boolean,
timeoutMs = 3_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor timed out');
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
test('keeps summing shifts without recent input after the first-card cutoff until the workspace content is quiet', async () => {
const fixture = createFixture({
settle: { ...FAST_SETTLE, capMs: 5_000, quietMs: 80 },
});
const [observer] = fixture.observers as [FakeObserver];
const now = () => fixture.window.performance.now();
const content = fixture.window.document.querySelector(
'main.workspace-content'
) as HTMLElement;
observer.emit([layoutShift(now(), 0.25)]);
renderFirstCard(fixture);
await waitFor(() => fixture.rawState().final);
const cutoff = fixture.state();
assert.equal(cutoff.counters.layoutShiftScore, 0.25);
assert.equal(cutoff.settle.status, 'pending');
assert.equal(cutoff.settle.observedTarget, 'root');
assert.equal(cutoff.counters.layoutShiftScoreSettled, 0);
assert.equal(observer.disconnected, false);
// A skeleton rail that resolved empty collapses about 15 ms after the
// first card and pulls the rails below upwards (#1738).
const rail = fixture.window.document.createElement('lib-dashboard-rail');
const railSection = fixture.window.document.createElement('section');
railSection.className = 'rail';
railSection.setAttribute('data-test-id', 'dashboard-favorites-rail');
rail.append(railSection);
const collapse = {
...layoutShift(now(), 0.5),
sources: [
{
currentRect: { height: 220, y: 300 },
node: rail,
previousRect: { height: 220, y: 540 },
},
],
};
observer.emit([collapse, layoutShift(now(), 0.3, true)]);
for (let step = 0; step < 3; step += 1) {
await new Promise((resolve) => setTimeout(resolve, 20));
content.append(fixture.window.document.createElement('div'));
}
// Queued, not yet delivered, when the settle point is sampled.
observer.queue.push(
layoutShift(now(), 0.125),
layoutShift(now() + 60_000, 9)
);
// Outside the settle root: does not keep the window open.
fixture.window.document.body.setAttribute('data-late', '1');
await settle();
const state = fixture.state();
assert.equal(state.settle.status, 'quiet');
assert.equal(state.settle.domMutations, 3);
assert.ok(state.settle.epochMs !== null && state.settle.epochMs > 0);
assert.ok(
state.settle.epochMs - (state.settle.lastMutationEpochMs ?? 0) >= 75,
'the quiet period restarts at the last mutation'
);
assert.equal(state.counters.layoutShiftScoreSettled, 0.875);
// Only shifts after the cutoff are attributed; input-flagged ones are not.
assert.deepEqual(
state.settle.lateShifts.map((shift) => [shift.value, shift.sources]),
[
[
0.5,
[
{
deltaHeight: 0,
deltaY: -240,
node: 'lib-dashboard-rail[data-test-id="dashboard-favorites-rail"]',
},
],
],
[0.125, []],
]
);
assert.ok(
state.settle.lateShifts.every((shift) => shift.afterFirstCardMs > 0)
);
// The first-card counters were frozen at the cutoff.
assert.equal(state.counters.layoutShiftScore, 0.25);
assert.equal(state.counters.recentInputLayoutShiftScore, 0);
assert.equal(observer.disconnected, true);
assert.doesNotThrow(() => assertJourneyRendererProbeState(state));
observer.emit([layoutShift(now(), 1)]);
content.append(fixture.window.document.createElement('div'));
await settle();
assert.equal(fixture.state().counters.layoutShiftScoreSettled, 0.875);
assert.equal(fixture.state().settle.domMutations, 3);
});
test('the cap ends the settle window while the workspace content keeps mutating', async () => {
const fixture = createFixture({
settle: { ...FAST_SETTLE, capMs: 250, quietMs: 150 },
});
const [observer] = fixture.observers as [FakeObserver];
const now = () => fixture.window.performance.now();
const { document } = fixture.window;
renderFirstCard(fixture);
await waitFor(() => fixture.rawState().final);
const content = document.querySelector('main.workspace-content');
const ticker = setInterval(() => {
content?.setAttribute('data-tick', String(now()));
}, 10);
try {
observer.emit([layoutShift(now(), 0.0625)]);
await waitFor(() => fixture.rawState().settle.status !== 'pending');
observer.emit([layoutShift(now(), 0.5)]);
await new Promise((resolve) => setTimeout(resolve, 50));
} finally {
clearInterval(ticker);
}
const state = fixture.state();
assert.equal(state.settle.status, 'cap');
assert.ok(state.settle.domMutations > 0);
assert.ok(
(state.settle.epochMs ?? 0) - (state.firstCardPaintEpochMs ?? 0) >= 245,
'the cap is measured from the first-card cutoff'
);
assert.equal(state.counters.layoutShiftScoreSettled, 0.0625);
assert.doesNotThrow(() => assertJourneyRendererProbeState(state));
});
test('a late cap timer ends the window at its scheduled deadline', async () => {
const fixture = createFixture({
settle: { ...FAST_SETTLE, capMs: 100, quietMs: 60_000 },
});
const [observer] = fixture.observers as [FakeObserver];
const now = () => fixture.window.performance.now();
renderFirstCard(fixture);
await waitFor(() => fixture.rawState().final);
const cutoffMs =
(fixture.rawState().firstCardPaintEpochMs ?? 0) -
fixture.window.performance.timeOrigin;
observer.emit([layoutShift(now(), 0.25)]);
// Hold the main thread past the cap so its timer runs late, and shift
// the layout after the deadline while it is held.
while (now() < cutoffMs + 250) {
// busy
}
observer.emit([layoutShift(now(), 0.5)]);
observer.queue.push(layoutShift(now(), 1));
await waitFor(() => fixture.rawState().settle.status !== 'pending');
const state = fixture.state();
assert.equal(state.settle.status, 'cap');
const settledAfterCutoff =
(state.settle.epochMs ?? 0) - (state.firstCardPaintEpochMs ?? 0);
assert.ok(
settledAfterCutoff >= 100 && settledAfterCutoff < 150,
`settle point at the deadline, not when the timer ran (${settledAfterCutoff} ms)`
);
assert.equal(state.counters.layoutShiftScoreSettled, 0.25);
});
test('watches the document element when the settle root is missing', async () => {
const fixture = createFixture({
settle: { ...FAST_SETTLE, rootSelector: 'app-missing-root' },
});
renderFirstCard(fixture);
await settle();
const state = fixture.state();
assert.equal(state.settle.observedTarget, 'documentElement');
assert.equal(state.settle.status, 'quiet');
});
test('refuses a probe whose settle window is still open', async () => {
const fixture = createFixture({
settle: { ...FAST_SETTLE, capMs: 60_000, quietMs: 60_000 },
});
renderFirstCard(fixture);
await waitFor(() => fixture.rawState().final);
assert.throws(
() => assertJourneyRendererProbeState(fixture.state()),
/settle-pending/
);
// Mark the window closed so later tests' `settle()` does not wait on it,
// and stop its timers so they do not keep the runner alive.
fixture.rawState().settle.status = 'cap';
fixture.window.close();
});
test('does not end while the splash is present, off the workspace route, or before a card is visible', async () => {
const withSplash = createFixture();
const rail = withSplash.window.document.createElement('div');
@@ -437,6 +660,13 @@ test('launch options target the workspace source cards and the shared sentinel',
assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
assert.match(options.cardSelector, /app-playlist-item/);
assert.deepEqual(options.settle, {
capMs: JOURNEY_SETTLE_CAP_MS,
quietMs: JOURNEY_SETTLE_QUIET_MS,
rootSelector: 'main.workspace-content',
});
assert.equal(JOURNEY_SETTLE_QUIET_MS, 500);
assert.equal(JOURNEY_SETTLE_CAP_MS, 3_000);
});
// J2 "Open a source": the probe is armed in a loaded document and starts at
@@ -643,6 +873,10 @@ 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);
// J2 has no settle window: the observers close at the cutoff.
assert.equal(state.settle.status, 'disabled');
assert.equal(state.counters.layoutShiftScoreSettled, 0);
assert.ok(fixture.observers.every((observer) => observer.disconnected));
assert.equal(state.counters.longTasks, 1);
assert.deepEqual(state.longTaskDurationsMs, [90]);
});
@@ -12,7 +12,9 @@ import type { Page } from '@playwright/test';
* `window` (which runs before any listener of the app) and starts counting at
* the first click inside `startClick.selector`. It counts DOM mutations,
* layout shifts and long tasks until the journey's terminal condition and
* then emits one JSON blob under `options.stateKey`.
* then emits one JSON blob under `options.stateKey`. With `options.settle`
* (J1) it keeps summing layout shifts after the first card until the page
* has settled, for shifts such as collapsing skeletons that land later.
*
* IPC invocations are not counted here: the bridge object exposed by
* `contextBridge` is frozen, so the probe cannot wrap it. Instead the probe
@@ -50,6 +52,23 @@ export interface JourneyRendererProbeStartClick {
readonly sentinelId: string;
}
/**
* J1's settle window after the first card: it ends once nothing under
* `rootSelector` has mutated for `quietMs`, or `capMs` after the first-card
* cutoff, whichever comes first. See docs/architecture/performance-journeys.md.
*/
export interface JourneyRendererProbeSettleOptions {
readonly capMs: number;
readonly quietMs: number;
/** Falls back to the document element when nothing matches. */
readonly rootSelector: string;
}
export const JOURNEY_SETTLE_QUIET_MS = 500;
export const JOURNEY_SETTLE_CAP_MS = 3_000;
/** The workspace shell's content pane; the rail and header stay outside. */
export const JOURNEY_SETTLE_ROOT_SELECTOR = 'main.workspace-content';
export interface JourneyRendererProbeOptions {
/** Selector for the element whose visibility ends the journey. */
readonly cardSelector: string;
@@ -59,6 +78,8 @@ export interface JourneyRendererProbeOptions {
/** Pathname fragment the terminal route must contain. */
readonly routeFragment: string;
readonly sentinelId: string;
/** Absent: no settle window, the probe ends at the first-card cutoff. */
readonly settle?: JourneyRendererProbeSettleOptions;
readonly sentinelMethod: string;
/** Element id of the inline splash that must be gone at the end. */
readonly splashId: string;
@@ -71,6 +92,11 @@ export interface JourneyRendererProbeCounters {
domMutations: number;
/** Shifts with `hadRecentInput === false` (the CLS definition). */
layoutShiftScore: number;
/**
* The same filter from the journey's start until the settle point. Zero
* while `settle.status` is `pending` or `disabled`.
*/
layoutShiftScoreSettled: number;
longTasks: number;
/**
* Shifts with `hadRecentInput === true`. Zero for J1, which has no
@@ -79,6 +105,18 @@ export interface JourneyRendererProbeCounters {
recentInputLayoutShiftScore: 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. */
readonly sources: readonly {
readonly deltaHeight: number;
readonly deltaY: number;
readonly node: string;
}[];
readonly value: number;
}
export interface JourneyRendererProbeState {
readonly capabilities: {
changeDetectionTicks: string;
@@ -113,6 +151,20 @@ export interface JourneyRendererProbeState {
readonly epochMs: number | null;
readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
};
/** `final` freezes the first-card counters; the settle window ends later. */
settle: {
/** Mutation records under the settle root after the cutoff. */
domMutations: number;
epochMs: number | null;
lastMutationEpochMs: number | null;
/**
* Counted shifts after the first-card cutoff, at most 20, with the
* nodes that moved, so a late shift can be traced to its component.
*/
lateShifts: JourneyRendererProbeLateShift[];
observedTarget: 'documentElement' | 'root' | null;
status: 'cap' | 'disabled' | 'pending' | 'quiet';
};
start: {
/** `min(event.timeStamp, listener time)` as epoch milliseconds. */
readonly epochMs: number;
@@ -145,6 +197,7 @@ export function journeyRendererProbeScript(
}
const epoch = (): number => performance.timeOrigin + performance.now();
const startClick = options.startClick ?? null;
const settleOptions = options.settle ?? null;
const companionSelectors = options.companionSelectors ?? [];
const bridge = target['electron'] as Record<string, unknown> | undefined;
const state: JourneyRendererProbeState = {
@@ -159,6 +212,7 @@ export function journeyRendererProbeScript(
counters: {
domMutations: 0,
layoutShiftScore: 0,
layoutShiftScoreSettled: 0,
longTasks: 0,
recentInputLayoutShiftScore: 0,
},
@@ -178,6 +232,14 @@ export function journeyRendererProbeScript(
preStart: { domMutations: 0, lastMutationEpochMs: null },
schemaVersion: 1,
sentinel: { epochMs: null, status: 'not-sent' },
settle: {
domMutations: 0,
epochMs: null,
lastMutationEpochMs: null,
lateShifts: [],
observedTarget: null,
status: settleOptions === null ? 'disabled' : 'pending',
},
start: null,
terminal: null,
};
@@ -249,13 +311,24 @@ export function journeyRendererProbeScript(
// cutoff wait here so the cutoff applies to them as well.
const pendingLayoutShifts: PerformanceEntry[] = [];
const pendingLongTasks: PerformanceEntry[] = [];
// Every layout shift delivered until the settle point, kept apart from
// the first-card path so that counter stays exactly as it was; the
// settle bound is applied once it is known.
const settleLayoutShifts: PerformanceEntry[] = [];
const collectSettleShifts = (entries: readonly PerformanceEntry[]) => {
if (state.settle.status === 'pending') {
settleLayoutShifts.push(...entries);
}
};
const observe = (
type: string,
accept: (entries: readonly PerformanceEntry[], until: number) => void,
pending: PerformanceEntry[]
pending: PerformanceEntry[],
collect: ((entries: readonly PerformanceEntry[]) => void) | null
): PerformanceObserver | null => {
try {
const observer = new PerformanceObserver((list) => {
collect?.(list.getEntries());
if (state.final) return;
if (state.terminal !== null) {
pending.push(...list.getEntries());
@@ -272,23 +345,133 @@ export function journeyRendererProbeScript(
const layoutShiftObserver = observe(
'layout-shift',
acceptLayoutShift,
pendingLayoutShifts
pendingLayoutShifts,
collectSettleShifts
);
const longTaskObserver = observe(
'longtask',
acceptLongTasks,
pendingLongTasks
pendingLongTasks,
null
);
state.capabilities.layoutShift = layoutShiftObserver !== null;
state.capabilities.longTask = longTaskObserver !== null;
const endSettle = (status: 'cap' | 'quiet', untilEpochMs: number) => {
if (layoutShiftObserver) {
collectSettleShifts(layoutShiftObserver.takeRecords());
layoutShiftObserver.disconnect();
}
let score = 0;
for (const entry of settleLayoutShifts) {
const shift = entry as PerformanceEntry & {
hadRecentInput?: boolean;
sources?: readonly LateShiftSource[];
value?: number;
};
if (
typeof shift.value !== 'number' ||
shift.hadRecentInput === true ||
!inWindow(entry, untilEpochMs)
) {
continue;
}
score += shift.value;
const entryEpochMs = performance.timeOrigin + entry.startTime;
if (
entryEpochMs > (state.firstCardPaintEpochMs ?? untilEpochMs) &&
state.settle.lateShifts.length < 20
) {
state.settle.lateShifts.push({
afterFirstCardMs:
entryEpochMs - (state.terminal?.epochMs ?? 0),
sources: (shift.sources ?? []).map(describeSource),
value: shift.value,
});
}
}
state.counters.layoutShiftScoreSettled = score;
state.settle.epochMs = untilEpochMs;
state.settle.status = status;
};
type LateShiftSource = {
currentRect?: { height: number; y: number };
node?: Node | null;
previousRect?: { height: number; y: number };
};
const describeSource = (source: LateShiftSource) => {
const node = source.node;
let label = node ? node.nodeName.toLowerCase() : 'unknown';
if (node instanceof Element) {
const className = node.classList.item(0);
// Component hosts such as `lib-dashboard-rail` carry the test
// id on their first child.
const testId =
node.getAttribute('data-test-id') ??
node.firstElementChild?.getAttribute('data-test-id');
label += className ? `.${className}` : '';
label += testId ? `[data-test-id="${testId}"]` : '';
}
const before = source.previousRect;
const after = source.currentRect;
return {
deltaHeight: before && after ? after.height - before.height : 0,
deltaY: before && after ? after.y - before.y : 0,
node: label,
};
};
// Starts at the first-card cutoff. Every mutation record under the root
// restarts the quiet timer; the cap timer never moves. A timer can run
// late on a busy main thread, so the settle point is the deadline it was
// scheduled for, never the moment it ran: a late timer must not let
// shifts after the deadline into the counter.
const startSettle = (settle: JourneyRendererProbeSettleOptions) => {
const root = document.querySelector(settle.rootSelector);
state.settle.observedTarget =
root === null ? 'documentElement' : 'root';
let quietTimer: ReturnType<typeof setTimeout> | undefined;
let quietDeadlineEpochMs = Number.POSITIVE_INFINITY;
const capDeadlineEpochMs = epoch() + settle.capMs;
const capTimer = setTimeout(() => end('cap'), settle.capMs);
const settleObserver = new MutationObserver((records) => {
if (state.settle.status !== 'pending') return;
state.settle.domMutations += records.length;
state.settle.lastMutationEpochMs = epoch();
armQuiet();
});
const end = (status: 'cap' | 'quiet'): void => {
if (state.settle.status !== 'pending') return;
clearTimeout(quietTimer);
clearTimeout(capTimer);
state.settle.domMutations += settleObserver.takeRecords().length;
settleObserver.disconnect();
const deadline =
status === 'cap' ? capDeadlineEpochMs : quietDeadlineEpochMs;
endSettle(status, Math.min(epoch(), deadline));
};
const armQuiet = (): void => {
clearTimeout(quietTimer);
quietDeadlineEpochMs = epoch() + settle.quietMs;
quietTimer = setTimeout(() => end('quiet'), settle.quietMs);
};
settleObserver.observe(root ?? document.documentElement ?? document, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
armQuiet();
};
const finalize = (untilEpochMs: number): void => {
if (layoutShiftObserver) {
const records = layoutShiftObserver.takeRecords();
collectSettleShifts(records);
acceptLayoutShift(
[...pendingLayoutShifts, ...layoutShiftObserver.takeRecords()],
[...pendingLayoutShifts, ...records],
untilEpochMs
);
layoutShiftObserver.disconnect();
if (settleOptions === null) layoutShiftObserver.disconnect();
}
if (longTaskObserver) {
acceptLongTasks(
@@ -299,6 +482,7 @@ export function journeyRendererProbeScript(
}
state.firstCardPaintEpochMs = untilEpochMs;
state.final = true;
if (settleOptions !== null) startSettle(settleOptions);
};
const callSentinel = (id: string): 'bridge-missing' | 'failed' | 'sent' => {
const method = bridge?.[options.sentinelMethod];
@@ -441,6 +625,11 @@ export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions {
routeFragment: '/workspace',
sentinelId: JOURNEY_IPC_SENTINEL_ID,
sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD,
settle: {
capMs: JOURNEY_SETTLE_CAP_MS,
quietMs: JOURNEY_SETTLE_QUIET_MS,
rootSelector: JOURNEY_SETTLE_ROOT_SELECTOR,
},
splashId: 'initial-splash',
stateKey: JOURNEY_PROBE_STATE_KEY,
};
@@ -501,10 +690,17 @@ export async function waitForJourneyRendererProbe(
stateKey: string,
timeoutMs: number
): Promise<JourneyRendererProbeState> {
// A settle window, where enabled, ends after `final`.
await page.waitForFunction(
(key) =>
(globalThis as unknown as Record<string, { final?: boolean }>)[key]
?.final === true,
(key) => {
const state = (
globalThis as unknown as Record<
string,
{ final?: boolean; settle?: { status?: string } }
>
)[key];
return state?.final === true && state.settle?.status !== 'pending';
},
stateKey,
{ polling: 50, timeout: timeoutMs }
);
@@ -532,6 +728,9 @@ export function assertJourneyRendererProbeState(
) {
throw new Error('journey-renderer-probe-incomplete');
}
if (state.settle.status === 'pending') {
throw new Error('journey-renderer-probe-settle-pending');
}
if (state.invalidReasons.length > 0) {
throw new Error(
`journey-renderer-probe-invalid: ${state.invalidReasons.join(', ')}`
@@ -22,6 +22,7 @@ function measurement(
counters: {
domMutations: 480,
layoutShiftScore: 0.123456789,
layoutShiftScoreSettled: 0.2304999,
longTasks: 2,
recentInputLayoutShiftScore: 0,
},
@@ -44,6 +45,26 @@ function measurement(
preStart: { domMutations: 0, lastMutationEpochMs: null },
schemaVersion: 1,
sentinel: { epochMs: 2_601, status: 'sent' },
settle: {
domMutations: 37,
epochMs: 3_180.06,
lastMutationEpochMs: 2_680,
lateShifts: [
{
afterFirstCardMs: 14.96,
sources: [
{
deltaHeight: 0,
deltaY: -240,
node: 'section.dashboard-rail',
},
],
value: 0.23049,
},
],
observedTarget: 'root',
status: 'quiet',
},
start: null,
terminal: {
cardCount: 2,
@@ -111,6 +132,7 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
'renderer.domMutationsToFirstCard': 480,
'renderer.ipcCallsToFirstCard': 14,
'renderer.layoutShiftScore': 0.123,
'renderer.layoutShiftScoreSettled': 0.23,
'renderer.longTasks': 2,
});
assert.deepEqual(record.wallClock, {
@@ -141,8 +163,28 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
rendererGateBlankLoaded: 1_050,
rendererGateReleased: 1_150,
rendererProbeInstalled: 1_200,
settled: 3_180.06,
spawn: 1_000,
});
assert.deepEqual(record.evidence['settle'], {
domMutations: 37,
firstCardToSettledMs: 580,
lateShifts: [
{
afterFirstCardMs: 15,
sources: [
{
deltaHeight: 0,
deltaY: -240,
node: 'section.dashboard-rail',
},
],
value: 0.2305,
},
],
observedTarget: 'root',
reason: 'quiet',
});
});
test('rejects measurements whose clocks or probes are inconsistent', () => {
@@ -183,6 +225,50 @@ test('rejects measurements whose clocks or probes are inconsistent', () => {
);
});
test('refuses a launch whose settle window did not end after the first-card cutoff', () => {
const base = measurement();
const withSettle = (
settle: Partial<LaunchJourneyMeasurement['renderer']['settle']>
): LaunchJourneyMeasurement => ({
...base,
renderer: {
...base.renderer,
settle: { ...base.renderer.settle, ...settle },
},
});
assert.throws(
() =>
toLaunchIterationRecord(
0,
false,
withSettle({ epochMs: null, status: 'pending' })
),
/settle-pending/
);
assert.throws(
() =>
toLaunchIterationRecord(
0,
false,
withSettle({ epochMs: null, status: 'disabled' })
),
/settle-disabled/
);
assert.throws(
() => toLaunchIterationRecord(0, false, withSettle({ epochMs: 2_640 })),
/settle-quiet/
);
const capped = toLaunchIterationRecord(
0,
false,
withSettle({ epochMs: 5_650, status: 'cap' })
);
assert.equal(
(capped.evidence['settle'] as { reason: string }).reason,
'cap'
);
});
test('names the counters the harness cannot measure yet', () => {
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS), [
'renderer.cdTicksToFirstCard',
@@ -22,6 +22,7 @@ export const LAUNCH_JOURNEY_COUNTER = {
DOM_MUTATIONS: 'renderer.domMutationsToFirstCard',
IPC_CALLS: 'renderer.ipcCallsToFirstCard',
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
LAYOUT_SHIFT_SCORE_SETTLED: 'renderer.layoutShiftScoreSettled',
LONG_TASKS: 'renderer.longTasks',
} as const;
@@ -56,6 +57,11 @@ function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
/** Layout-shift scores keep three decimals; see performance-journeys.md. */
function roundThousandth(value: number): number {
return Math.round(value * 1_000) / 1_000;
}
export function toLaunchIterationRecord(
index: number,
warmup: boolean,
@@ -77,6 +83,15 @@ export function toLaunchIterationRecord(
) {
throw new Error('launch-journey-record-clock-order');
}
const { settle } = renderer;
if (
(settle.status !== 'quiet' && settle.status !== 'cap') ||
settle.epochMs === null ||
renderer.firstCardPaintEpochMs === null ||
settle.epochMs < renderer.firstCardPaintEpochMs
) {
throw new Error(`launch-journey-record-settle-${settle.status}`);
}
if (
renderer.capabilities.changeDetectionTicks !==
'unavailable-ng-global-not-published'
@@ -98,8 +113,11 @@ export function toLaunchIterationRecord(
[LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
renderer.counters.domMutations,
[LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
[LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]:
Math.round(renderer.counters.layoutShiftScore * 1_000) / 1_000,
[LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth(
renderer.counters.layoutShiftScore
),
[LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE_SETTLED]:
roundThousandth(renderer.counters.layoutShiftScoreSettled),
[LAUNCH_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks,
}),
evidence: Object.freeze({
@@ -123,6 +141,7 @@ export function toLaunchIterationRecord(
rendererGateBlankLoaded: measurement.gate.blankLoadedEpochMs,
rendererGateReleased: measurement.gate.releasedEpochMs,
rendererProbeInstalled: renderer.installed.epochMs,
settled: settle.epochMs,
spawn: spawnEpochMs,
}),
firstCard: Object.freeze({
@@ -138,6 +157,21 @@ export function toLaunchIterationRecord(
ipcCallsByMethod: ipc.callsByMethod,
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
observedTarget: renderer.capabilities.observedTarget,
settle: Object.freeze({
domMutations: settle.domMutations,
firstCardToSettledMs: roundTenth(
settle.epochMs - renderer.terminal.epochMs
),
lateShifts: settle.lateShifts.map((shift) =>
Object.freeze({
afterFirstCardMs: roundTenth(shift.afterFirstCardMs),
sources: shift.sources,
value: Math.round(shift.value * 10_000) / 10_000,
})
),
observedTarget: settle.observedTarget,
reason: settle.status,
}),
}),
index,
pid: measurement.pid,
@@ -30,6 +30,7 @@ function measurement(
counters: {
domMutations: 1_596,
layoutShiftScore: 0.0004,
layoutShiftScoreSettled: 0,
longTasks: 1,
recentInputLayoutShiftScore: 0.22061,
},
@@ -49,6 +50,14 @@ function measurement(
preStart: { domMutations: 4, lastMutationEpochMs: 9_100 },
schemaVersion: 1,
sentinel: { epochMs: 10_080.5, status: 'sent' },
settle: {
domMutations: 0,
epochMs: null,
lastMutationEpochMs: null,
lateShifts: [],
observedTarget: null,
status: 'disabled',
},
start: {
epochMs: 10_000.2,
listenerEpochMs: 10_001,
+113 -6
View File
@@ -95,7 +95,8 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
`about:blank` page, so it runs at the start of the real document. It
records that it ran while the document was still `loading` with zero
scripts and emits one JSON blob under `window.__iptvnatorJourneyProbe`.
scripts and emits one JSON blob under `window.__iptvnatorJourneyProbe`,
complete once the [settle window](#settle-window) has closed.
- `journey-main-ipc-capture.ts` subscribes to the preload's renderer-API trace
channel (`IPTVNATOR_DEBUG_TRACE_EVENT`, enabled with
`IPTVNATOR_TRACE_IPC=1`) through `electronApp.evaluate`, also before the
@@ -109,8 +110,81 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
| `renderer.ipcCallsToFirstCard` | `start` trace events the preload emits for every bridge invocation (listener registrations `on*`/`remove*` excluded, as in `wrapElectronApi`). The renderer probe fires one sentinel `cancelSourceProbe('__iptvnator-journey-sentinel__')` at the terminal moment; renderer-to-main IPC is ordered, so events before the sentinel are the exact count. The preload traces the call before forwarding it, and `SOURCE_HEALTH_CANCEL` only looks the id up in an in-memory map, so the sentinel never reaches the database worker. |
| `renderer.domMutationsToFirstCard` | `MutationRecord`s (not callback batches) from a `MutationObserver` on the document element with `childList`, `attributes`, `characterData` and `subtree`. When the init script runs before `<html>` exists the observer watches `document`, which the blob reports in `capabilities.observedTarget`. |
| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. |
| `renderer.layoutShiftScoreSettled` | The same filter from navigation start until the settle point after the first card (see [Settle window](#settle-window)), rounded to three decimals. It catches shifts that land after the cutoff, such as skeletons that collapse once their data resolves. |
| `renderer.longTasks` | `longtask` entries over 50 ms up to that same cutoff, which includes the task that rendered the card. The count depends on machine speed, so it is evidence until a run shows it is stable on the CI runner. |
#### Settle window
`renderer.layoutShiftScore` stops at the first-card cutoff, one frame after
the terminal batch. A shift that lands later is invisible to it: in #1738,
rail skeletons of rails that resolved empty collapsed about 15 ms after the
first card and pulled the rails below upwards, a shift of about 0.23 on every
relaunch with sources that the counter read as 0.
`renderer.layoutShiftScoreSettled` sums the same entries until the page has
settled. The first-card counter is unchanged, so its baselines and history
stay comparable.
The settle window opens at the first-card cutoff. A second
`MutationObserver` watches `main.workspace-content`, the workspace shell's
content pane (the document element if it is missing, reported in
`evidence.settle.observedTarget`). The window closes when nothing in that
subtree has mutated for 500 ms, or 3 s after the cutoff, whichever comes
first. The settle point is the deadline the firing timer was scheduled for
(the last mutation plus 500 ms, or the cutoff plus 3 s), or the moment it
ran if that is earlier, so a timer delayed by a busy main thread does not
let later shifts in. Every entry that starts at or before the settle point
counts, including entries still queued in the observer. Why this point:
- A DOM change in the content pane is what causes the shifts this counter
is after (data resolving, skeletons swapped for content), so quiet in that
subtree is a condition the page reaches, not a guess at a delay. The rail
and header stay outside the watched subtree, so their own updates neither
keep the window open nor hide a shift in the content, which still counts
wherever it happens.
- 500 ms is many frames and well above the round trips to the local mock,
so startup data that is already on its way lands inside the window. On the
J1 profile the content pane goes quiet within about 110 ms of the first
card, so the window closes about 520-610 ms after it.
- The 3 s cap bounds each iteration when something keeps mutating (an
animation, a ticking label). A capped window can end in the middle of that
activity, so `evidence.settle.reason` (`quiet` or `cap`) is recorded for
every iteration, together with `firstCardToSettledMs` and the mutation
records seen (`domMutations`). Iterations that close for different reasons
point at a settle point that is not deterministic; compare them before
trusting `stable`.
Entries with `hadRecentInput === true` are excluded, as for the first-card
counter; J1 has no input. The probe keeps its layout-shift observer open
only for this window: `final` still marks the first-card counters as
frozen, `settle.status` moves from `pending` to `quiet` or `cap`, and the
test waits for both. The record refuses an iteration whose window never
closed or closed before the cutoff. J2's probe has no settle window
(`settle.status` is `disabled`) and its counters are unchanged.
`evidence.settle.lateShifts` lists the counted shifts after the cutoff (at
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.
First local measurement (macOS, 2026-09-29, `master` with #1738): all
windows closed on `quiet`, `renderer.layoutShiftScore` stayed 0, and
`renderer.layoutShiftScoreSettled` was 0.236 in 14 of 15 measured
iterations over three runs (`stable: false` in the first run with one 0,
stable in the other two). Every iteration shows the same two shifts of 0.118:
about 12 ms after the first card the `dashboard-recent-sources-rail`, which
holds the first card, moves up by 316 px, and 12-65 ms later it moves back
down. Something 316 px tall above it is removed and inserted again during
startup, a flicker #1738 did not cover. The counter is working as intended;
the flicker is a separate fix.
On the Linux CI runner (`Performance journeys` job of #1756, run
36618062068) the same flicker is a race: the measured iterations read
`[0, 0, 0.235, 0, 0]` (`stable: false`, every window `quiet` about 540 ms
after the first card), and the one hit shows the same two 316 px moves of
the recent-sources rail.
#### Main-process counters
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
@@ -231,11 +305,18 @@ serial-depth counter is a better guardrail candidate than a raw call count.
},
"journeys": {
"launch": {
"counters": { "renderer.ipcCallsToFirstCard": 12 },
"counters": {
"renderer.ipcCallsToFirstCard": 12,
"renderer.layoutShiftScoreSettled": 0.236
},
"counterStability": {
"renderer.ipcCallsToFirstCard": {
"stable": true,
"values": [12, 12, 12, 12, 12]
},
"renderer.layoutShiftScoreSettled": {
"stable": true,
"values": [0.236, 0.236, 0.236, 0.236, 0.236]
}
},
"wallClock": {
@@ -250,7 +331,27 @@ serial-depth counter is a better guardrail candidate than a raw call count.
"pid": 1,
"counters": {},
"wallClock": {},
"evidence": {}
"evidence": {
"settle": {
"domMutations": 458,
"firstCardToSettledMs": 536.6,
"lateShifts": [
{
"afterFirstCardMs": 12.4,
"sources": [
{
"deltaHeight": 0,
"deltaY": -316,
"node": "lib-dashboard-rail[data-test-id=\"dashboard-recent-sources-rail\"]"
}
],
"value": 0.118
}
],
"observedTarget": "root",
"reason": "quiet"
}
}
}
]
}
@@ -260,7 +361,9 @@ serial-depth counter is a better guardrail candidate than a raw call count.
`journeys.<id>.counters.<name>` and `journeys.<id>.wallClock.<name>` are plain
numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
`tools/performance/journey-baselines.json`. A J1 runtime baseline is added
`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.
@@ -272,8 +375,8 @@ iteration copies it and spawns a fresh process through `runLaunchJourney`,
which measures J1 as usual (gate, probe, IPC capture) and then hands the
running app to `measureOpenSourceJourney` in
`src/journeys/open-source-journey-app.ts`. The click therefore happens after
J1's terminal condition and its counters are final, and the two journeys never
overlap. One warm-up and five measured iterations, as for J1; the J1 numbers
J1's terminal condition and its counters are final, and after J1's settle
window has closed, so the two journeys never overlap. One warm-up and five measured iterations, as for J1; the J1 numbers
of these launches are not reported again. J2 does not read J1's
main-process counters, so its launches run without `IPTVNATOR_PERF_CAPTURE`
and `IPTVNATOR_PERF_COUNT_SQL` (`runLaunchJourney` with
@@ -553,6 +656,10 @@ 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 reads
it as `stable: false` because the dashboard flicker it reports is a race
there (see [Settle window](#settle-window)). Add the runner's number once
that flicker is fixed and the counter is deterministic.
## Charset parse benchmark