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 862d35ce1..9dfa35301 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 @@ -526,6 +526,37 @@ test('the cap ends the settle window while the workspace content keeps mutating' 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' }, 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 94ddf8767..0326119b0 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -421,12 +421,17 @@ export function journeyRendererProbeScript( }; }; // Starts at the first-card cutoff. Every mutation record under the root - // restarts the quiet timer; the cap timer never moves. + // 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 | 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; @@ -440,10 +445,13 @@ export function journeyRendererProbeScript( clearTimeout(capTimer); state.settle.domMutations += settleObserver.takeRecords().length; settleObserver.disconnect(); - endSettle(status, epoch()); + 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, { diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 75a4704df..bc5fba189 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -129,9 +129,11 @@ The settle window opens at the first-card cutoff. A second 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 sampled when the timer fires, and every entry -that starts before it counts, including entries still queued in the -observer. Why this point: +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