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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-30 07:21:46 +02:00
1 parent 7a1003be55
commit 926d3040cb
3 files changed
+46 -5

No files matched your search

@@ -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' },
@@ -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<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;
@@ -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, {
+5 -3
View File
@@ -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