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
+3 -1
View File
@@ -4,7 +4,9 @@
"\\.test\\.ts$",
"\\.test\\.tsx$",
"\\.spec\\.ts$",
"\\.spec\\.tsx$"
"\\.spec\\.tsx$",
"lottie-message-bubble\\.tsx$",
"stripe-payment-dialog\\.tsx$"
],
"directory": [
"./src/data/repositories",
+104
View File
@@ -0,0 +1,104 @@
# Performance baseline
Baseline date: 2026-07-14
Next.js: 16.2.7 (Turbopack)
This baseline covers two different signals:
- Bundle analysis verifies whether expensive modules are on the eager or async client path.
- Lab Web Vitals provide a repeatable mobile profile for detecting large regressions before field data is available.
## Bundle baseline
Run:
```bash
pnpm perf:bundle
```
The command uses the built-in Next.js Turbopack analyzer and writes:
- Interactive analysis: `.next/diagnostics/analyze/`
- Machine-readable summary: `.next/diagnostics/bundle-summary.json`
Compressed sizes below are Turbopack module estimates. `Eager` is the route's synchronous client graph; `async` is code behind at least one dynamic import.
| Route | Eager before | Eager baseline | Change | Async baseline |
| --- | ---: | ---: | ---: | ---: |
| `/splash` | 644.4 KiB | 644.4 KiB | 0.0 KiB | 14.3 KiB |
| `/chat` | 666.2 KiB | 666.2 KiB | 0.0 KiB | 14.7 KiB |
| `/subscription` | 660.7 KiB | 657.0 KiB | -3.7 KiB | 20.7 KiB |
| `/tip` | 653.9 KiB | 650.4 KiB | -3.5 KiB | 20.7 KiB |
Module loading baseline:
| Module | Before | Baseline |
| --- | --- | --- |
| `@stripe/react-stripe-js` | eager | async |
| `@stripe/stripe-js` | eager | async |
| Stripe payment dialog | eager | async |
| Chat reply animation | eager | async |
| `lottie-react` | dependency present, not bundled | removed |
The total potential graph can grow slightly because an async chunk has its own loader metadata. The primary budget is the eager graph and the audited module phase, not the sum of eager and unopened async code.
Initial warning budgets are intentionally above the baseline and are not enforced by CI:
| Route | Eager client JS warning level |
| --- | ---: |
| `/splash` | 650 KiB |
| `/chat` | 675 KiB |
| `/subscription` | 665 KiB |
| `/tip` | 660 KiB |
## Lab Web Vitals baseline
Build and measure the local production app:
```bash
pnpm build
pnpm perf:vitals
```
Default profile:
- Pixel 7 viewport and user agent
- 4× CPU slowdown
- 1.6 Mbps download, 750 Kbps upload, 150 ms latency
- Three cold browser contexts per route; the table reports the median
- Service workers disabled to keep first-load measurements comparable
The default routes are `/splash` and `/tip`, which are stable without a stored login. The 2026-07-14 local baseline is:
| Route | TTFB | FCP | LCP | CLS | Synthetic INP | Initial JS transfer |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `/splash` | 8.5 ms | 840 ms | 840 ms | 0.000 | 24 ms | 468.5 KiB |
| `/tip` | 6.3 ms | 828 ms | 6,112 ms | 0.010 | n/a | 473.7 KiB |
`/tip` LCP is the first visible performance gap from this baseline and should be investigated separately. The synthetic INP value is produced by one body interaction and is only a regression signal; it does not replace field INP.
The report is written to `.next/diagnostics/web-vitals-baseline.json`. Useful overrides:
```bash
PERF_RUNS=5 pnpm perf:vitals
PERF_ROUTES=/splash,/tip,/chat pnpm perf:vitals
PERF_BASE_URL=https://frontend-test.banlv-ai.com pnpm perf:vitals
PERF_REPORT_PATH=./performance-report.json pnpm perf:vitals
```
The report records both the requested route and final path. Do not use a protected route sample when it redirected to `/auth`.
## Field Core Web Vitals
The existing Sentry browser tracing integration captures real-user LCP, CLS, and INP. After deployment, use the first complete seven-day window as the field baseline and compare p75 by route and device class.
Targets follow the standard Core Web Vitals “good” thresholds:
| Metric | p75 target |
| --- | ---: |
| LCP | ≤ 2.5 s |
| CLS | ≤ 0.1 |
| INP | ≤ 200 ms |
Lab results explain reproducible loading behavior; Sentry p75 remains the release decision signal because it includes real devices, networks, authenticated routes, and actual interactions.
+2 -1
View File
@@ -21,6 +21,8 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend",
"test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=prod-smoke",
"perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs",
"perf:vitals": "node scripts/performance/collect-web-vitals.mjs",
"generate-barrels": "barrelsby --delete -c barrelsby.json"
},
"dependencies": {
@@ -33,7 +35,6 @@
"classnames": "^2.5.1",
"dexie": "^4.4.3",
"esbuild": "^0.25.0",
"lottie-react": "^2.4.1",
"lucide-react": "^1.18.0",
"next": "16.2.7",
"next-auth": "^4.24.14",
-20
View File
@@ -35,9 +35,6 @@ importers:
esbuild:
specifier: ^0.25.0
version: 0.25.12
lottie-react:
specifier: ^2.4.1
version: 2.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
lucide-react:
specifier: ^1.18.0
version: 1.18.0(react@19.2.4)
@@ -2884,15 +2881,6 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lottie-react@2.4.1:
resolution: {integrity: sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
lottie-web@5.13.0:
resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==}
lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22}
@@ -6739,14 +6727,6 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lottie-react@2.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
lottie-web: 5.13.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
lottie-web@5.13.0: {}
lru-cache@11.5.1: {}
lru-cache@5.1.1:
+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";
}
+345
View File
@@ -0,0 +1,345 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const root = process.cwd();
const analyzeDataDirectory = resolve(
root,
process.env.BUNDLE_ANALYZE_DIR ?? ".next/diagnostics/analyze/data",
);
const outputPath = resolve(
root,
process.env.PERF_REPORT_PATH ??
".next/diagnostics/bundle-summary.json",
);
const routes = parseList(
process.env.PERF_ROUTES ?? "/splash,/chat,/subscription,/tip",
);
const auditTargets = [
{
name: "Stripe React SDK",
matches: (path) => path.includes("/node_modules/@stripe/react-stripe-js/"),
},
{
name: "Stripe loader",
matches: (path) => path.includes("/node_modules/@stripe/stripe-js/"),
},
{
name: "Stripe dialog",
matches: (path) => path.includes("/stripe-payment-dialog.tsx"),
},
{
name: "Reply animation",
matches: (path) => path.includes("/lottie-message-bubble"),
},
{
name: "Lottie runtime",
matches: (path) => path.includes("/node_modules/lottie-react/"),
},
];
function main() {
const modulesData = new FramedDataFile(
resolve(analyzeDataDirectory, "modules.data"),
);
const asyncBoundariesByPath = getAsyncBoundariesByModulePath(modulesData);
const routeReports = routes.map((route) =>
analyzeRoute(route, asyncBoundariesByPath),
);
const packageJson = JSON.parse(
readFileSync(resolve(root, "package.json"), "utf8"),
);
const report = {
generatedAt: new Date().toISOString(),
nextVersion: packageJson.dependencies.next,
routes: routeReports,
};
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
console.table(
routeReports.map((route) => ({
route: route.route,
"eager client JS": formatBytes(route.eagerClientJs.compressedBytes),
"async client JS": formatBytes(route.asyncClientJs.compressedBytes),
"total client JS": formatBytes(route.totalClientJs.compressedBytes),
})),
);
for (const route of routeReports) {
const presentAudits = route.audits.filter(
(audit) => audit.phase !== "absent",
);
if (presentAudits.length === 0) continue;
console.log(`\n${route.route} audited modules`);
console.table(
presentAudits.map((audit) => ({
module: audit.name,
phase: audit.phase,
compressed: formatBytes(audit.compressedBytes),
})),
);
}
console.log(`\nBundle summary written to ${outputPath}`);
}
function analyzeRoute(route, boundariesByPath) {
const routeFile =
route === "/"
? resolve(analyzeDataDirectory, "analyze.data")
: resolve(
analyzeDataDirectory,
route.replace(/^\//, ""),
"analyze.data",
);
const data = new FramedDataFile(routeFile);
const sourceIndexes = collectRouteSourceIndexes(data);
const clientParts = collectClientJavaScriptParts(data, sourceIndexes);
const eagerParts = [];
const asyncParts = [];
for (const partIndex of clientParts) {
const part = data.header.chunk_parts[partIndex];
const sourcePath = normalizeSourcePath(
data.getFullSourcePath(part.source_index),
);
const boundaryCount = boundariesByPath.get(sourcePath);
if (boundaryCount !== undefined && boundaryCount > 0) {
asyncParts.push(partIndex);
} else {
eagerParts.push(partIndex);
}
}
return {
route,
eagerClientJs: sumParts(data, eagerParts),
asyncClientJs: sumParts(data, asyncParts),
totalClientJs: sumParts(data, clientParts),
audits: auditTargets.map((target) => {
const matchingParts = clientParts.filter((partIndex) => {
const part = data.header.chunk_parts[partIndex];
return target.matches(data.getFullSourcePath(part.source_index));
});
const boundaryCounts = matchingParts
.map((partIndex) => {
const part = data.header.chunk_parts[partIndex];
return boundariesByPath.get(
normalizeSourcePath(data.getFullSourcePath(part.source_index)),
);
})
.filter((value) => value !== undefined);
const sizes = sumParts(data, matchingParts);
return {
name: target.name,
phase:
matchingParts.length === 0
? "absent"
: boundaryCounts.length > 0 && Math.min(...boundaryCounts) > 0
? "async"
: "eager",
...sizes,
};
}),
};
}
function collectRouteSourceIndexes(data) {
const indexes = new Set();
const pending = [...data.header.source_roots];
while (pending.length > 0) {
const index = pending.pop();
if (index === undefined || indexes.has(index)) continue;
indexes.add(index);
pending.push(...data.readEdges(data.header.source_children, index));
}
return indexes;
}
function collectClientJavaScriptParts(data, sourceIndexes) {
const parts = new Set();
for (const sourceIndex of sourceIndexes) {
for (const partIndex of data.readEdges(
data.header.source_chunk_parts,
sourceIndex,
)) {
const part = data.header.chunk_parts[partIndex];
const output = data.header.output_files[part.output_file_index];
if (
output.filename.startsWith("[client-fs]/") &&
output.filename.endsWith(".js")
) {
parts.add(partIndex);
}
}
}
return [...parts];
}
function sumParts(data, partIndexes) {
let bytes = 0;
let compressedBytes = 0;
for (const partIndex of partIndexes) {
const part = data.header.chunk_parts[partIndex];
bytes += part.size;
compressedBytes += part.compressed_size;
}
return { bytes, compressedBytes };
}
function getAsyncBoundariesByModulePath(data) {
const modules = data.header.modules;
const entryDependents = [
"next/dist/esm/build/templates/pages.js",
"next/dist/esm/build/templates/pages-api.js",
"next/dist/esm/build/templates/pages-edge-api.js",
"next/dist/esm/build/templates/edge-ssr.js",
"next/dist/esm/build/templates/app-route.js",
"next/dist/esm/build/templates/edge-app-route.js",
"next/dist/esm/build/templates/app-page.js",
"next/dist/esm/build/templates/edge-ssr-app.js",
"next/dist/esm/build/templates/middleware.js",
"[next]/entry/page-loader.ts",
];
const directEntries = [
"next/dist/client/app-next-turbopack.js",
"next/dist/client/next-turbopack.js",
];
const entries = new Set();
for (let index = 0; index < modules.length; index += 1) {
const moduleRecord = modules[index];
if (entryDependents.some((entry) => moduleRecord.ident.includes(entry))) {
for (const dependency of data.readEdges(
data.header.module_dependencies,
index,
)) {
if (!modules[dependency].path.includes("next/dist/")) {
entries.add(dependency);
}
}
}
if (directEntries.some((entry) => moduleRecord.ident.includes(entry))) {
entries.add(index);
}
}
const distances = Array(modules.length).fill(Number.POSITIVE_INFINITY);
const buckets = [[...entries]];
for (const entry of entries) distances[entry] = 0;
for (let boundaryCount = 0; boundaryCount < buckets.length; boundaryCount += 1) {
const bucket = buckets[boundaryCount] ?? [];
for (let cursor = 0; cursor < bucket.length; cursor += 1) {
const moduleIndex = bucket[cursor];
if (distances[moduleIndex] !== boundaryCount) continue;
relaxDependencies(
data.header.module_dependencies,
moduleIndex,
boundaryCount,
);
relaxDependencies(
data.header.async_module_dependencies,
moduleIndex,
boundaryCount + 1,
);
}
}
const boundariesByPath = new Map();
for (let index = 0; index < modules.length; index += 1) {
const moduleRecord = modules[index];
if (!moduleRecord.ident.includes("[app-client]")) continue;
const boundaryCount = distances[index];
if (!Number.isFinite(boundaryCount)) continue;
const current = boundariesByPath.get(moduleRecord.path);
if (current === undefined || boundaryCount < current) {
boundariesByPath.set(moduleRecord.path, boundaryCount);
}
}
return boundariesByPath;
function relaxDependencies(reference, moduleIndex, nextDistance) {
for (const dependency of data.readEdges(reference, moduleIndex)) {
if (nextDistance >= distances[dependency]) continue;
distances[dependency] = nextDistance;
buckets[nextDistance] ??= [];
buckets[nextDistance].push(dependency);
}
}
}
function normalizeSourcePath(path) {
return path.replace(/ \[app-(?:client|ssr)\].*$/, "");
}
function parseList(value) {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((route) => (route.startsWith("/") ? route : `/${route}`));
}
function formatBytes(bytes) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
class FramedDataFile {
constructor(filePath) {
this.buffer = readFileSync(filePath);
const headerLength = this.buffer.readUInt32BE(0);
this.binaryStart = 4 + headerLength;
this.header = JSON.parse(
this.buffer.subarray(4, this.binaryStart).toString("utf8"),
);
this.sourcePathCache = new Map();
}
readEdges(reference, index) {
if (reference.length === 0) return [];
const referenceStart = this.binaryStart + reference.offset;
const indexCount = this.buffer.readUInt32BE(referenceStart);
if (index < 0 || index >= indexCount) return [];
const offsetsStart = referenceStart + 4;
const previousOffset =
index === 0
? 0
: this.buffer.readUInt32BE(offsetsStart + (index - 1) * 4);
const nextOffset = this.buffer.readUInt32BE(offsetsStart + index * 4);
const dataStart = offsetsStart + indexCount * 4;
const edges = [];
for (let cursor = previousOffset; cursor < nextOffset; cursor += 1) {
edges.push(this.buffer.readUInt32BE(dataStart + cursor * 4));
}
return edges;
}
getFullSourcePath(index) {
const cached = this.sourcePathCache.get(index);
if (cached !== undefined) return cached;
const source = this.header.sources[index];
const path =
source.parent_source_index === null
? source.path
: `${this.getFullSourcePath(source.parent_source_index)}${source.path}`;
this.sourcePathCache.set(index, path);
return path;
}
}
main();
+18 -3
View File
@@ -12,7 +12,14 @@
* - 新消息到达时自动滚到底部
* - 用户向上滚动时不强制拉回(保持阅读上下文)
*/
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import {
lazy,
Suspense,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import type { UiMessage } from "@/data/dto/chat";
@@ -23,11 +30,15 @@ import {
} from "../chat-render-items";
import { AiDisclosureBanner } from "./ai-disclosure-banner";
import { DateHeader } from "./date-header";
import { LottieMessageBubble } from "./lottie-message-bubble";
import { MessageBubble } from "./message-bubble";
import styles from "./chat-area.module.css";
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
const ReplyingAnimation = lazy(() =>
import("./lottie-message-bubble").then((module) => ({
default: module.LottieMessageBubble,
})),
);
export interface ChatAreaProps {
messages: readonly UiMessage[];
@@ -104,7 +115,11 @@ export function ChatArea({
onOpenImage,
)}
{isReplyingAI && <LottieMessageBubble />}
{isReplyingAI ? (
<Suspense fallback={null}>
<ReplyingAnimation />
</Suspense>
) : null}
</main>
);
}
-1
View File
@@ -17,7 +17,6 @@ export * from "./fullscreen-image-viewer";
export * from "./history-unlock-dialog";
export * from "./image-bubble";
export * from "./insufficient-credits-dialog";
export * from "./lottie-message-bubble";
export * from "./message-avatar";
export * from "./message-bubble";
export * from "./message-content";
-1
View File
@@ -8,4 +8,3 @@ export * from "./subscription-cta-button";
export * from "./subscription-payment-method";
export * from "./subscription-payment-success-dialog";
export * from "./subscription-vip-offer-section";
export * from "./stripe-payment-dialog";
@@ -0,0 +1,41 @@
"use client";
import dynamic from "next/dynamic";
import type { StripePaymentDialogProps } from "./stripe-payment-dialog";
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
const StripePaymentDialog = dynamic(
() =>
import("./stripe-payment-dialog").then(
(module) => module.StripePaymentDialog,
),
{
ssr: false,
loading: StripePaymentDialogLoading,
},
);
export function LazyStripePaymentDialog(props: StripePaymentDialogProps) {
return <StripePaymentDialog {...props} />;
}
function StripePaymentDialogLoading() {
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="stripe-payment-loading-title"
>
<div className={styles.dialog} aria-busy="true">
<div className={styles.header}>
<h2 id="stripe-payment-loading-title" className={styles.title}>
Preparing secure payment
</h2>
<p className={styles.content}>Loading payment methods...</p>
</div>
</div>
</div>
);
}
@@ -11,7 +11,7 @@ import {
} from "@/stores/payment/payment-context";
import { Logger } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog";
import { LazyStripePaymentDialog } from "./lazy-stripe-payment-dialog";
import { stripePaymentDialogStyles as dialogStyles } from "./stripe-payment-dialog.styles";
import { SubscriptionCtaButton } from "./subscription-cta-button";
@@ -95,7 +95,7 @@ export function SubscriptionCheckoutButton({
/>
) : null}
{paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? (
<StripePaymentDialog
<LazyStripePaymentDialog
clientSecret={paymentLaunch.stripeClientSecret}
onClose={paymentLaunch.handleStripeClose}
onConfirmed={paymentLaunch.handleStripeConfirmed}
+2 -2
View File
@@ -8,7 +8,7 @@ import {
} from "@/stores/payment/payment-context";
import { Logger } from "@/utils";
import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog";
import { LazyStripePaymentDialog } from "../subscription/components/lazy-stripe-payment-dialog";
import { stripePaymentDialogStyles as dialogStyles } from "../subscription/components/stripe-payment-dialog.styles";
import styles from "./tip-screen.module.css";
@@ -74,7 +74,7 @@ export function TipCheckoutButton({
/>
) : null}
{paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? (
<StripePaymentDialog
<LazyStripePaymentDialog
clientSecret={paymentLaunch.stripeClientSecret}
returnPath={returnPath}
onClose={paymentLaunch.handleStripeClose}