webperf-snippets 0.2.0 → 0.4.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.
Files changed (73) hide show
  1. package/README.md +142 -14
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +44 -9
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +72 -24
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +86 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +74 -11
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1179 -0
  59. package/snippets/_shared/helpers.js +61 -0
  60. package/src/bin.js +72 -45
  61. package/src/crux.js +84 -0
  62. package/src/decision-tree.js +55 -7
  63. package/src/interactions.js +13 -3
  64. package/src/load-snippet.js +1 -1
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +43 -5
  67. package/src/reporters/markdown.js +27 -1
  68. package/src/runner.js +75 -21
  69. package/src/snippet-names.js +89 -0
  70. package/src/tracking.js +6 -0
  71. package/src/workflows/audit.js +17 -0
  72. package/src/workflows/interaction.js +30 -0
  73. package/src/workflows/loading.js +9 -0
@@ -0,0 +1,134 @@
1
+ // DOM Size and Depth
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ // Lighthouse "Avoid an excessive DOM size" thresholds
6
+ const THRESHOLDS = { warnNodes: 800, errorNodes: 1400, maxDepth: 32, maxChildren: 60 };
7
+ const MAX_ITEMS = 50;
8
+ const SELECTOR_LEVELS = 4;
9
+
10
+ // Short selector for reporting: stops at the nearest id, at most SELECTOR_LEVELS levels
11
+ // up, and marks open shadow root boundaries with ">>>". Never returns DOM nodes.
12
+ const selectorFor = (el) => {
13
+ const parts = [];
14
+ let node = el;
15
+ let truncated = false;
16
+ while (node && node.nodeType === 1) {
17
+ if (parts.length === SELECTOR_LEVELS) {
18
+ truncated = true;
19
+ break;
20
+ }
21
+ let part = node.localName;
22
+ if (node.id) {
23
+ parts.unshift(`${part}#${window.CSS.escape(node.id)}`);
24
+ break;
25
+ }
26
+ const cls = typeof node.className === "string" ? node.className.trim().split(/\s+/)[0] : "";
27
+ if (cls) part += `.${window.CSS.escape(cls)}`;
28
+ parts.unshift(part);
29
+ const parent = node.parentNode;
30
+ if (parent && parent.nodeType === 11 && parent.host) {
31
+ parts.unshift(">>>");
32
+ node = parent.host;
33
+ continue;
34
+ }
35
+ node = node.parentElement;
36
+ }
37
+ return (truncated ? "... > " : "") + parts.join(" > ").replace(/> >>> >/g, ">>>");
38
+ };
39
+
40
+ // Iterative walk (explicit stack) so very deep trees cannot overflow the call stack.
41
+ // Depth counts <html> as level 1 and continues through open shadow roots.
42
+ let totalElements = 0;
43
+ let maxDepth = 0;
44
+ let deepest = null;
45
+ let shadowRoots = 0;
46
+ const parents = [];
47
+ const stack = [document.documentElement, 1];
48
+
49
+ while (stack.length) {
50
+ const depth = stack.pop();
51
+ const el = stack.pop();
52
+ totalElements++;
53
+ if (depth > maxDepth) {
54
+ maxDepth = depth;
55
+ deepest = el;
56
+ }
57
+ const roots = [el];
58
+ if (el.shadowRoot) {
59
+ shadowRoots++;
60
+ roots.push(el.shadowRoot);
61
+ }
62
+ for (const root of roots) {
63
+ const kids = root.children;
64
+ if (kids.length) parents.push({ el, root, children: kids.length, depth });
65
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i], depth + 1);
66
+ }
67
+ }
68
+
69
+ const ranked = parents
70
+ .sort((a, b) => b.children - a.children || a.depth - b.depth);
71
+ const widest = ranked[0] ?? null;
72
+ const widestSelector = widest ? selectorFor(widest.el) + (widest.root !== widest.el ? " (shadow root)" : "") : null;
73
+
74
+ const items = ranked.slice(0, 10).map((p) => ({
75
+ selector: selectorFor(p.el) + (p.root !== p.el ? " (shadow root)" : ""),
76
+ children: p.children,
77
+ depth: p.depth,
78
+ }));
79
+
80
+ const issues = [];
81
+ if (totalElements > THRESHOLDS.errorNodes) {
82
+ issues.push({
83
+ severity: "error",
84
+ message: `${totalElements} nodes exceeds ${THRESHOLDS.errorNodes}; a large DOM slows style recalculation and layout on every interaction`,
85
+ });
86
+ } else if (totalElements > THRESHOLDS.warnNodes) {
87
+ issues.push({
88
+ severity: "warning",
89
+ message: `${totalElements} nodes exceeds ${THRESHOLDS.warnNodes}; a large DOM increases style recalculation and layout cost`,
90
+ });
91
+ }
92
+ if (maxDepth > THRESHOLDS.maxDepth) {
93
+ issues.push({
94
+ severity: "warning",
95
+ message: `DOM depth of ${maxDepth} exceeds ${THRESHOLDS.maxDepth}; deep trees make style and layout work costlier (deepest: ${selectorFor(deepest)})`,
96
+ });
97
+ }
98
+ if (widest && widest.children > THRESHOLDS.maxChildren) {
99
+ issues.push({
100
+ severity: "warning",
101
+ message: `${widestSelector} has ${widest.children} children, above ${THRESHOLDS.maxChildren}; consider virtualizing or paginating the list`,
102
+ });
103
+ }
104
+
105
+ const deepestElement = deepest ? selectorFor(deepest) : null;
106
+
107
+ // Human output
108
+ console.group("%cDOM size and depth", "font-weight: bold; font-size: 14px;");
109
+ console.log(`Elements: ${totalElements} (warn > ${THRESHOLDS.warnNodes}, error > ${THRESHOLDS.errorNodes})`);
110
+ console.log(`Max depth: ${maxDepth} (limit ${THRESHOLDS.maxDepth}) at ${deepestElement}`);
111
+ if (widest) console.log(`Widest parent: ${widestSelector} with ${widest.children} children (limit ${THRESHOLDS.maxChildren})`);
112
+ console.log(`Open shadow roots traversed: ${shadowRoots}`);
113
+ issues.forEach((i) => console.warn(`${i.severity}: ${i.message}`));
114
+ if (items.length) console.table(items);
115
+ console.groupEnd();
116
+
117
+ // Agent output
118
+ return {
119
+ script: "DOM-Size-and-Depth",
120
+ status: "ok",
121
+ count: items.length,
122
+ issues,
123
+ items: items.slice(0, MAX_ITEMS),
124
+ details: {
125
+ totalElements,
126
+ maxDepth,
127
+ maxChildren: widest ? widest.children : 0,
128
+ widestParent: widestSelector,
129
+ deepestElement,
130
+ shadowRoots,
131
+ thresholds: THRESHOLDS,
132
+ },
133
+ };
134
+ })();
@@ -179,7 +179,9 @@
179
179
  script: "Forced-Synchronous-Layout",
180
180
  status: "ok",
181
181
  count: 0,
182
- details: { fslEvents: [] },
182
+ details: {},
183
+ items: [],
184
+ issues: [],
183
185
  };
184
186
  }
185
187
 
@@ -235,13 +237,37 @@
235
237
  fastestSinceLastMutationMs: Math.min(
236
238
  ...fslEvents.map((e) => e.sinceLastMutationMs)
237
239
  ),
238
- fslEvents: fslEvents.map(({ property, accessType, element, sinceLastMutationMs }) => ({
239
- property,
240
- accessType,
241
- element,
242
- sinceLastMutationMs,
243
- })),
244
240
  },
241
+ // One item per property and element, most frequent first (at most 50)
242
+ items: Object.values(
243
+ fslEvents.reduce((acc, e) => {
244
+ const key = `${e.property}|${e.accessType}|${e.element}`;
245
+ if (!acc[key]) {
246
+ acc[key] = {
247
+ property: e.property,
248
+ accessType: e.accessType,
249
+ element: e.element,
250
+ count: 0,
251
+ fastestSinceLastMutationMs: e.sinceLastMutationMs,
252
+ };
253
+ }
254
+ acc[key].count++;
255
+ acc[key].fastestSinceLastMutationMs = Math.min(
256
+ acc[key].fastestSinceLastMutationMs,
257
+ e.sinceLastMutationMs
258
+ );
259
+ return acc;
260
+ }, {})
261
+ )
262
+ .sort((a, b) => b.count - a.count)
263
+ .slice(0, 50),
264
+ issues: Object.entries(byProperty)
265
+ .sort((a, b) => b[1] - a[1])
266
+ .slice(0, 10)
267
+ .map(([property, count]) => ({
268
+ severity: count > 5 ? "error" : "warning",
269
+ message: `${property} triggered ${count} forced synchronous layout${count > 1 ? "s" : ""}. Read layout properties before writing to the DOM.`,
270
+ })),
245
271
  };
246
272
  };
247
273
 
@@ -81,7 +81,12 @@
81
81
  console.log(
82
82
  " Interact with the page (click, type, etc.) and call this again."
83
83
  );
84
- return;
84
+ return {
85
+ script: "Input-Latency-Breakdown",
86
+ status: "error",
87
+ error: "No interactions recorded yet. Interact with the page (click, type, etc.) and call getInputLatencyBreakdown() again.",
88
+ getDataFn: "getInputLatencyBreakdown",
89
+ };
85
90
  }
86
91
 
87
92
  console.group(
@@ -171,11 +176,50 @@
171
176
  };
172
177
  }
173
178
  console.groupEnd();
179
+
180
+ const BOTTLENECK_FIXES = {
181
+ "Input Delay": "break up the long tasks that block the main thread (scheduler.yield(), setTimeout)",
182
+ Processing: "optimize the event handlers or debounce them",
183
+ Presentation: "reduce the DOM changes after the handler and avoid layout thrashing",
184
+ };
185
+
186
+ const items = types.map((type) => {
187
+ const b = byEventType[type];
188
+ return {
189
+ type,
190
+ count: b.count,
191
+ p75Ms: Math.round(p75(b.durations)),
192
+ inputDelayMs: Math.round(p75(b.inputDelays)),
193
+ processingMs: Math.round(p75(b.processingTimes)),
194
+ presentationMs: Math.round(p75(b.presentationDelays)),
195
+ rating: valueToRating(p75(b.durations)),
196
+ };
197
+ });
198
+
199
+ const issues = types
200
+ .filter((type) => valueToRating(p75(byEventType[type].durations)) !== "good")
201
+ .map((type) => {
202
+ const b = byEventType[type];
203
+ const phases = [
204
+ { name: "Input Delay", value: p75(b.inputDelays) },
205
+ { name: "Processing", value: p75(b.processingTimes) },
206
+ { name: "Presentation", value: p75(b.presentationDelays) },
207
+ ];
208
+ const bottleneck = phases.reduce((a, c) => (a.value > c.value ? a : c));
209
+ const rating = valueToRating(p75(b.durations));
210
+ return {
211
+ severity: rating === "poor" ? "error" : "warning",
212
+ message: `${type} P75 is ${Math.round(p75(b.durations))}ms (${rating}). Bottleneck: ${bottleneck.name}. Fix: ${BOTTLENECK_FIXES[bottleneck.name]}.`,
213
+ };
214
+ });
215
+
174
216
  return {
175
217
  script: "Input-Latency-Breakdown",
176
218
  status: "ok",
177
219
  count: types.length,
178
220
  details: { eventTypes: typeSummary },
221
+ items,
222
+ issues,
179
223
  };
180
224
  };
181
225
 
@@ -199,11 +199,22 @@
199
199
  p75Ms: Math.round(p75),
200
200
  byRating,
201
201
  },
202
- items: allInteractions.map((i) => ({
203
- type: i.type,
204
- durationMs: Math.round(i.duration),
205
- rating: i.rating,
206
- })),
202
+ // Slowest first, at most 50
203
+ items: [...allInteractions]
204
+ .sort((a, b) => b.duration - a.duration)
205
+ .slice(0, 50)
206
+ .map((i) => ({
207
+ type: i.type,
208
+ durationMs: Math.round(i.duration),
209
+ rating: i.rating,
210
+ })),
211
+ issues: [...slowInteractions]
212
+ .sort((a, b) => b.duration - a.duration)
213
+ .slice(0, 10)
214
+ .map((i) => ({
215
+ severity: i.rating === "poor" ? "error" : "warning",
216
+ message: `${i.type} interaction took ${Math.round(i.duration)}ms (${i.rating}), above the ${i.rating === "poor" ? "500ms" : "200ms"} threshold`,
217
+ })),
207
218
  };
208
219
  };
209
220
 
@@ -1,7 +1,11 @@
1
1
  // Layout Shift Tracking
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
- (() => {
4
+ (async () => {
5
+ if (!PerformanceObserver.supportedEntryTypes?.includes("layout-shift")) {
6
+ console.warn("⚠️ layout-shift entries are not supported in this browser.");
7
+ return { script: "Layout-Shift-Loading-and-Interaction", status: "unsupported", error: "layout-shift entries not supported in this browser" };
8
+ }
5
9
  // CLS thresholds
6
10
  const valueToRating = (score) =>
7
11
  score <= 0.1 ? "good" : score <= 0.25 ? "needs-improvement" : "poor";
@@ -18,11 +22,26 @@
18
22
  poor: "🔴",
19
23
  };
20
24
 
21
- // Track all shifts
25
+ // Track all shifts. CLS is the largest session window: shifts less than 1s apart,
26
+ // in a window of at most 5s.
22
27
  let totalCLS = 0;
28
+ let sessionValue = 0;
29
+ let sessionFirst = null;
30
+ let sessionLast = null;
23
31
  const allShifts = [];
24
32
  const elementShifts = new Map(); // Track shifts per element
25
33
 
34
+ // The five elements that shifted the most (same value at start and in the summary)
35
+ const getTopElements = () =>
36
+ Array.from(elementShifts.entries())
37
+ .sort((a, b) => b[1].totalShift - a[1].totalShift)
38
+ .slice(0, 5)
39
+ .map(([selector, data]) => ({
40
+ selector,
41
+ shiftCount: data.count,
42
+ totalImpact: Math.round(data.totalShift * 10000) / 10000,
43
+ }));
44
+
26
45
  const getElementSelector = (element) => {
27
46
  if (!element) return "(unknown)";
28
47
  if (element.id) return `#${element.id}`;
@@ -39,7 +58,18 @@
39
58
  const countedForCLS = !entry.hadRecentInput;
40
59
 
41
60
  if (countedForCLS) {
42
- totalCLS += entry.value;
61
+ if (
62
+ sessionLast &&
63
+ entry.startTime - sessionLast.startTime < 1000 &&
64
+ entry.startTime - sessionFirst.startTime < 5000
65
+ ) {
66
+ sessionValue += entry.value;
67
+ } else {
68
+ sessionValue = entry.value;
69
+ sessionFirst = entry;
70
+ }
71
+ sessionLast = entry;
72
+ totalCLS = Math.max(totalCLS, sessionValue);
43
73
  }
44
74
 
45
75
  // Get affected elements
@@ -81,7 +111,7 @@
81
111
  `font-weight: bold; color: ${countedForCLS ? clsColor : "#888"};`
82
112
  );
83
113
 
84
- console.log(`%cCumulative CLS: ${totalCLS.toFixed(4)}`, `color: ${clsColor}; font-weight: bold;`);
114
+ console.log(`%cCurrent CLS: ${totalCLS.toFixed(4)}`, `color: ${clsColor}; font-weight: bold;`);
85
115
  console.log(` Time: ${Math.round(entry.startTime)}ms`);
86
116
  console.log(` Had recent input: ${entry.hadRecentInput ? "Yes (excluded from CLS)" : "No"}`);
87
117
 
@@ -205,10 +235,7 @@
205
235
 
206
236
  console.groupEnd();
207
237
 
208
- const topElementsData = Array.from(elementShifts.entries())
209
- .sort((a, b) => b[1].totalShift - a[1].totalShift)
210
- .slice(0, 5)
211
- .map(([selector, data]) => ({ selector, shiftCount: data.count, totalImpact: Math.round(data.totalShift * 10000) / 10000 }));
238
+ const topElementsData = getTopElements();
212
239
  return {
213
240
  script: "Layout-Shift-Loading-and-Interaction",
214
241
  status: "ok",
@@ -227,6 +254,10 @@
227
254
  };
228
255
  };
229
256
 
257
+ // Buffered entries reach the observer callback asynchronously; wait for them so the
258
+ // summary below (and the return value) reflect shifts that happened before this ran.
259
+ await new Promise((resolve) => setTimeout(resolve, 100));
260
+
230
261
  // Initial message
231
262
  const rating = valueToRating(totalCLS);
232
263
  const icon = RATING_ICONS[rating];
@@ -240,11 +271,10 @@
240
271
  ""
241
272
  );
242
273
 
243
- // Synchronous return for agent (buffered layout-shift entries)
244
- const clsBufferedSync = performance.getEntriesByType("layout-shift")
245
- .reduce((sum, e) => !e.hadRecentInput ? sum + e.value : sum, 0);
246
- const countedSync = performance.getEntriesByType("layout-shift").filter((e) => !e.hadRecentInput).length;
247
- const excludedSync = performance.getEntriesByType("layout-shift").filter((e) => e.hadRecentInput).length;
274
+ // Return for agent (shifts collected by the buffered observer above)
275
+ const clsBufferedSync = totalCLS;
276
+ const countedSync = allShifts.filter((s) => s.countedForCLS).length;
277
+ const excludedSync = allShifts.length - countedSync;
248
278
  const clsRatingSync = valueToRating(clsBufferedSync);
249
279
  return {
250
280
  script: "Layout-Shift-Loading-and-Interaction",
@@ -259,6 +289,7 @@
259
289
  shiftCount: countedSync + excludedSync,
260
290
  countedShifts: countedSync,
261
291
  excludedShifts: excludedSync,
292
+ topElements: getTopElements(),
262
293
  },
263
294
  message: "Layout shift tracking active. Call getLayoutShiftSummary() for full element attribution.",
264
295
  getDataFn: "getLayoutShiftSummary",
@@ -354,6 +354,33 @@
354
354
  return capturedFrames;
355
355
  },
356
356
 
357
+ /**
358
+ * Get a structured summary (the value agents read through getDataFn)
359
+ */
360
+ getData() {
361
+ const blocking = capturedFrames.filter((f) => f.blockingDuration > 0);
362
+ const worst = capturedFrames.reduce((max, f) => Math.max(max, f.blockingDuration), 0);
363
+ return {
364
+ script: "Long-Animation-Frames-Helpers",
365
+ status: "ok",
366
+ count: capturedFrames.length,
367
+ details: {
368
+ withBlockingTime: blocking.length,
369
+ totalBlockingTimeMs: Math.round(blocking.reduce((sum, f) => sum + f.blockingDuration, 0)),
370
+ worstBlockingMs: Math.round(worst),
371
+ },
372
+ items: [...capturedFrames]
373
+ .sort((a, b) => b.duration - a.duration)
374
+ .slice(0, 20)
375
+ .map((f) => ({
376
+ startTimeMs: Math.round(f.startTime),
377
+ durationMs: Math.round(f.duration),
378
+ blockingDurationMs: Math.round(f.blockingDuration),
379
+ scriptCount: f.scripts.length,
380
+ })),
381
+ };
382
+ },
383
+
357
384
  /**
358
385
  * Clear all captured data
359
386
  */
@@ -392,6 +419,6 @@
392
419
  script: "Long-Animation-Frames-Helpers",
393
420
  status: "tracking",
394
421
  message: "LoAF Helpers loaded. Use loafHelpers.summary(), loafHelpers.topScripts(), etc. Call loafHelpers.getRawData() to get raw frame data.",
395
- getDataFn: "loafHelpers.getRawData",
422
+ getDataFn: "loafHelpers.getData",
396
423
  };
397
424
  })();
@@ -132,6 +132,14 @@
132
132
  category: data.category,
133
133
  durationMs: Math.round(data.duration),
134
134
  count: data.count,
135
+ functions: [...data.functions]
136
+ .sort((a, b) => b.duration - a.duration)
137
+ .slice(0, 5)
138
+ .map((fn) => ({
139
+ name: fn.name,
140
+ invoker: fn.invoker,
141
+ durationMs: Math.round(fn.duration),
142
+ })),
135
143
  })),
136
144
  };
137
145
  })();
@@ -77,6 +77,27 @@
77
77
  };
78
78
  };
79
79
 
80
+ // Item returned for agents: the same shape at start and in the summary
81
+ const toItem = (frame) => ({
82
+ startTimeMs: frame.startTime,
83
+ durationMs: frame.duration,
84
+ blockingDurationMs: frame.blockingDuration,
85
+ scripts: [...frame.scripts]
86
+ .sort((a, b) => b.duration - a.duration)
87
+ .slice(0, 10)
88
+ .map((s) => ({
89
+ invoker: s.invoker,
90
+ source: s.source,
91
+ durationMs: s.duration,
92
+ forcedStyleAndLayoutMs: s.forcedStyleAndLayout,
93
+ })),
94
+ });
95
+ const topFrames = (frames) =>
96
+ [...frames]
97
+ .sort((a, b) => b.blockingDuration - a.blockingDuration)
98
+ .slice(0, 20)
99
+ .map(toItem);
100
+
80
101
  const overlap = (e1, e2) =>
81
102
  e1.startTime < e2.startTime + e2.duration &&
82
103
  e2.startTime < e1.startTime + e1.duration;
@@ -164,7 +185,7 @@
164
185
  });
165
186
 
166
187
  loafObserver.observe({ type: "long-animation-frame", buffered: true });
167
- eventObserver.observe({ type: "event", buffered: true });
188
+ eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
168
189
 
169
190
  // Summary function
170
191
  window.getLoAFSummary = () => {
@@ -173,7 +194,13 @@
173
194
  if (allLoAFs.length === 0) {
174
195
  console.log(" No long animation frames recorded.");
175
196
  console.groupEnd();
176
- return;
197
+ return {
198
+ script: "Long-Animation-Frames",
199
+ status: "ok",
200
+ count: 0,
201
+ details: { totalLoAFs: 0, withBlockingTime: 0, totalBlockingTimeMs: 0, worstBlockingMs: 0, topScripts: [] },
202
+ items: [],
203
+ };
177
204
  }
178
205
 
179
206
  const blocking = allLoAFs.filter((l) => l.blockingDuration > 0);
@@ -266,6 +293,7 @@
266
293
  .slice(0, 5)
267
294
  .map((s) => ({ invoker: s.invoker, source: s.source, totalDurationMs: Math.round(s.totalDuration), count: s.count })),
268
295
  },
296
+ items: topFrames(allLoAFs),
269
297
  };
270
298
  };
271
299
 
@@ -292,6 +320,7 @@
292
320
  totalBlockingTimeMs: Math.round(totalBlockingSync),
293
321
  worstBlockingMs: Math.round(worstBlockingSync),
294
322
  },
323
+ items: topFrames(loafBuffered.map(processLoAF)),
295
324
  message: "Tracking long animation frames. Call getLoAFSummary() for full script attribution.",
296
325
  getDataFn: "getLoAFSummary",
297
326
  };
@@ -1,7 +1,11 @@
1
1
  // Long Tasks Tracking
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
- (() => {
4
+ (async () => {
5
+ if (!PerformanceObserver.supportedEntryTypes?.includes("longtask")) {
6
+ console.warn("⚠️ longtask entries are not supported in this browser.");
7
+ return { script: "LongTask", status: "unsupported", error: "longtask entries not supported in this browser" };
8
+ }
5
9
  const formatMs = (ms) => `${Math.round(ms)}ms`;
6
10
 
7
11
  // Severity helpers
@@ -14,6 +18,19 @@
14
18
 
15
19
  // Track all long tasks
16
20
  const allTasks = [];
21
+
22
+ // Item returned for agents: the same shape at start and in the summary. Slowest first, at most 50.
23
+ const toItems = (tasks) =>
24
+ [...tasks]
25
+ .sort((a, b) => b.duration - a.duration)
26
+ .slice(0, 50)
27
+ .map((t) => ({
28
+ startTimeMs: Math.round(t.startTime),
29
+ durationMs: Math.round(t.duration),
30
+ blockingTimeMs: Math.round(t.blockingTime),
31
+ severity: t.severity,
32
+ attribution: t.attribution,
33
+ }));
17
34
  let totalBlockingTime = 0;
18
35
 
19
36
  try {
@@ -79,7 +96,18 @@
79
96
  console.log(" No long tasks recorded.");
80
97
  console.log(" This is good! The main thread has been responsive.");
81
98
  console.groupEnd();
82
- return;
99
+ return {
100
+ script: "LongTask",
101
+ status: "ok",
102
+ count: 0,
103
+ details: {
104
+ totalBlockingTimeMs: 0,
105
+ worstTaskMs: 0,
106
+ avgDurationMs: 0,
107
+ bySeverity: { critical: 0, high: 0, medium: 0, low: 0 },
108
+ },
109
+ items: [],
110
+ };
83
111
  }
84
112
 
85
113
  const durations = allTasks.map((t) => t.duration);
@@ -119,7 +147,7 @@
119
147
  console.log("");
120
148
  console.log("%c⏱️ Timeline:", "font-weight: bold;");
121
149
 
122
- const tableData = allTasks
150
+ const tableData = [...allTasks]
123
151
  .sort((a, b) => b.duration - a.duration)
124
152
  .slice(0, 10)
125
153
  .map((t) => {
@@ -160,6 +188,7 @@
160
188
  avgDurationMs: Math.round(avg),
161
189
  bySeverity,
162
190
  },
191
+ items: toItems(allTasks),
163
192
  };
164
193
  };
165
194
 
@@ -172,8 +201,10 @@
172
201
  ""
173
202
  );
174
203
 
175
- // Synchronous return for agent (buffered entries)
176
- const longtaskBuffered = performance.getEntriesByType("longtask");
204
+ // Return for agent. Chrome only exposes longtask entries through a PerformanceObserver,
205
+ // so wait for the buffered observer above to deliver them.
206
+ await new Promise((resolve) => setTimeout(resolve, 100));
207
+ const longtaskBuffered = allTasks;
177
208
  const totalBlockingSync = longtaskBuffered.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0);
178
209
  const worstTaskSync = longtaskBuffered.length > 0 ? Math.max(...longtaskBuffered.map((t) => t.duration)) : 0;
179
210
  const bySeveritySync = {
@@ -191,6 +222,7 @@
191
222
  worstTaskMs: Math.round(worstTaskSync),
192
223
  bySeverity: bySeveritySync,
193
224
  },
225
+ items: toItems(allTasks),
194
226
  message: "Tracking long tasks. Call getLongTaskSummary() for statistics.",
195
227
  getDataFn: "getLongTaskSummary",
196
228
  };