diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts index c42b35f59..37f86d4d5 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -341,6 +341,8 @@ test('keeps summing shifts without recent input after the first-card cutoff unti [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'lib-dashboard-rail[data-test-id="dashboard-favorites-rail"]', }, @@ -798,6 +800,13 @@ test('drops performance entries from before the click and keeps recent-input shi { entryType: 'layout-shift', hadRecentInput: true, + sources: [ + { + currentRect: { height: 40, width: 300, x: 48, y: 152 }, + node: fixture.card, + previousRect: { height: 40, width: 320, x: 0, y: 100 }, + }, + ], startTime: now(), value: 0.25, }, @@ -823,6 +832,25 @@ test('drops performance entries from before the click and keeps recent-input shi assert.equal(state.final, true); assert.equal(state.counters.layoutShiftScore, 0.125); assert.equal(state.counters.recentInputLayoutShiftScore, 0.25); + // Each counted shift keeps its nodes; the pre-click ones are not listed. + assert.deepEqual( + state.shifts.map((shift) => [ + shift.hadRecentInput, + shift.value, + shift.sources.map((source) => [ + source.deltaX, + source.deltaY, + source.deltaWidth, + ]), + ]), + [ + [true, 0.25, [[48, 52, -20]]], + [false, 0.125, []], + ] + ); + assert.equal(state.shiftCount, 2); + assert.ok(state.shifts.every((shift) => shift.sinceStartMs >= 0)); + assert.match(state.shifts[0]?.sources[0]?.node ?? '', /^[a-z-]+/); // J2 has no settle window: the observers close at the cutoff. assert.equal(state.settle.status, 'disabled'); assert.equal(state.counters.layoutShiftScoreSettled, 0); @@ -869,6 +897,8 @@ test('rejects a click start whose sentinel could not be sent', async () => { const started = { ...state, sentinel: { epochMs: 1, status: 'sent' as const }, + shiftCount: 0, + shifts: [], }; assert.throws( () => assertJourneyRendererProbeState(started), diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts index 799aeeb48..3fcf9142f 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts @@ -17,9 +17,9 @@ export interface FakeEntry { entryType: string; hadRecentInput?: boolean; sources?: { - currentRect: { height: number; y: number }; + currentRect: { height: number; width?: number; x?: number; y: number }; node: unknown; - previousRect: { height: number; y: number }; + previousRect: { height: number; width?: number; x?: number; y: number }; }[]; startTime: number; value?: number; diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts index 71a321a23..7ee9bac31 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -166,12 +166,24 @@ export interface JourneyRendererProbeCounters { recentInputLayoutShiftScore: number; } +/** A shift counted in `layoutShiftScore` or `recentInputLayoutShiftScore`. */ +export interface JourneyRendererProbeShift { + readonly hadRecentInput: boolean; + /** Entry start minus the journey start (navigation start for J1). */ + readonly sinceStartMs: number; + /** `tag.class[data-test-id]` and the move of each source. */ + readonly sources: JourneyRendererProbeLateShift['sources']; + readonly value: number; +} + export interface JourneyRendererProbeLateShift { /** Entry start minus the first-card terminal epoch. */ readonly afterFirstCardMs: number; - /** `tag.class[data-test-id]` and the vertical move of each source. */ + /** `tag.class[data-test-id]` and the move of each source. */ readonly sources: readonly { readonly deltaHeight: number; + readonly deltaWidth: number; + readonly deltaX: number; readonly deltaY: number; readonly node: string; }[]; @@ -237,6 +249,13 @@ export interface JourneyRendererProbeState { readonly epochMs: number | null; readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent'; }; + /** Every shift counted until the cutoff; `shifts` keeps the first 20. */ + shiftCount: number; + /** + * The first 20 shifts counted until the cutoff, with the nodes that + * moved, so a layout-shift score can be traced to its components. + */ + shifts: JourneyRendererProbeShift[]; /** `final` freezes the first-card counters; the settle window ends later. */ settle: { /** Mutation records under the settle root after the cutoff. */ @@ -329,6 +348,8 @@ export function journeyRendererProbeScript( preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, sentinel: { epochMs: null, status: 'not-sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 0, epochMs: null, @@ -389,6 +410,7 @@ export function journeyRendererProbeScript( for (const entry of entries) { const shift = entry as PerformanceEntry & { hadRecentInput?: boolean; + sources?: readonly LateShiftSource[]; value?: number; }; if ( @@ -397,6 +419,18 @@ export function journeyRendererProbeScript( ) { continue; } + state.shiftCount += 1; + if (state.shifts.length < 20) { + state.shifts.push({ + hadRecentInput: shift.hadRecentInput === true, + sinceStartMs: + performance.timeOrigin + + entry.startTime - + (state.start?.epochMs ?? performance.timeOrigin), + sources: (shift.sources ?? []).map(describeSource), + value: shift.value, + }); + } if (shift.hadRecentInput === true) { state.counters.recentInputLayoutShiftScore += shift.value; continue; @@ -530,10 +564,11 @@ export function journeyRendererProbeScript( state.idle.status = 'done'; }, idle.durationMs); }; + type ShiftRect = { height: number; width?: number; x?: number; y: number }; type LateShiftSource = { - currentRect?: { height: number; y: number }; + currentRect?: ShiftRect; node?: Node | null; - previousRect?: { height: number; y: number }; + previousRect?: ShiftRect; }; const describeSource = (source: LateShiftSource) => { const node = source.node; @@ -550,9 +585,13 @@ export function journeyRendererProbeScript( } const before = source.previousRect; const after = source.currentRect; + const delta = (key: keyof ShiftRect) => + before && after ? (after[key] ?? 0) - (before[key] ?? 0) : 0; return { - deltaHeight: before && after ? after.height - before.height : 0, - deltaY: before && after ? after.y - before.y : 0, + deltaHeight: delta('height'), + deltaWidth: delta('width'), + deltaX: delta('x'), + deltaY: delta('y'), node: label, }; }; diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts index f2084d099..b5123cd3b 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts @@ -54,6 +54,8 @@ function measurement( preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, sentinel: { epochMs: 2_601, status: 'sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 37, epochMs: 3_180.06, @@ -64,6 +66,8 @@ function measurement( sources: [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'section.dashboard-rail', }, @@ -210,6 +214,8 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn- sources: [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'section.dashboard-rail', }, diff --git a/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts index 95cb2fb4d..f17f7b390 100644 --- a/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts @@ -59,6 +59,23 @@ function measurement( preStart: { domMutations: 4, lastMutationEpochMs: 9_100 }, schemaVersion: 1, sentinel: { epochMs: 10_080.5, status: 'sent' }, + shiftCount: 23, + shifts: [ + { + hadRecentInput: true, + sinceStartMs: 41.26, + sources: [ + { + deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, + deltaY: 52, + node: 'div.content[data-test-id="category-list"]', + }, + ], + value: 0.22106, + }, + ], settle: { domMutations: 0, epochMs: null, @@ -147,6 +164,24 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters }); assert.deepEqual(record.evidence['layoutShift'], { recentInput: 0.221, + // More shifts were counted than listed. + shiftCount: 23, + shifts: [ + { + hadRecentInput: true, + sinceStartMs: 41.3, + sources: [ + { + deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, + deltaY: 52, + node: 'div.content[data-test-id="category-list"]', + }, + ], + value: 0.2211, + }, + ], withoutRecentInput: 0, }); assert.deepEqual(record.evidence['httpRequestsByRoute'], { diff --git a/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts b/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts index 4d68bdcaa..ef9e61403 100644 --- a/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts @@ -184,6 +184,17 @@ export function toOpenSourceIterationRecord( recentInput: roundThousandth( renderer.counters.recentInputLayoutShiftScore ), + // Every counted shift; `shifts` lists the first 20. + shiftCount: renderer.shiftCount, + // The first 20 counted shifts and the nodes that moved. + shifts: renderer.shifts.map((shift) => + Object.freeze({ + hadRecentInput: shift.hadRecentInput, + sinceStartMs: roundTenth(shift.sinceStartMs), + sources: shift.sources, + value: Math.round(shift.value * 10_000) / 10_000, + }) + ), withoutRecentInput: roundThousandth( renderer.counters.layoutShiftScore ), diff --git a/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts index ee228c1c8..2b4246025 100644 --- a/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts @@ -74,6 +74,8 @@ function measurement( preStart: { domMutations: 53, lastMutationEpochMs: 8_900 }, schemaVersion: 1, sentinel: { epochMs: 10_350, status: 'sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 0, epochMs: null, diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index d16fb4f50..dfb8a2824 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -175,8 +175,8 @@ closed or closed before the cutoff. J2's probe has no settle window most 20): the time after the first card, the value and, for each source the browser attributes the shift to, the node (`tag.class[data-test-id]`; a component host such as `lib-dashboard-rail` takes its first child's test id) -and its vertical move. A late shift can therefore be traced to its component -from the summary alone. +and its move (`deltaX`, `deltaY`, `deltaWidth`, `deltaHeight`). A late shift +can therefore be traced to its component from the summary alone. First local measurement (macOS, 2026-09-29, `master` with #1738): all windows closed on `quiet`, `renderer.layoutShiftScore` stayed 0, and @@ -719,7 +719,7 @@ strings and stream paths carry credentials and are never stored. | `renderer.ipcCallsToFirstPage` | Bridge `start` trace events between the start and end sentinels, counted by a second `journey-main-ipc-capture.ts` instance installed with `startSentinelId`. Calls before the start marker are tallied separately (`callsBeforeStart`); a start marker that is missing, repeated or received after the end sentinel fails the iteration. | | `renderer.domMutationsToFirstPage` | `MutationRecord`s from the click until the terminal batch. Records produced before the click (hover, settling) are taken from the observer at the start and counted under `evidence.settle` instead. | | `renderer.cdTicksToFirstPage` | `ApplicationRef` ticks from the click until the terminal batch: the counter's running total read in the capture-phase click listener, before the app handles the click, subtracted from its value at the terminal batch (see [Change-detection ticks](#change-detection-ticks)). | -| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`. | +| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`, and `evidence.layoutShift.shifts` lists the first 20 counted shifts (`shiftCount` is the total) with their value, `hadRecentInput`, time since the click and the nodes that moved (`tag.class[data-test-id]` and their `deltaX`, `deltaY`, `deltaWidth` and `deltaHeight`, as J1's late shifts). | | `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the cutoff. The task that dispatches the click began before the event's timestamp and still counts; buffered J1 tasks that ended before the click are dropped. Evidence until it is shown to be stable on the CI runner, as for J1. | | `main.mockHttpRequestsToSettled` | Requests the proxy received from the click until, after the terminal batch, no new request had arrived for 1 s and none was in flight (a response slower than that, and what it triggers, stays inside the window). The window ends at the ledger position read by that accepted quiet sample; a request arriving after it was never seen in flight, so it goes to `evidence.httpRequestsAfterSettledByRoute` instead of the counter. The ledger is read 1 s after that sample, so that late traffic is actually observed. The window starts at the renderer's click stamp, the same boundary as every other J2 counter, not when Playwright began its actionability checks; the proxy stamps requests with the test process's wall clock, and both processes read the same host clock. Bounding by the terminal would compare the test process's clock with the renderer's, so the count up to the terminal epoch is evidence only (`evidence.httpRequestsToFirstPage`); `evidence.httpRequestsByRoute` names the requests. |