e2e/perf/probe.ts
Linked repository file from this build.
/** Browser-only diagnostics. Keep this function self-contained for Playwright serialization. */
export function probe({ selector, cold, path }: { selector: string; cold: boolean; path: string }) {
window.__qcPerf?.cancel();
const started = cold ? 0 : performance.now();
const timeline: {
at: number;
phase: string;
shell: number;
content: number;
skeleton: number;
}[] = [];
const frameGaps: { at: number; duration: number }[] = [];
const frameTimes: number[] = [];
const animations: { at: number; name: string; target: string }[] = [];
const longTasks: { at: number; duration: number }[] = [];
const shifts: { at: number; value: number; recentInput: boolean; targets: string[] }[] = [];
const longAnimationFrames: {
at: number;
duration: number;
blockingMs: number;
scripts: { source: string; function: string; duration: number; forcedLayoutMs: number }[];
}[] = [];
const observers: PerformanceObserver[] = [];
const identities = new WeakMap<Element, number>();
let nextIdentity = 0;
const identity = (element: Element | null) => {
if (!element) return 0;
if (!identities.has(element)) identities.set(element, ++nextIdentity);
return identities.get(element)!;
};
const describe = (element: Element | null) =>
element
? `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}${Array.from(
element.classList,
)
.slice(0, 3)
.map((c) => `.${c}`)
.join("")}`
: "";
const observe = (type: string, accept: (entry: PerformanceEntry) => void) => {
if (!PerformanceObserver.supportedEntryTypes.includes(type)) return;
const observer = new PerformanceObserver((list) =>
list.getEntries().forEach((entry) => {
if (entry.startTime >= started) accept(entry);
}),
);
observer.observe({ type, buffered: true });
observers.push(observer);
};
observe("longtask", (entry) => {
if (longTasks.length < 1000)
longTasks.push({ at: entry.startTime - started, duration: entry.duration });
});
observe("layout-shift", (entry) => {
const shift = entry as PerformanceEntry & {
value: number;
hadRecentInput: boolean;
sources?: { node?: Element }[];
};
if (shifts.length < 1000)
shifts.push({
at: entry.startTime - started,
value: shift.value,
recentInput: shift.hadRecentInput,
targets: (shift.sources ?? []).map((s) =>
describe(s.node instanceof Element ? s.node : null),
),
});
});
observe("long-animation-frame", (entry) => {
const frame = entry as PerformanceEntry & {
blockingDuration: number;
scripts: {
sourceURL: string;
sourceFunctionName: string;
duration: number;
forcedStyleAndLayoutDuration: number;
}[];
};
if (longAnimationFrames.length < 1000)
longAnimationFrames.push({
at: frame.startTime - started,
duration: frame.duration,
blockingMs: frame.blockingDuration,
scripts: frame.scripts.map((script) => ({
source: script.sourceURL,
function: script.sourceFunctionName,
duration: script.duration,
forcedLayoutMs: script.forcedStyleAndLayoutDuration,
})),
});
});
const animation = (event: AnimationEvent) => {
if (animations.length < 1000)
animations.push({
at: performance.now() - started,
name: event.animationName,
target: describe(event.target instanceof Element ? event.target : null),
});
};
document.addEventListener("animationstart", animation, true);
// Read ancestors too: DOM presence alone misses nested opacity fades and hidden source pages.
const visibility = (element: Element | null) => {
if (
!element ||
!element.getClientRects().length ||
element.closest('[hidden], [inert], [aria-hidden="true"]')
)
return 0;
let opacity = 1;
for (let current: Element | null = element; current; current = current.parentElement) {
const style = getComputedStyle(current);
if (style.visibility === "hidden" || style.display === "none") return 0;
opacity *= Number(style.opacity);
}
return opacity;
};
let frame = 0;
let previousFrame: number | null = null;
let candidate: number | null = null;
let visualCandidate: number | null = null;
let previousSignature = "";
let wasReady = false;
let wasVisible = false;
let visibilityRegressions = 0;
let regressions = 0;
let lastRegression: number | null = null;
const state = {
started,
ready: null as number | null,
selector,
visualReady: null as number | null,
settled: null as number | null,
timeline,
frameGaps,
animations,
longTasks,
shifts,
summary: () => {
const sorted = [...frameTimes].sort((a, b) => a - b);
return {
observedMs: performance.now() - started,
visualReadyMs: state.visualReady == null ? null : state.visualReady - started,
settledMs: state.settled == null ? null : state.settled - started,
readinessRegressions: regressions,
visibilityRegressions,
lastRegressionMs: lastRegression,
frameCount: frameTimes.length,
frameP95Ms: sorted.length ? sorted[Math.ceil(sorted.length * 0.95) - 1] : null,
maxFrameMs: sorted.at(-1) ?? null,
framesOver50Ms: frameGaps.length,
longTaskBlockingMs: longTasks.reduce(
(sum, task) => sum + Math.max(0, task.duration - 50),
0,
),
// Raw shift sum, not session-window CLS. Include input-associated shifts separately.
layoutShiftSum: shifts.filter((s) => !s.recentInput).reduce((sum, s) => sum + s.value, 0),
inputLayoutShiftSum: shifts
.filter((s) => s.recentInput)
.reduce((sum, s) => sum + s.value, 0),
longAnimationFrames,
supportedEntryTypes: PerformanceObserver.supportedEntryTypes,
timeline,
frameGaps,
animations,
longTasks,
shifts,
};
},
cancel: () => {
cancelAnimationFrame(frame);
observers.forEach((observer) => observer.disconnect());
document.removeEventListener("animationstart", animation, true);
},
};
window.__qcPerf = state;
const check = (frameTime: number) => {
const now = performance.now();
if (previousFrame != null) {
const duration = frameTime - previousFrame;
frameTimes.push(duration);
if (duration > 50) frameGaps.push({ at: now - started, duration });
}
previousFrame = frameTime;
const element = document.querySelector(selector);
const opacity = location.pathname === path ? visibility(element) : 0;
const visible = opacity > 0;
const ready = visible && !element?.closest('[aria-busy="true"]');
if (wasVisible && !visible) visibilityRegressions++;
wasVisible = visible;
if (ready) {
if (candidate != null && state.ready == null) state.ready = now;
candidate ??= now;
} else candidate = null;
if (opacity >= 0.99) {
if (visualCandidate != null) {
state.visualReady ??= now;
state.settled ??= now;
}
visualCandidate ??= now;
} else {
visualCandidate = null;
state.settled = null;
}
if (wasReady && !ready) {
regressions++;
lastRegression = now - started;
}
wasReady = ready;
const shell = document.querySelector(".app-shell");
const content = document.querySelector(".app-page-content:not([data-loading])");
const skeleton = document.querySelector(".page-transition-loading, main .skeleton-ui");
const phase =
location.pathname !== path
? skeleton
? "loading"
: "source"
: skeleton
? "loading"
: visible
? opacity >= 0.99
? "content"
: "fading"
: "unready";
const snapshot = {
at: now - started,
phase,
shell: identity(shell),
content: identity(content),
skeleton: identity(skeleton),
};
const signature = JSON.stringify([phase, snapshot.shell, snapshot.content, snapshot.skeleton]);
if (signature !== previousSignature && timeline.length < 1000) timeline.push(snapshot);
previousSignature = signature;
frame = requestAnimationFrame(check);
};
frame = requestAnimationFrame(check);
}
declare global {
interface Window {
__qcPerf?: {
started: number;
ready: number | null;
selector: string;
cancel(): void;
summary(): {
observedMs: number;
visualReadyMs: number | null;
settledMs: number | null;
readinessRegressions: number;
visibilityRegressions: number;
lastRegressionMs: number | null;
frameCount: number;
frameP95Ms: number | null;
maxFrameMs: number | null;
framesOver50Ms: number;
longTaskBlockingMs: number;
layoutShiftSum: number;
inputLayoutShiftSum: number;
longAnimationFrames: {
at: number;
duration: number;
blockingMs: number;
scripts: { source: string; function: string; duration: number; forcedLayoutMs: number }[];
}[];
supportedEntryTypes: readonly string[];
timeline: { at: number; phase: string; shell: number; content: number; skeleton: number }[];
frameGaps: { at: number; duration: number }[];
animations: { at: number; name: string; target: string }[];
longTasks: { at: number; duration: number }[];
shifts: { at: number; value: number; recentInput: boolean; targets: string[] }[];
};
};
}
}