test(perf): record which nodes move in a journey's layout shift

J2's renderer.layoutShiftScore went from 0.222 to 0.233 with #1814, and
its evidence only held the recent-input / without-recent-input split, so
the moved element could not be named from a summary. The probe now keeps
the first 20 counted shifts (value, recent input, time since the journey
start and the moved nodes, as J1's late shifts do), and J2 writes them to
evidence.layoutShift.shifts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-06 11:53:21 +02:00
1 parent 0a006cb027
commit 655f54b362
6 files changed
+89

No files matched your search

@@ -798,6 +798,13 @@ test('drops performance entries from before the click and keeps recent-input shi
{
entryType: 'layout-shift',
hadRecentInput: true,
sources: [
{
currentRect: { height: 40, y: 152 },
node: fixture.card,
previousRect: { height: 40, y: 100 },
},
],
startTime: now(),
value: 0.25,
},
@@ -823,6 +830,20 @@ 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.deltaY),
]),
[
[true, 0.25, [52]],
[false, 0.125, []],
]
);
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 +890,7 @@ test('rejects a click start whose sentinel could not be sent', async () => {
const started = {
...state,
sentinel: { epochMs: 1, status: 'sent' as const },
shifts: [],
};
assert.throws(
() => assertJourneyRendererProbeState(started),
@@ -166,6 +166,16 @@ 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 vertical 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;
@@ -237,6 +247,11 @@ export interface JourneyRendererProbeState {
readonly epochMs: number | null;
readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
};
/**
* 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 +344,7 @@ export function journeyRendererProbeScript(
preStart: { domMutations: 0, lastMutationEpochMs: null },
schemaVersion: 1,
sentinel: { epochMs: null, status: 'not-sent' },
shifts: [],
settle: {
domMutations: 0,
epochMs: null,
@@ -389,6 +405,7 @@ export function journeyRendererProbeScript(
for (const entry of entries) {
const shift = entry as PerformanceEntry & {
hadRecentInput?: boolean;
sources?: readonly LateShiftSource[];
value?: number;
};
if (
@@ -397,6 +414,17 @@ export function journeyRendererProbeScript(
) {
continue;
}
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;
@@ -54,6 +54,7 @@ function measurement(
preStart: { domMutations: 0, lastMutationEpochMs: null },
schemaVersion: 1,
sentinel: { epochMs: 2_601, status: 'sent' },
shifts: [],
settle: {
domMutations: 37,
epochMs: 3_180.06,
@@ -59,6 +59,20 @@ function measurement(
preStart: { domMutations: 4, lastMutationEpochMs: 9_100 },
schemaVersion: 1,
sentinel: { epochMs: 10_080.5, status: 'sent' },
shifts: [
{
hadRecentInput: true,
sinceStartMs: 41.26,
sources: [
{
deltaHeight: 0,
deltaY: 52,
node: 'div.content[data-test-id="category-list"]',
},
],
value: 0.22106,
},
],
settle: {
domMutations: 0,
epochMs: null,
@@ -147,6 +161,20 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters
});
assert.deepEqual(record.evidence['layoutShift'], {
recentInput: 0.221,
shifts: [
{
hadRecentInput: true,
sinceStartMs: 41.3,
sources: [
{
deltaHeight: 0,
deltaY: 52,
node: 'div.content[data-test-id="category-list"]',
},
],
value: 0.2211,
},
],
withoutRecentInput: 0,
});
assert.deepEqual(record.evidence['httpRequestsByRoute'], {
@@ -184,6 +184,15 @@ export function toOpenSourceIterationRecord(
recentInput: roundThousandth(
renderer.counters.recentInputLayoutShiftScore
),
// 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,7 @@ function measurement(
preStart: { domMutations: 53, lastMutationEpochMs: 8_900 },
schemaVersion: 1,
sentinel: { epochMs: 10_350, status: 'sent' },
shifts: [],
settle: {
domMutations: 0,
epochMs: null,