perf: defer heavy modules and add performance baselines

Lazy-load Stripe and the chat reply animation, remove the unused Lottie runtime, and add repeatable bundle and Web Vitals baseline tooling.
This commit is contained in:
2026-07-14 10:55:08 +08:00
parent 0fe74b5371
commit b4904e738b
12 changed files with 846 additions and 31 deletions
+329
View File
@@ -0,0 +1,329 @@
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { spawn } from "node:child_process";
import { chromium, devices } from "@playwright/test";
const root = process.cwd();
const externalBaseUrl = process.env.PERF_BASE_URL;
const localEnvPath = resolve(root, ".env.local");
const baseUrl = externalBaseUrl ?? "http://127.0.0.1:3100";
const routes = parseList(
process.env.PERF_ROUTES ?? "/splash,/tip",
);
const runCount = readPositiveInteger(process.env.PERF_RUNS, 3);
const outputPath = resolve(
root,
process.env.PERF_REPORT_PATH ??
".next/diagnostics/web-vitals-baseline.json",
);
const shouldThrottle = process.env.PERF_DISABLE_THROTTLING !== "1";
let serverProcess;
try {
if (!externalBaseUrl) {
const buildIdPath = resolve(root, ".next/BUILD_ID");
if (!existsSync(buildIdPath)) {
throw new Error(
"Production build not found. Run `pnpm build` before `pnpm perf:vitals`.",
);
}
serverProcess = startProductionServer();
await waitForServer(baseUrl);
}
const browser = await chromium.launch({ headless: true });
const samples = [];
try {
for (const route of routes) {
for (let run = 1; run <= runCount; run += 1) {
const sample = await measureRoute(browser, route, run);
samples.push(sample);
console.log(
`${route} run ${run}/${runCount}: LCP ${formatMetric(sample.lcpMs, "ms")}, CLS ${formatMetric(sample.cls, "")}`,
);
}
}
} finally {
await browser.close();
}
const routeReports = routes.map((route) => summarizeRoute(route, samples));
const report = {
generatedAt: new Date().toISOString(),
baseUrl,
profile: {
device: "Pixel 7",
runs: runCount,
network: shouldThrottle
? "1.6 Mbps down / 750 Kbps up / 150 ms latency"
: "unthrottled",
cpuSlowdownMultiplier: shouldThrottle ? 4 : 1,
},
routes: routeReports,
samples,
};
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
console.table(
routeReports.map((route) => ({
route: route.route,
finalPath: route.finalPaths.join(", "),
TTFB: formatMetric(route.median.ttfbMs, " ms"),
FCP: formatMetric(route.median.fcpMs, " ms"),
LCP: formatMetric(route.median.lcpMs, " ms"),
CLS: formatDecimal(route.median.cls, 3),
"synthetic INP": formatMetric(route.median.syntheticInpMs, " ms"),
"initial JS": formatBytes(route.median.initialJsTransferBytes),
})),
);
console.log(`\nWeb Vitals baseline written to ${outputPath}`);
} finally {
serverProcess?.kill("SIGTERM");
}
async function measureRoute(browser, route, run) {
const context = await browser.newContext({
...devices["Pixel 7"],
serviceWorkers: "block",
});
const page = await context.newPage();
const session = await context.newCDPSession(page);
if (shouldThrottle) {
await session.send("Network.enable");
await session.send("Network.emulateNetworkConditions", {
offline: false,
latency: 150,
downloadThroughput: (1.6 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
});
await session.send("Emulation.setCPUThrottlingRate", { rate: 4 });
}
await page.addInitScript(installPerformanceObservers);
try {
await page.goto(new URL(route, baseUrl).toString(), {
waitUntil: "load",
timeout: 45_000,
});
await page.waitForTimeout(2_500);
await page.mouse.click(1, 1);
await page.waitForTimeout(500);
return await page.evaluate(
({ requestedRoute, sampleRun }) => {
const state = window.__cozsweetPerformanceBaseline;
const navigation = performance.getEntriesByType("navigation")[0];
const fcp = performance
.getEntriesByType("paint")
.find((entry) => entry.name === "first-contentful-paint");
const scriptResources = performance
.getEntriesByType("resource")
.filter(
(entry) =>
entry.initiatorType === "script" &&
new URL(entry.name).pathname.includes("/_next/"),
);
const interactions = [...state.interactions.values()].sort(
(left, right) => right - left,
);
const inpIndex = Math.min(
interactions.length - 1,
Math.floor(interactions.length / 50),
);
return {
route: requestedRoute,
run: sampleRun,
finalPath: window.location.pathname,
ttfbMs: navigation
? navigation.responseStart - navigation.requestStart
: null,
fcpMs: fcp?.startTime ?? null,
lcpMs: state.lcp,
cls: state.cls,
syntheticInpMs: inpIndex >= 0 ? interactions[inpIndex] : null,
initialJsTransferBytes: scriptResources.reduce(
(total, entry) => total + entry.transferSize,
0,
),
initialJsEncodedBytes: scriptResources.reduce(
(total, entry) => total + entry.encodedBodySize,
0,
),
};
},
{ requestedRoute: route, sampleRun: run },
);
} finally {
await context.close();
}
}
function installPerformanceObservers() {
const state = {
lcp: null,
cls: 0,
clsSessionValue: 0,
clsSessionStart: 0,
clsSessionEnd: 0,
interactions: new Map(),
};
window.__cozsweetPerformanceBaseline = state;
try {
new PerformanceObserver((list) => {
const entries = list.getEntries();
state.lcp = entries.at(-1)?.startTime ?? state.lcp;
}).observe({ type: "largest-contentful-paint", buffered: true });
} catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
const startsNewSession =
state.clsSessionEnd === 0 ||
entry.startTime - state.clsSessionEnd >= 1_000 ||
entry.startTime - state.clsSessionStart >= 5_000;
if (startsNewSession) {
state.clsSessionValue = entry.value;
state.clsSessionStart = entry.startTime;
} else {
state.clsSessionValue += entry.value;
}
state.clsSessionEnd = entry.startTime;
state.cls = Math.max(state.cls, state.clsSessionValue);
}
}).observe({ type: "layout-shift", buffered: true });
} catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.interactionId) continue;
state.interactions.set(
entry.interactionId,
Math.max(
state.interactions.get(entry.interactionId) ?? 0,
entry.duration,
),
);
}
}).observe({ type: "event", buffered: true, durationThreshold: 16 });
} catch {}
}
function summarizeRoute(route, samples) {
const routeSamples = samples.filter((sample) => sample.route === route);
const metricNames = [
"ttfbMs",
"fcpMs",
"lcpMs",
"cls",
"syntheticInpMs",
"initialJsTransferBytes",
"initialJsEncodedBytes",
];
const median = Object.fromEntries(
metricNames.map((name) => [
name,
getMedian(routeSamples.map((sample) => sample[name])),
]),
);
return {
route,
finalPaths: [...new Set(routeSamples.map((sample) => sample.finalPath))],
median,
};
}
function getMedian(values) {
const numbers = values
.filter((value) => typeof value === "number" && Number.isFinite(value))
.sort((left, right) => left - right);
if (numbers.length === 0) return null;
const middle = Math.floor(numbers.length / 2);
return numbers.length % 2 === 0
? (numbers[middle - 1] + numbers[middle]) / 2
: numbers[middle];
}
function startProductionServer() {
const standaloneRoot = resolve(root, ".next/standalone");
const standaloneServer = resolve(standaloneRoot, "server.js");
const standaloneNextDirectory = resolve(standaloneRoot, ".next");
mkdirSync(standaloneNextDirectory, { recursive: true });
cpSync(resolve(root, "public"), resolve(standaloneRoot, "public"), {
recursive: true,
force: true,
});
cpSync(
resolve(root, ".next/static"),
resolve(standaloneNextDirectory, "static"),
{ recursive: true, force: true },
);
const child = spawn(
process.execPath,
[
...(existsSync(localEnvPath) ? [`--env-file=${localEnvPath}`] : []),
standaloneServer,
],
{
cwd: standaloneRoot,
env: {
...process.env,
HOSTNAME: "127.0.0.1",
PORT: "3100",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
child.stdout.on("data", (chunk) => process.stdout.write(chunk));
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
return child;
}
async function waitForServer(url) {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) return;
} catch {}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
}
throw new Error(`Timed out waiting for ${url}`);
}
function parseList(value) {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((route) => (route.startsWith("/") ? route : `/${route}`));
}
function readPositiveInteger(value, fallback) {
const number = Number.parseInt(value ?? "", 10);
return Number.isInteger(number) && number > 0 ? number : fallback;
}
function formatMetric(value, suffix) {
return typeof value === "number" ? `${value.toFixed(1)}${suffix}` : "n/a";
}
function formatDecimal(value, fractionDigits) {
return typeof value === "number" ? value.toFixed(fractionDigits) : "n/a";
}
function formatBytes(value) {
return typeof value === "number"
? `${(value / 1024).toFixed(1)} KiB`
: "n/a";
}