@decocms/start 0.37.2 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,1117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Chrome Performance Trace Comparator
4
+ * Compares two Chrome DevTools traces side-by-side for performance analysis.
5
+ *
6
+ * Usage:
7
+ * node analyze-traces.mjs <traceA.gz> <traceB.gz> [labelA] [labelB] [flags]
8
+ *
9
+ * Flags:
10
+ * --all Include everything (extensions + third-party)
11
+ * --first-party Only site code: filters out GTM, analytics, ads, chat, etc.
12
+ * --output <name> Write results to <name>.md as markdown
13
+ * --ai Generate AI context file with raw data + analysis prompt
14
+ * (default) Filters extensions, includes third-party scripts
15
+ *
16
+ * Examples:
17
+ * node analyze-traces.mjs worker.gz fallback.gz Worker Fallback
18
+ * node analyze-traces.mjs worker.gz fallback.gz Worker Fallback --first-party
19
+ * node analyze-traces.mjs worker.gz fallback.gz Worker Fallback --output report
20
+ * node analyze-traces.mjs worker.gz fallback.gz --all --output results
21
+ * node analyze-traces.mjs worker.gz fallback.gz Worker Fallback --ai
22
+ */
23
+ import { readFileSync, writeFileSync } from "fs";
24
+ import { gunzipSync } from "zlib";
25
+ import { basename } from "path";
26
+
27
+ // ── CLI parsing ──────────────────────────────────────────────────────────────
28
+
29
+ const args = process.argv.slice(2);
30
+
31
+ // Parse --output <name>
32
+ let outputFile = null;
33
+ const outputIdx = args.indexOf("--output");
34
+ if (outputIdx !== -1 && args[outputIdx + 1]) {
35
+ outputFile = args[outputIdx + 1];
36
+ if (!outputFile.endsWith(".md")) outputFile += ".md";
37
+ args.splice(outputIdx, 2);
38
+ }
39
+
40
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
41
+ const positional = args.filter((a) => !a.startsWith("--"));
42
+
43
+ const [pathA, pathB, labelA, labelB] = positional;
44
+
45
+ if (!pathA || !pathB) {
46
+ console.error(
47
+ "Usage: node analyze-traces.mjs <traceA.gz> <traceB.gz> [labelA] [labelB] [--first-party|--all] [--output name]"
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ const MODE_ALL = flags.has("--all");
53
+ const MODE_FIRST_PARTY = flags.has("--first-party");
54
+ const MODE_AI = flags.has("--ai");
55
+ const modeLabel = MODE_ALL
56
+ ? "ALL (incl. extensions)"
57
+ : MODE_FIRST_PARTY
58
+ ? "FIRST-PARTY only"
59
+ : "default (excl. extensions)";
60
+
61
+ const nameA = labelA || basename(pathA).replace(/\.(json\.)?gz$/, "");
62
+ const nameB = labelB || basename(pathB).replace(/\.(json\.)?gz$/, "");
63
+
64
+ // ── filter helpers ───────────────────────────────────────────────────────────
65
+
66
+ const isExtension = (url) =>
67
+ url &&
68
+ (url.includes("chrome-extension://") || url.includes("moz-extension://"));
69
+
70
+ // Known third-party domains/patterns
71
+ const THIRD_PARTY_PATTERNS = [
72
+ "googletagmanager.com",
73
+ "google-analytics.com",
74
+ "analytics.google.com",
75
+ "gtag/js",
76
+ "googlesyndication.com",
77
+ "googleads.g.doubleclick.net",
78
+ "google.com/ccm",
79
+ "connect.facebook.net",
80
+ "facebook.com/tr",
81
+ "facebook.com/privacy_sandbox",
82
+ "analytics.tiktok.com",
83
+ "tiktok.com/i18n/pixel",
84
+ "bat.bing.com",
85
+ "clarity.ms",
86
+ "scripts.clarity.ms",
87
+ "hotjar.com",
88
+ "script.hotjar.com",
89
+ "scarabresearch.com",
90
+ "creativecdn.com",
91
+ "cdn.pn.vg",
92
+ "osp-assets.pn.vg",
93
+ "ilabspush",
94
+ "pmweb.com",
95
+ "lilstts.com",
96
+ "onedollarstats.com",
97
+ "push-webchat",
98
+ "storage.googleapis.com/push-webchat",
99
+ "weni-sp-integrations",
100
+ ];
101
+
102
+ function isThirdParty(url) {
103
+ if (!url) return false;
104
+ return THIRD_PARTY_PATTERNS.some((p) => url.includes(p));
105
+ }
106
+
107
+ /** Returns true if this URL should be excluded based on current mode */
108
+ function shouldExclude(url) {
109
+ if (!url) return false;
110
+ if (!MODE_ALL && isExtension(url)) return true;
111
+ if (MODE_FIRST_PARTY && isThirdParty(url)) return true;
112
+ return false;
113
+ }
114
+
115
+ function shouldExcludeEvent(e) {
116
+ const url =
117
+ e.args?.data?.url ||
118
+ e.args?.data?.scriptName ||
119
+ e.args?.data?.sourceURL ||
120
+ "";
121
+ return shouldExclude(url);
122
+ }
123
+
124
+ // ── helpers ──────────────────────────────────────────────────────────────────
125
+
126
+ function loadTrace(path) {
127
+ const gz = readFileSync(path);
128
+ const json = gunzipSync(gz).toString("utf-8");
129
+ const data = JSON.parse(json);
130
+ return Array.isArray(data) ? data : data.traceEvents || [];
131
+ }
132
+
133
+ function navStart(events) {
134
+ return (
135
+ events.find(
136
+ (e) =>
137
+ e.name === "navigationStart" ||
138
+ (e.cat === "blink.user_timing" && e.name === "navigationStart")
139
+ )?.ts ?? null
140
+ );
141
+ }
142
+
143
+ // ── metric extractors ────────────────────────────────────────────────────────
144
+
145
+ function timingMarks(events, ns) {
146
+ const names = [
147
+ "firstPaint",
148
+ "firstContentfulPaint",
149
+ "largestContentfulPaint::Candidate",
150
+ "domInteractive",
151
+ "domContentLoadedEventEnd",
152
+ "domComplete",
153
+ "loadEventEnd",
154
+ ];
155
+ const marks = {};
156
+ for (const e of events) {
157
+ if (!e.cat?.includes("blink.user_timing") && !e.cat?.includes("loading"))
158
+ continue;
159
+ for (const n of names) {
160
+ if (e.name === n || e.name.startsWith(n)) {
161
+ const ms = (e.ts - ns) / 1000;
162
+ if (!marks[n] || ms > marks[n]) marks[n] = ms;
163
+ }
164
+ }
165
+ }
166
+ for (const e of events) {
167
+ if (e.name === "firstPaint" || e.name === "firstContentfulPaint") {
168
+ const ms = (e.ts - ns) / 1000;
169
+ if (!marks[e.name] || ms > marks[e.name]) marks[e.name] = ms;
170
+ }
171
+ }
172
+ return marks;
173
+ }
174
+
175
+ function cls(events) {
176
+ let score = 0;
177
+ const shifts = [];
178
+ for (const e of events) {
179
+ if (
180
+ e.name === "LayoutShift" &&
181
+ e.args?.data?.is_main_frame !== false &&
182
+ !e.args?.data?.had_recent_input
183
+ ) {
184
+ const s = e.args.data.score || 0;
185
+ score += s;
186
+ if (s > 0.001) shifts.push({ score: s, sources: e.args.data.sources });
187
+ }
188
+ }
189
+ return { score, shifts };
190
+ }
191
+
192
+ function tbt(events) {
193
+ let total = 0;
194
+ for (const e of events) {
195
+ if (
196
+ (e.name === "RunTask" || e.name === "ThreadControllerImpl::RunTask") &&
197
+ e.dur > 50000 &&
198
+ !shouldExcludeEvent(e)
199
+ ) {
200
+ total += e.dur - 50000;
201
+ }
202
+ }
203
+ return total / 1000;
204
+ }
205
+
206
+ function longTasks(events, ns) {
207
+ const tasks = [];
208
+ for (const e of events) {
209
+ if (
210
+ (e.name === "RunTask" || e.name === "ThreadControllerImpl::RunTask") &&
211
+ e.dur > 50000
212
+ ) {
213
+ const taskStart = e.ts;
214
+ const taskEnd = e.ts + e.dur;
215
+ const childScripts = events.filter(
216
+ (c) =>
217
+ c.name === "EvaluateScript" &&
218
+ c.ts >= taskStart &&
219
+ c.ts <= taskEnd &&
220
+ c.args?.data?.url
221
+ );
222
+ const allExcluded =
223
+ childScripts.length > 0 &&
224
+ childScripts.every((c) => shouldExclude(c.args.data.url));
225
+ if (!allExcluded) {
226
+ const mainScript = childScripts.find((c) => !shouldExclude(c.args.data.url));
227
+ tasks.push({
228
+ ts_ms: (e.ts - ns) / 1000,
229
+ dur_ms: e.dur / 1000,
230
+ script: mainScript
231
+ ? (mainScript.args.data.url || "").split("/").slice(-2).join("/")
232
+ : "",
233
+ });
234
+ }
235
+ }
236
+ }
237
+ return tasks.sort((a, b) => b.dur_ms - a.dur_ms);
238
+ }
239
+
240
+ function layoutTree(events) {
241
+ let count = 0,
242
+ total = 0;
243
+ for (const e of events) {
244
+ if (e.name === "UpdateLayoutTree" && e.dur > 1000) {
245
+ count++;
246
+ total += e.dur;
247
+ }
248
+ }
249
+ return { count, total_ms: total / 1000 };
250
+ }
251
+
252
+ function v8Compile(events) {
253
+ let count = 0,
254
+ total = 0;
255
+ const top = [];
256
+ for (const e of events) {
257
+ if (
258
+ (e.name === "v8.compile" || e.name === "V8.CompileCode") &&
259
+ e.dur > 1000 &&
260
+ !shouldExcludeEvent(e)
261
+ ) {
262
+ count++;
263
+ total += e.dur;
264
+ top.push({
265
+ dur_ms: e.dur / 1000,
266
+ url: (e.args?.data?.url || "inline").split("/").slice(-2).join("/"),
267
+ });
268
+ }
269
+ }
270
+ top.sort((a, b) => b.dur_ms - a.dur_ms);
271
+ return { count, total_ms: total / 1000, top: top.slice(0, 10) };
272
+ }
273
+
274
+ function scriptEval(events, ns) {
275
+ const scripts = [];
276
+ for (const e of events) {
277
+ if (
278
+ e.name === "EvaluateScript" &&
279
+ e.dur > 5000 &&
280
+ !shouldExcludeEvent(e)
281
+ ) {
282
+ scripts.push({
283
+ url: (e.args?.data?.url || "inline").split("/").slice(-2).join("/"),
284
+ fullUrl: e.args?.data?.url || "inline",
285
+ ts_ms: (e.ts - ns) / 1000,
286
+ dur_ms: e.dur / 1000,
287
+ isThirdParty: isThirdParty(e.args?.data?.url || ""),
288
+ });
289
+ }
290
+ }
291
+ return scripts.sort((a, b) => b.dur_ms - a.dur_ms).slice(0, 15);
292
+ }
293
+
294
+ function paintEvents(events) {
295
+ let count = 0,
296
+ total = 0;
297
+ for (const e of events) {
298
+ if (e.name === "Paint" && e.dur > 500) {
299
+ count++;
300
+ total += e.dur;
301
+ }
302
+ }
303
+ return { count, total_ms: total / 1000 };
304
+ }
305
+
306
+ function htmlDoc(events) {
307
+ const htmlResps = events.filter(
308
+ (e) =>
309
+ e.name === "ResourceReceiveResponse" &&
310
+ e.args?.data?.mimeType?.includes("text/html")
311
+ );
312
+ for (const resp of htmlResps) {
313
+ const id = resp.args.data.requestId;
314
+ const send = events.find(
315
+ (e) =>
316
+ e.name === "ResourceSendRequest" && e.args?.data?.requestId === id
317
+ );
318
+ if (!send) continue;
319
+ const url = send.args.data.url || "";
320
+ if (isExtension(url)) continue;
321
+ if (url.includes("sw_iframe") || url.includes("service_worker")) continue;
322
+ const finish = events.find(
323
+ (e) => e.name === "ResourceFinish" && e.args?.data?.requestId === id
324
+ );
325
+ return {
326
+ url,
327
+ encoded_kb: (finish?.args?.data?.encodedDataLength || 0) / 1024,
328
+ decoded_kb: (finish?.args?.data?.decodedBodyLength || 0) / 1024,
329
+ ttfb_ms: (resp.ts - send.ts) / 1000,
330
+ total_ms: finish ? (finish.ts - send.ts) / 1000 : 0,
331
+ };
332
+ }
333
+ return null;
334
+ }
335
+
336
+ function serverFnPayload(events) {
337
+ const sends = events.filter(
338
+ (e) =>
339
+ e.name === "ResourceSendRequest" &&
340
+ (e.args?.data?.url?.includes("_serverFn") ||
341
+ e.args?.data?.url?.includes("deco/render"))
342
+ );
343
+ let encoded = 0,
344
+ decoded = 0,
345
+ count = 0;
346
+ for (const s of sends) {
347
+ const id = s.args.data.requestId;
348
+ const finish = events.find(
349
+ (e) => e.name === "ResourceFinish" && e.args?.data?.requestId === id
350
+ );
351
+ if (finish) {
352
+ encoded += finish.args.data.encodedDataLength || 0;
353
+ decoded += finish.args.data.decodedBodyLength || 0;
354
+ }
355
+ count++;
356
+ }
357
+ return { count, encoded_kb: encoded / 1024, decoded_kb: decoded / 1024 };
358
+ }
359
+
360
+ function imageStats(events, ns) {
361
+ const imgDomains = [
362
+ "vtexassets",
363
+ "decoims",
364
+ "decoazn",
365
+ "decocache",
366
+ "deco-sites-assets",
367
+ ];
368
+ const sends = events.filter((e) => {
369
+ if (e.name !== "ResourceSendRequest") return false;
370
+ const url = e.args?.data?.url || "";
371
+ return imgDomains.some((d) => url.includes(d));
372
+ });
373
+
374
+ const pivot = events.find(
375
+ (e) =>
376
+ e.name === "ResourceSendRequest" &&
377
+ (e.args?.data?.url?.includes("_serverFn") ||
378
+ e.args?.data?.url?.includes("deco/render"))
379
+ );
380
+ const pivotTs = pivot ? (pivot.ts - ns) / 1000 : Infinity;
381
+
382
+ let before = 0,
383
+ after = 0,
384
+ totalEncoded = 0;
385
+ for (const s of sends) {
386
+ const ts = (s.ts - ns) / 1000;
387
+ const id = s.args.data.requestId;
388
+ const finish = events.find(
389
+ (e) => e.name === "ResourceFinish" && e.args?.data?.requestId === id
390
+ );
391
+ totalEncoded += finish?.args?.data?.encodedDataLength || 0;
392
+ if (ts < pivotTs) before++;
393
+ else after++;
394
+ }
395
+ return {
396
+ total: sends.length,
397
+ before_deferred: before,
398
+ after_deferred: after,
399
+ total_kb: totalEncoded / 1024,
400
+ };
401
+ }
402
+
403
+ function jsBundleSize(events) {
404
+ const sends = events.filter(
405
+ (e) =>
406
+ e.name === "ResourceSendRequest" &&
407
+ e.args?.data?.url &&
408
+ /\.(js|mjs)(\?|$)/.test(e.args.data.url) &&
409
+ !shouldExclude(e.args.data.url)
410
+ );
411
+ const finishes = {};
412
+ for (const e of events) {
413
+ if (e.name === "ResourceFinish" && e.args?.data) {
414
+ finishes[e.args.data.requestId] = e.args.data;
415
+ }
416
+ }
417
+
418
+ let total = 0,
419
+ firstPartyTotal = 0,
420
+ thirdPartyTotal = 0;
421
+ const bundles = [];
422
+ for (const s of sends) {
423
+ const f = finishes[s.args.data.requestId];
424
+ const size = f?.encodedDataLength || 0;
425
+ const url = s.args.data.url;
426
+ const is3p = isThirdParty(url);
427
+ total += size;
428
+ if (is3p) thirdPartyTotal += size;
429
+ else firstPartyTotal += size;
430
+ bundles.push({
431
+ url,
432
+ kb: size / 1024,
433
+ isThirdParty: is3p,
434
+ });
435
+ }
436
+ bundles.sort((a, b) => b.kb - a.kb);
437
+ return {
438
+ count: sends.length,
439
+ total_kb: total / 1024,
440
+ firstParty_kb: firstPartyTotal / 1024,
441
+ thirdParty_kb: thirdPartyTotal / 1024,
442
+ top: bundles.slice(0, 15),
443
+ };
444
+ }
445
+
446
+ function requestCount(events) {
447
+ return events.filter(
448
+ (e) =>
449
+ e.name === "ResourceSendRequest" &&
450
+ !shouldExclude(e.args?.data?.url || "")
451
+ ).length;
452
+ }
453
+
454
+ function domainBreakdown(events) {
455
+ const domains = {};
456
+ for (const e of events) {
457
+ if (e.name !== "ResourceSendRequest" || !e.args?.data?.url) continue;
458
+ if (shouldExclude(e.args.data.url)) continue;
459
+ try {
460
+ const d = new URL(e.args.data.url).hostname;
461
+ if (d) domains[d] = (domains[d] || 0) + 1;
462
+ } catch {}
463
+ }
464
+ return Object.entries(domains)
465
+ .sort((a, b) => b[1] - a[1])
466
+ .map(([d, c]) => ({ domain: d, count: c }));
467
+ }
468
+
469
+ function lcpCandidate(events, ns) {
470
+ const candidates = events.filter(
471
+ (e) =>
472
+ e.name === "largestContentfulPaint::Candidate" ||
473
+ e.name === "LargestContentfulPaint::Candidate"
474
+ );
475
+ if (!candidates.length) return null;
476
+ const last = candidates[candidates.length - 1];
477
+ return {
478
+ ts_ms: (last.ts - ns) / 1000,
479
+ size: last.args?.data?.size || "?",
480
+ type: last.args?.data?.type || "?",
481
+ url: last.args?.data?.url || "",
482
+ };
483
+ }
484
+
485
+ function parseHTMLStats(events) {
486
+ let count = 0,
487
+ total = 0;
488
+ for (const e of events) {
489
+ if (e.name === "ParseHTML" && e.dur) {
490
+ count++;
491
+ total += e.dur;
492
+ }
493
+ }
494
+ return { count, total_ms: total / 1000 };
495
+ }
496
+
497
+ // ── extract all metrics from a trace ─────────────────────────────────────────
498
+
499
+ function extract(path) {
500
+ const events = loadTrace(path);
501
+ const ns = navStart(events);
502
+ if (!ns) {
503
+ console.error(`No navigationStart in ${path}`);
504
+ process.exit(1);
505
+ }
506
+
507
+ const marks = timingMarks(events, ns);
508
+ const doc = htmlDoc(events);
509
+ const sfn = serverFnPayload(events);
510
+ const imgs = imageStats(events, ns);
511
+ const js = jsBundleSize(events);
512
+ const layout = layoutTree(events);
513
+ const v8 = v8Compile(events);
514
+ const paint = paintEvents(events);
515
+ const lcp = lcpCandidate(events, ns);
516
+ const ph = parseHTMLStats(events);
517
+ const lt = longTasks(events, ns);
518
+ const scripts = scriptEval(events, ns);
519
+ const clsData = cls(events);
520
+
521
+ return {
522
+ events: events.length,
523
+ marks,
524
+ doc,
525
+ serverFn: sfn,
526
+ images: imgs,
527
+ js,
528
+ requests: requestCount(events),
529
+ domains: domainBreakdown(events),
530
+ cls: clsData.score,
531
+ clsShifts: clsData.shifts,
532
+ tbt: tbt(events),
533
+ longTasks: lt,
534
+ layout,
535
+ v8,
536
+ paint,
537
+ lcp,
538
+ parseHTML: ph,
539
+ scripts,
540
+ };
541
+ }
542
+
543
+ // ── output buffer ────────────────────────────────────────────────────────────
544
+
545
+ const output = [];
546
+ const log = (s = "") => output.push(s);
547
+
548
+ // ── shared formatting ────────────────────────────────────────────────────────
549
+
550
+ function fmt(val, unit) {
551
+ if (val == null || val === "?") return "-";
552
+ const n = typeof val === "number" ? val : parseFloat(val);
553
+ if (isNaN(n)) return "-";
554
+ if (unit === "") return Number.isInteger(n) ? String(n) : n.toFixed(4);
555
+ return n.toFixed(1) + unit;
556
+ }
557
+
558
+ function delta(a, b, lowerBetter = true) {
559
+ if (a == null || b == null) return { text: "-", icon: " ", mdIcon: "" };
560
+ const na = typeof a === "number" ? a : parseFloat(a);
561
+ const nb = typeof b === "number" ? b : parseFloat(b);
562
+ if (isNaN(na) || isNaN(nb)) return { text: "-", icon: " ", mdIcon: "" };
563
+ const diff = na - nb;
564
+ const pct = nb !== 0 ? ((diff / nb) * 100).toFixed(0) : "∞";
565
+ const sign = diff > 0 ? "+" : "";
566
+ const better = lowerBetter ? diff < -0.001 : diff > 0.001;
567
+ const worse = lowerBetter ? diff > 0.001 : diff < -0.001;
568
+ const icon = better ? "✅" : worse ? "❌" : "🟰";
569
+ const mdIcon = better ? "✅" : worse ? "❌" : "🟰";
570
+ return { text: `${sign}${pct}%`, icon, mdIcon };
571
+ }
572
+
573
+ function row(label, valA, valB, unit = "ms", lowerBetter = true) {
574
+ const d = delta(valA, valB, lowerBetter);
575
+ return { label, a: fmt(valA, unit), b: fmt(valB, unit), delta: d.text, icon: d.icon, mdIcon: d.mdIcon };
576
+ }
577
+
578
+ function infoRow(label, valA, valB, unit = "ms") {
579
+ return { label, a: fmt(valA, unit), b: fmt(valB, unit), delta: "-", icon: "ℹ️", mdIcon: "ℹ️" };
580
+ }
581
+
582
+ // ── terminal table ───────────────────────────────────────────────────────────
583
+
584
+ function printTable(title, rows) {
585
+ if (!rows.length) return;
586
+
587
+ const colW = {
588
+ icon: 2,
589
+ label: Math.max(20, ...rows.map((r) => r.label.length)),
590
+ a: Math.max(nameA.length, ...rows.map((r) => r.a.length)),
591
+ b: Math.max(nameB.length, ...rows.map((r) => r.b.length)),
592
+ delta: Math.max(5, ...rows.map((r) => r.delta.length)),
593
+ };
594
+
595
+ const topBorder = `┌${"─".repeat(colW.icon + 2)}┬${"─".repeat(colW.label + 2)}┬${"─".repeat(colW.a + 2)}┬${"─".repeat(colW.b + 2)}┬${"─".repeat(colW.delta + 2)}┐`;
596
+ const sep = `├${"─".repeat(colW.icon + 2)}┼${"─".repeat(colW.label + 2)}┼${"─".repeat(colW.a + 2)}┼${"─".repeat(colW.b + 2)}┼${"─".repeat(colW.delta + 2)}┤`;
597
+ const bottomBorder = `└${"─".repeat(colW.icon + 2)}┴${"─".repeat(colW.label + 2)}┴${"─".repeat(colW.a + 2)}┴${"─".repeat(colW.b + 2)}┴${"─".repeat(colW.delta + 2)}┘`;
598
+ const pad = (s, w, align = "right") =>
599
+ align === "left" ? s.padEnd(w) : s.padStart(w);
600
+
601
+ log(`\n ${title}`);
602
+ log(topBorder);
603
+ log(`│ ${pad("", colW.icon, "left")} │ ${pad("Métrica", colW.label, "left")} │ ${pad(nameA, colW.a)} │ ${pad(nameB, colW.b)} │ ${pad("Delta", colW.delta)} │`);
604
+ log(sep);
605
+ for (const r of rows) {
606
+ log(`│ ${pad(r.icon, colW.icon, "left")} │ ${pad(r.label, colW.label, "left")} │ ${pad(r.a, colW.a)} │ ${pad(r.b, colW.b)} │ ${pad(r.delta, colW.delta)} │`);
607
+ }
608
+ log(bottomBorder);
609
+ }
610
+
611
+ function printList(title, items) {
612
+ if (!items.length) return;
613
+ log(`\n ${title}`);
614
+ for (const item of items) log(` ${item}`);
615
+ }
616
+
617
+ // ── markdown table ───────────────────────────────────────────────────────────
618
+
619
+ function mdTable(title, rows) {
620
+ if (!rows.length) return "";
621
+ const lines = [];
622
+ lines.push(`### ${title}\n`);
623
+ lines.push(`| | Métrica | ${nameA} | ${nameB} | Delta |`);
624
+ lines.push(`|---|---|---:|---:|---:|`);
625
+ for (const r of rows) {
626
+ lines.push(`| ${r.mdIcon} | ${r.label} | ${r.a} | ${r.b} | ${r.delta} |`);
627
+ }
628
+ lines.push("");
629
+ return lines.join("\n");
630
+ }
631
+
632
+ function mdList(title, items) {
633
+ if (!items.length) return "";
634
+ const lines = [`**${title}**\n`];
635
+ for (const item of items) lines.push(`- ${item}`);
636
+ lines.push("");
637
+ return lines.join("\n");
638
+ }
639
+
640
+ function mdDetailBlock(title, sections) {
641
+ const lines = [`<details>\n<summary>${title}</summary>\n`];
642
+ for (const [label, items] of sections) {
643
+ lines.push(`**${label}:**\n`);
644
+ lines.push("```");
645
+ for (const item of items) lines.push(item);
646
+ lines.push("```\n");
647
+ }
648
+ lines.push("</details>\n");
649
+ return lines.join("\n");
650
+ }
651
+
652
+ // ── main ─────────────────────────────────────────────────────────────────────
653
+
654
+ log(`\n ⚡ Chrome Trace Comparison`);
655
+ log(` ${nameA} vs ${nameB}`);
656
+ log(` Mode: ${modeLabel}`);
657
+ if (outputFile) log(` Output: ${outputFile}`);
658
+ log("");
659
+
660
+ const a = extract(pathA);
661
+ const b = extract(pathB);
662
+
663
+ // ── build rows ───────────────────────────────────────────────────────────────
664
+
665
+ const cwvRows = [
666
+ row("FCP", a.marks.firstContentfulPaint, b.marks.firstContentfulPaint),
667
+ row("LCP", a.lcp?.ts_ms, b.lcp?.ts_ms),
668
+ row("CLS", a.cls, b.cls, "", true),
669
+ row("TBT", a.tbt, b.tbt),
670
+ ];
671
+
672
+ const docRows = [
673
+ row("HTML encoded", a.doc?.encoded_kb, b.doc?.encoded_kb, "KB"),
674
+ row("HTML decoded", a.doc?.decoded_kb, b.doc?.decoded_kb, "KB"),
675
+ row("TTFB", a.doc?.ttfb_ms, b.doc?.ttfb_ms),
676
+ row("Doc download", a.doc?.total_ms, b.doc?.total_ms),
677
+ infoRow("_serverFn payload", a.serverFn.decoded_kb, b.serverFn.decoded_kb, "KB"),
678
+ infoRow("_serverFn calls", a.serverFn.count, b.serverFn.count, ""),
679
+ row("ParseHTML total", a.parseHTML.total_ms, b.parseHTML.total_ms),
680
+ ];
681
+
682
+ const domRows = [
683
+ row("domInteractive", a.marks.domInteractive, b.marks.domInteractive),
684
+ row("domContentLoaded", a.marks.domContentLoadedEventEnd, b.marks.domContentLoadedEventEnd),
685
+ row("domComplete", a.marks.domComplete, b.marks.domComplete),
686
+ row("loadEventEnd", a.marks.loadEventEnd, b.marks.loadEventEnd),
687
+ ];
688
+
689
+ const imgRows = [
690
+ row("Total images", a.images.total, b.images.total, "", true),
691
+ row("Before deferred", a.images.before_deferred, b.images.before_deferred, "", true),
692
+ row("After deferred", a.images.after_deferred, b.images.after_deferred, "", true),
693
+ row("Images size", a.images.total_kb, b.images.total_kb, "KB", true),
694
+ ];
695
+
696
+ const jsRows = [
697
+ row("JS bundles", a.js.count, b.js.count, "", true),
698
+ row("Total JS size", a.js.total_kb, b.js.total_kb, "KB", true),
699
+ ];
700
+ if (!MODE_ALL) {
701
+ jsRows.push(
702
+ row("1st-party JS", a.js.firstParty_kb, b.js.firstParty_kb, "KB", true),
703
+ row("3rd-party JS", a.js.thirdParty_kb, b.js.thirdParty_kb, "KB", true)
704
+ );
705
+ }
706
+
707
+ const layoutRows = [
708
+ row("Long tasks (>50ms)", a.longTasks.length, b.longTasks.length, "", true),
709
+ row("LayoutTree count", a.layout.count, b.layout.count, "", true),
710
+ row("LayoutTree total", a.layout.total_ms, b.layout.total_ms),
711
+ row("Paint events", a.paint.count, b.paint.count, "", true),
712
+ row("Paint total", a.paint.total_ms, b.paint.total_ms),
713
+ ];
714
+
715
+ const v8Rows = [
716
+ row("Compile count", a.v8.count, b.v8.count, "", true),
717
+ row("Compile total", a.v8.total_ms, b.v8.total_ms),
718
+ ];
719
+
720
+ const netRows = [
721
+ row("Total requests", a.requests, b.requests, "", true),
722
+ ];
723
+
724
+ // Domain rows
725
+ const allDomains = new Set([
726
+ ...a.domains.map((d) => d.domain),
727
+ ...b.domains.map((d) => d.domain),
728
+ ]);
729
+ const domainData = [...allDomains]
730
+ .map((d) => {
731
+ const ac = a.domains.find((x) => x.domain === d)?.count || 0;
732
+ const bc = b.domains.find((x) => x.domain === d)?.count || 0;
733
+ return { domain: d, a: ac, b: bc, total: ac + bc };
734
+ })
735
+ .sort((x, y) => y.total - x.total)
736
+ .slice(0, 15);
737
+ const domainTableRows = domainData.map((d) =>
738
+ row(d.domain.slice(0, 40), d.a, d.b, "", true)
739
+ );
740
+
741
+ const aOnly = a.domains.filter((d) => !b.domains.find((x) => x.domain === d.domain)).map((d) => d.domain);
742
+ const bOnly = b.domains.filter((d) => !a.domains.find((x) => x.domain === d.domain)).map((d) => d.domain);
743
+
744
+ // Score
745
+ const scoredMetrics = [
746
+ { label: "LCP", a: a.lcp?.ts_ms, b: b.lcp?.ts_ms, weight: 25 },
747
+ { label: "CLS", a: a.cls, b: b.cls, weight: 25 },
748
+ { label: "TBT", a: a.tbt, b: b.tbt, weight: 30 },
749
+ { label: "Requests", a: a.requests, b: b.requests, weight: 10 },
750
+ { label: "JS Size", a: a.js.total_kb, b: b.js.total_kb, weight: 10 },
751
+ ];
752
+
753
+ let wins = 0, losses = 0, ties = 0;
754
+ for (const m of scoredMetrics) {
755
+ if (m.a == null || m.b == null) continue;
756
+ if (m.a < m.b - 0.001) wins++;
757
+ else if (m.a > m.b + 0.001) losses++;
758
+ else ties++;
759
+ }
760
+
761
+ const scoreA = scoredMetrics.reduce((sum, m) => {
762
+ if (m.a == null || m.b == null || m.b === 0) return sum;
763
+ const ratio = m.a / m.b;
764
+ return sum + (ratio < 1 ? m.weight : -m.weight * (ratio - 1));
765
+ }, 0);
766
+
767
+ const verdict =
768
+ scoreA > 10 ? `${nameA} is significantly better`
769
+ : scoreA > 0 ? `${nameA} is slightly better`
770
+ : scoreA < -10 ? `${nameB} is significantly better`
771
+ : scoreA < 0 ? `${nameB} is slightly better`
772
+ : "Roughly equal";
773
+
774
+ // ── terminal output ──────────────────────────────────────────────────────────
775
+
776
+ printTable("Core Web Vitals", cwvRows);
777
+ printTable("Document & SSR", docRows);
778
+ printTable("DOM Timing", domRows);
779
+ printTable("Images", imgRows);
780
+ printTable("JavaScript", jsRows);
781
+ printTable("Layout & Rendering", layoutRows);
782
+ printTable("V8 Compile", v8Rows);
783
+ printTable("Network", netRows);
784
+ printTable("Top Domains", domainTableRows);
785
+
786
+ if (aOnly.length) printList(`Domains ONLY in ${nameA}:`, aOnly);
787
+ if (bOnly.length) printList(`Domains ONLY in ${nameB}:`, bOnly);
788
+
789
+ if (a.scripts.length || b.scripts.length) {
790
+ log(`\n Top Script Evaluation`);
791
+ for (const [label, scripts] of [[nameA, a.scripts], [nameB, b.scripts]]) {
792
+ log(` ${label}:`);
793
+ for (const s of scripts.slice(0, 8)) {
794
+ const tag = s.isThirdParty ? " [3P]" : "";
795
+ log(` ${s.dur_ms.toFixed(1)}ms @${s.ts_ms.toFixed(0)}ms ${s.url}${tag}`);
796
+ }
797
+ }
798
+ }
799
+
800
+ if (a.v8.top.length || b.v8.top.length) {
801
+ log(`\n Top V8 Compile`);
802
+ for (const [label, top] of [[nameA, a.v8.top], [nameB, b.v8.top]]) {
803
+ log(` ${label}:`);
804
+ for (const c of top.slice(0, 5)) {
805
+ log(` ${c.dur_ms.toFixed(1)}ms ${c.url}`);
806
+ }
807
+ }
808
+ }
809
+
810
+ if (a.js.top.length || b.js.top.length) {
811
+ log(`\n Top JS Bundles by Size`);
812
+ for (const [label, top] of [[nameA, a.js.top], [nameB, b.js.top]]) {
813
+ log(` ${label}:`);
814
+ for (const j of top.slice(0, 10)) {
815
+ const short = j.url.length > 80 ? "..." + j.url.slice(-77) : j.url;
816
+ const tag = j.isThirdParty ? " [3P]" : "";
817
+ log(` ${j.kb.toFixed(1)}KB ${short}${tag}`);
818
+ }
819
+ }
820
+ }
821
+
822
+ if (a.longTasks.length || b.longTasks.length) {
823
+ log(`\n Long Tasks (>50ms)`);
824
+ for (const [label, tasks] of [[nameA, a.longTasks], [nameB, b.longTasks]]) {
825
+ log(` ${label}: ${tasks.length} tasks`);
826
+ for (const t of tasks.slice(0, 8)) {
827
+ const script = t.script ? ` ${t.script}` : "";
828
+ log(` ${t.dur_ms.toFixed(1)}ms @${t.ts_ms.toFixed(0)}ms${script}`);
829
+ }
830
+ }
831
+ }
832
+
833
+ log(`\n ${"═".repeat(50)}`);
834
+ log(` SUMMARY`);
835
+ log(` ${"═".repeat(50)}`);
836
+ log(`\n ${nameA} wins: ${wins} | ${nameB} wins: ${losses} | ties: ${ties}`);
837
+ log(` Weighted score: ${scoreA.toFixed(1)} → ${verdict}`);
838
+ log("");
839
+
840
+ // Print terminal output
841
+ console.log(output.join("\n"));
842
+
843
+ // ── markdown output ──────────────────────────────────────────────────────────
844
+
845
+ if (outputFile) {
846
+ const md = [];
847
+ const date = new Date().toISOString().split("T")[0];
848
+
849
+ md.push(`# Performance Trace Comparison`);
850
+ md.push(`> **${nameA}** vs **${nameB}** | ${date} | Mode: ${modeLabel}\n`);
851
+
852
+ md.push(mdTable("Core Web Vitals", cwvRows));
853
+ md.push(mdTable("Document & SSR", docRows));
854
+ md.push(mdTable("DOM Timing", domRows));
855
+ md.push(mdTable("Images", imgRows));
856
+ md.push(mdTable("JavaScript", jsRows));
857
+ md.push(mdTable("Layout & Rendering", layoutRows));
858
+ md.push(mdTable("V8 Compile", v8Rows));
859
+ md.push(mdTable("Network", netRows));
860
+ md.push(mdTable("Top Domains", domainTableRows));
861
+
862
+ if (aOnly.length) md.push(mdList(`Domains only in ${nameA}`, aOnly));
863
+ if (bOnly.length) md.push(mdList(`Domains only in ${nameB}`, bOnly));
864
+
865
+ // Detail blocks
866
+ const scriptSections = [];
867
+ for (const [label, scripts] of [[nameA, a.scripts], [nameB, b.scripts]]) {
868
+ if (scripts.length) {
869
+ scriptSections.push([
870
+ label,
871
+ scripts.slice(0, 8).map((s) => {
872
+ const tag = s.isThirdParty ? " [3P]" : "";
873
+ return `${s.dur_ms.toFixed(1)}ms @${s.ts_ms.toFixed(0)}ms ${s.url}${tag}`;
874
+ }),
875
+ ]);
876
+ }
877
+ }
878
+ if (scriptSections.length) md.push(mdDetailBlock("Top Script Evaluation", scriptSections));
879
+
880
+ const jsSections = [];
881
+ for (const [label, top] of [[nameA, a.js.top], [nameB, b.js.top]]) {
882
+ if (top.length) {
883
+ jsSections.push([
884
+ label,
885
+ top.slice(0, 10).map((j) => {
886
+ const short = j.url.length > 80 ? "..." + j.url.slice(-77) : j.url;
887
+ const tag = j.isThirdParty ? " [3P]" : "";
888
+ return `${j.kb.toFixed(1)}KB ${short}${tag}`;
889
+ }),
890
+ ]);
891
+ }
892
+ }
893
+ if (jsSections.length) md.push(mdDetailBlock("Top JS Bundles by Size", jsSections));
894
+
895
+ const taskSections = [];
896
+ for (const [label, tasks] of [[nameA, a.longTasks], [nameB, b.longTasks]]) {
897
+ if (tasks.length) {
898
+ taskSections.push([
899
+ `${label} (${tasks.length} tasks)`,
900
+ tasks.slice(0, 8).map((t) => {
901
+ const script = t.script ? ` ${t.script}` : "";
902
+ return `${t.dur_ms.toFixed(1)}ms @${t.ts_ms.toFixed(0)}ms${script}`;
903
+ }),
904
+ ]);
905
+ }
906
+ }
907
+ if (taskSections.length) md.push(mdDetailBlock("Long Tasks (>50ms)", taskSections));
908
+
909
+ md.push(`---\n`);
910
+ md.push(`## Summary\n`);
911
+ md.push(`**${nameA}** wins: ${wins} | **${nameB}** wins: ${losses} | ties: ${ties}\n`);
912
+ md.push(`Weighted score: **${scoreA.toFixed(1)}** → ${verdict}\n`);
913
+
914
+ writeFileSync(outputFile, md.join("\n"), "utf-8");
915
+ console.log(`\n 📄 Report saved to ${outputFile}`);
916
+ }
917
+
918
+ // ── AI context output ────────────────────────────────────────────────────────
919
+
920
+ if (MODE_AI) {
921
+ const aiFile = outputFile
922
+ ? outputFile.replace(/\.md$/, "-ai-context.md")
923
+ : "trace-ai-context.md";
924
+
925
+ const date = new Date().toISOString().split("T")[0];
926
+ const ai = [];
927
+
928
+ ai.push(`# Performance Trace Analysis Context`);
929
+ ai.push(`> Generated ${date} | ${nameA} vs ${nameB} | Mode: ${modeLabel}\n`);
930
+ ai.push(`## Instructions\n`);
931
+ ai.push(`You are analyzing Chrome Performance traces comparing two versions of a website.`);
932
+ ai.push(`**${nameA}** is the variant being tested. **${nameB}** is the baseline/control.`);
933
+ ai.push(`Use the raw data below to:`);
934
+ ai.push(`1. Identify the top 3-5 performance wins and losses`);
935
+ ai.push(`2. Find root causes for regressions (CLS, TBT, long tasks, large bundles)`);
936
+ ai.push(`3. Suggest concrete, actionable fixes ranked by impact`);
937
+ ai.push(`4. Call out any third-party scripts causing disproportionate impact`);
938
+ ai.push(`5. Note any anomalies in the data (missing metrics, unexpected patterns)\n`);
939
+
940
+ // Raw metrics as structured data
941
+ ai.push(`## Raw Metrics\n`);
942
+ ai.push("```json");
943
+
944
+ const rawData = {
945
+ date,
946
+ mode: modeLabel,
947
+ traceA: { label: nameA, file: pathA },
948
+ traceB: { label: nameB, file: pathB },
949
+ coreWebVitals: {
950
+ [nameA]: {
951
+ FCP: a.marks.firstContentfulPaint ?? null,
952
+ LCP: a.lcp?.ts_ms ?? null,
953
+ CLS: a.cls,
954
+ TBT: a.tbt,
955
+ },
956
+ [nameB]: {
957
+ FCP: b.marks.firstContentfulPaint ?? null,
958
+ LCP: b.lcp?.ts_ms ?? null,
959
+ CLS: b.cls,
960
+ TBT: b.tbt,
961
+ },
962
+ },
963
+ document: {
964
+ [nameA]: {
965
+ htmlEncoded_kb: a.doc?.encoded_kb ?? null,
966
+ htmlDecoded_kb: a.doc?.decoded_kb ?? null,
967
+ ttfb_ms: a.doc?.ttfb_ms ?? null,
968
+ docDownload_ms: a.doc?.total_ms ?? null,
969
+ serverFnPayload_kb: a.serverFn.decoded_kb,
970
+ serverFnCalls: a.serverFn.count,
971
+ parseHTML_ms: a.parseHTML.total_ms,
972
+ },
973
+ [nameB]: {
974
+ htmlEncoded_kb: b.doc?.encoded_kb ?? null,
975
+ htmlDecoded_kb: b.doc?.decoded_kb ?? null,
976
+ ttfb_ms: b.doc?.ttfb_ms ?? null,
977
+ docDownload_ms: b.doc?.total_ms ?? null,
978
+ serverFnPayload_kb: b.serverFn.decoded_kb,
979
+ serverFnCalls: b.serverFn.count,
980
+ parseHTML_ms: b.parseHTML.total_ms,
981
+ },
982
+ },
983
+ domTiming: {
984
+ [nameA]: {
985
+ domInteractive: a.marks.domInteractive,
986
+ domContentLoaded: a.marks.domContentLoadedEventEnd,
987
+ domComplete: a.marks.domComplete,
988
+ loadEventEnd: a.marks.loadEventEnd,
989
+ },
990
+ [nameB]: {
991
+ domInteractive: b.marks.domInteractive,
992
+ domContentLoaded: b.marks.domContentLoadedEventEnd,
993
+ domComplete: b.marks.domComplete,
994
+ loadEventEnd: b.marks.loadEventEnd,
995
+ },
996
+ },
997
+ images: {
998
+ [nameA]: a.images,
999
+ [nameB]: b.images,
1000
+ },
1001
+ javascript: {
1002
+ [nameA]: {
1003
+ bundles: a.js.count,
1004
+ total_kb: a.js.total_kb,
1005
+ firstParty_kb: a.js.firstParty_kb,
1006
+ thirdParty_kb: a.js.thirdParty_kb,
1007
+ topBundles: a.js.top.slice(0, 10).map((j) => ({
1008
+ url: j.url.length > 120 ? "..." + j.url.slice(-117) : j.url,
1009
+ kb: j.kb,
1010
+ thirdParty: j.isThirdParty,
1011
+ })),
1012
+ },
1013
+ [nameB]: {
1014
+ bundles: b.js.count,
1015
+ total_kb: b.js.total_kb,
1016
+ firstParty_kb: b.js.firstParty_kb,
1017
+ thirdParty_kb: b.js.thirdParty_kb,
1018
+ topBundles: b.js.top.slice(0, 10).map((j) => ({
1019
+ url: j.url.length > 120 ? "..." + j.url.slice(-117) : j.url,
1020
+ kb: j.kb,
1021
+ thirdParty: j.isThirdParty,
1022
+ })),
1023
+ },
1024
+ },
1025
+ layout: {
1026
+ [nameA]: {
1027
+ longTasks: a.longTasks.length,
1028
+ longTasksDetail: a.longTasks.slice(0, 10).map((t) => ({
1029
+ dur_ms: t.dur_ms,
1030
+ ts_ms: t.ts_ms,
1031
+ script: t.script || null,
1032
+ })),
1033
+ layoutTreeCount: a.layout.count,
1034
+ layoutTreeTotal_ms: a.layout.total_ms,
1035
+ paintEvents: a.paint.count,
1036
+ paintTotal_ms: a.paint.total_ms,
1037
+ },
1038
+ [nameB]: {
1039
+ longTasks: b.longTasks.length,
1040
+ longTasksDetail: b.longTasks.slice(0, 10).map((t) => ({
1041
+ dur_ms: t.dur_ms,
1042
+ ts_ms: t.ts_ms,
1043
+ script: t.script || null,
1044
+ })),
1045
+ layoutTreeCount: b.layout.count,
1046
+ layoutTreeTotal_ms: b.layout.total_ms,
1047
+ paintEvents: b.paint.count,
1048
+ paintTotal_ms: b.paint.total_ms,
1049
+ },
1050
+ },
1051
+ v8Compile: {
1052
+ [nameA]: {
1053
+ count: a.v8.count,
1054
+ total_ms: a.v8.total_ms,
1055
+ top: a.v8.top.slice(0, 8),
1056
+ },
1057
+ [nameB]: {
1058
+ count: b.v8.count,
1059
+ total_ms: b.v8.total_ms,
1060
+ top: b.v8.top.slice(0, 8),
1061
+ },
1062
+ },
1063
+ scriptEvaluation: {
1064
+ [nameA]: a.scripts.slice(0, 10).map((s) => ({
1065
+ url: s.url,
1066
+ dur_ms: s.dur_ms,
1067
+ ts_ms: s.ts_ms,
1068
+ thirdParty: s.isThirdParty,
1069
+ })),
1070
+ [nameB]: b.scripts.slice(0, 10).map((s) => ({
1071
+ url: s.url,
1072
+ dur_ms: s.dur_ms,
1073
+ ts_ms: s.ts_ms,
1074
+ thirdParty: s.isThirdParty,
1075
+ })),
1076
+ },
1077
+ network: {
1078
+ [nameA]: { totalRequests: a.requests, domains: a.domains.slice(0, 20) },
1079
+ [nameB]: { totalRequests: b.requests, domains: b.domains.slice(0, 20) },
1080
+ domainsOnlyInA: aOnly,
1081
+ domainsOnlyInB: bOnly,
1082
+ },
1083
+ summary: {
1084
+ wins: { [nameA]: wins, [nameB]: losses, ties },
1085
+ weightedScore: scoreA,
1086
+ verdict,
1087
+ },
1088
+ };
1089
+
1090
+ ai.push(JSON.stringify(rawData, null, 2));
1091
+ ai.push("```\n");
1092
+
1093
+ // Also include the formatted comparison tables for quick reference
1094
+ ai.push(`## Formatted Comparison\n`);
1095
+ ai.push(`The tables below are the same data formatted for readability.\n`);
1096
+
1097
+ const tables = [
1098
+ ["Core Web Vitals", cwvRows],
1099
+ ["Document & SSR", docRows],
1100
+ ["DOM Timing", domRows],
1101
+ ["Images", imgRows],
1102
+ ["JavaScript", jsRows],
1103
+ ["Layout & Rendering", layoutRows],
1104
+ ["V8 Compile", v8Rows],
1105
+ ["Network", netRows],
1106
+ ];
1107
+ for (const [title, rows] of tables) {
1108
+ ai.push(mdTable(title, rows));
1109
+ }
1110
+
1111
+ ai.push(`## Summary\n`);
1112
+ ai.push(`**${nameA}** wins: ${wins} | **${nameB}** wins: ${losses} | ties: ${ties}`);
1113
+ ai.push(`Weighted score: **${scoreA.toFixed(1)}** → ${verdict}\n`);
1114
+
1115
+ writeFileSync(aiFile, ai.join("\n"), "utf-8");
1116
+ console.log(`\n 🤖 AI context saved to ${aiFile}`);
1117
+ }
@@ -544,66 +544,19 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
544
544
  await preloadSectionComponents(keys);
545
545
  }
546
546
 
547
- // SSR: create unawaited promises for TanStack native streaming.
548
- // Each deferred section becomes a promise that TanStack streams
549
- // via SSR chunked transfer all resolved in the SAME request.
547
+ // Deferred sections use client-side IntersectionObserver loading.
548
+ // DecoPageRenderer renders skeletons (LoadingFallback) during SSR and
549
+ // loads full section content via _serverFn when scrolled into view.
550
550
  //
551
- // IMPORTANT: We call resolveDeferredSectionFull directly instead of
552
- // loadDeferredSection (server function). Server functions serialize
553
- // their return via JSON-RPC TanStack only streams promises that
554
- // are directly in the loader return, not serialized server fn results.
555
- if (isServer && page.deferredSections?.length) {
556
- const originRequest = getRequest();
557
- const serverUrl = getRequestUrl();
558
- const matcherCtx: MatcherContext = {
559
- userAgent: getRequestHeader("user-agent") ?? "",
560
- url: page.pageUrl ?? serverUrl.toString(),
561
- path: page.pagePath ?? basePath,
562
- cookies: getCookies(),
563
- request: originRequest,
564
- };
565
- const deferredRequest = new Request(page.pageUrl ?? serverUrl.toString(), {
566
- headers: originRequest.headers,
567
- });
568
-
569
- const deferredPromises: Record<string, Promise<ResolvedSection | null>> = {};
570
- for (const ds of page.deferredSections) {
571
- deferredPromises[`d_${ds.index}`] = resolveDeferredSectionFull(
572
- ds,
573
- page.pagePath ?? basePath,
574
- deferredRequest,
575
- matcherCtx,
576
- ).catch((e) => {
577
- console.error(`[CMS] Deferred section "${ds.component}" failed:`, e);
578
- return null;
579
- });
580
- }
581
- return { ...page, deferredPromises };
582
- }
583
-
584
- // Client SPA navigation: resolve all deferred sections via server
585
- // function batch and merge into resolvedSections for immediate render.
586
- if (!isServer && page.deferredSections?.length) {
587
- const resolved = await Promise.all(
588
- page.deferredSections.map((ds: DeferredSection) =>
589
- loadDeferredSection({
590
- data: {
591
- component: ds.component,
592
- rawProps: ds.rawProps,
593
- pagePath: page.pagePath ?? basePath,
594
- pageUrl: page.pageUrl,
595
- index: ds.index,
596
- },
597
- }).catch(() => null),
598
- ),
599
- );
600
- const all = [
601
- ...page.resolvedSections,
602
- ...resolved.filter((s): s is ResolvedSection => s != null),
603
- ].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
604
- return { ...page, resolvedSections: all, deferredSections: [] };
605
- }
606
-
551
+ // Previously, SSR streaming via TanStack <Await> resolved ALL deferred
552
+ // sections server-side and streamed them in the initial HTML. This caused
553
+ // the browser to load ALL product shelf images at once (~3x more image
554
+ // requests than the IntersectionObserver path). Client SPA navigation
555
+ // also blocked on await Promise.all() for all deferred sections.
556
+ //
557
+ // The IO path (DeferredSectionWrapper) only fetches section data when
558
+ // the skeleton scrolls into view, matching Fresh/Deno partial behavior
559
+ // and significantly reducing initial image load and TBT.
607
560
  return page;
608
561
  },
609
562
 
@@ -653,55 +606,8 @@ export function cmsHomeRouteConfig(options: {
653
606
  await preloadSectionComponents(keys);
654
607
  }
655
608
 
656
- // SSR: create unawaited promises for TanStack native streaming
657
- if (isServer && page.deferredSections?.length) {
658
- const originRequest = getRequest();
659
- const serverUrl = getRequestUrl();
660
- const matcherCtx: MatcherContext = {
661
- userAgent: getRequestHeader("user-agent") ?? "",
662
- url: page.pageUrl ?? serverUrl.toString(),
663
- path: "/",
664
- cookies: getCookies(),
665
- request: originRequest,
666
- };
667
- const deferredRequest = new Request(page.pageUrl ?? serverUrl.toString(), {
668
- headers: originRequest.headers,
669
- });
670
-
671
- const deferredPromises: Record<string, Promise<ResolvedSection | null>> = {};
672
- for (const ds of page.deferredSections) {
673
- deferredPromises[`d_${ds.index}`] = resolveDeferredSectionFull(
674
- ds, "/", deferredRequest, matcherCtx,
675
- ).catch((e) => {
676
- console.error(`[CMS] Deferred section "${ds.component}" failed:`, e);
677
- return null;
678
- });
679
- }
680
- return { ...page, deferredPromises };
681
- }
682
-
683
- // Client SPA navigation: resolve all deferred via server function batch
684
- if (!isServer && page.deferredSections?.length) {
685
- const resolved = await Promise.all(
686
- page.deferredSections.map((ds: DeferredSection) =>
687
- loadDeferredSection({
688
- data: {
689
- component: ds.component,
690
- rawProps: ds.rawProps,
691
- pagePath: "/",
692
- pageUrl: page.pageUrl,
693
- index: ds.index,
694
- },
695
- }).catch(() => null),
696
- ),
697
- );
698
- const all = [
699
- ...page.resolvedSections,
700
- ...resolved.filter((s): s is ResolvedSection => s != null),
701
- ].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
702
- return { ...page, resolvedSections: all, deferredSections: [] };
703
- }
704
-
609
+ // Deferred sections use client-side IntersectionObserver loading.
610
+ // See cmsRouteConfig loader for rationale.
705
611
  return page;
706
612
  },
707
613
  ...(options.pendingComponent ? { pendingComponent: options.pendingComponent } : {}),