webperf-snippets 0.2.0 → 0.3.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 (65) hide show
  1. package/README.md +120 -5
  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 +52 -30
  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 +40 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -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 +15 -0
  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 +55 -25
  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 +59 -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 +216 -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 +22 -3
  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 +1176 -0
  59. package/snippets/_shared/helpers.js +44 -0
  60. package/src/bin.js +55 -3
  61. package/src/crux.js +84 -0
  62. package/src/report-to.js +46 -0
  63. package/src/reporters/human.js +20 -1
  64. package/src/reporters/markdown.js +22 -1
  65. package/src/runner.js +4 -2
@@ -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
  };
@@ -188,6 +188,38 @@
188
188
  return results;
189
189
  };
190
190
 
191
+ // Findings as homogeneous items and actionable issues (same value at start and in the summary)
192
+ const buildFindings = (css) => {
193
+ const seen = new Set();
194
+ const items = [];
195
+ const add = (kind, target, detail) => {
196
+ const key = `${kind}|${target}|${detail}`;
197
+ if (seen.has(key)) return;
198
+ seen.add(key);
199
+ items.push({ kind, target, detail });
200
+ };
201
+ nonPassiveListeners.forEach((l) =>
202
+ add("non-passive-listener", `${l.element}${l.id ? `#${l.id}` : ""}`, l.type)
203
+ );
204
+ css.willChangeElements.forEach((e) => add("will-change", e.element, e.value));
205
+ css.overscrollElements.forEach((e) => add("overscroll", e.element, e.value));
206
+
207
+ const issues = [];
208
+ if (nonPassiveListeners.length > 0) {
209
+ issues.push({
210
+ severity: "warning",
211
+ message: `${nonPassiveListeners.length} non-passive scroll or touch listener(s). Add { passive: true } so scrolling does not wait for JavaScript.`,
212
+ });
213
+ }
214
+ if (css.willChangeElements.length > 0) {
215
+ issues.push({
216
+ severity: "info",
217
+ message: `will-change is set on ${css.willChangeElements.length} element(s). Remove it from elements that do not animate, since each one keeps a compositor layer.`,
218
+ });
219
+ }
220
+ return { items: items.slice(0, 50), issues };
221
+ };
222
+
191
223
  // ─── 4. SUMMARY ─────────────────────────────────────────────────────────────
192
224
 
193
225
  window.getScrollSummary = () => {
@@ -306,6 +338,7 @@
306
338
  console.groupEnd();
307
339
 
308
340
  const cssAuditResult = auditScrollCSS();
341
+ const findings = buildFindings(cssAuditResult);
309
342
  return {
310
343
  script: "Scroll-Performance",
311
344
  status: "ok",
@@ -315,6 +348,7 @@
315
348
  smoothScrollElements: cssAuditResult.smoothScrollElements.length,
316
349
  willChangeElements: cssAuditResult.willChangeElements.length,
317
350
  contentVisibilityElements: cssAuditResult.contentVisibilityElements.length,
351
+ overscrollElements: cssAuditResult.overscrollElements.length,
318
352
  },
319
353
  sessionCount: sessions.length,
320
354
  ...(sessions.length > 0 ? {
@@ -323,6 +357,8 @@
323
357
  totalDrops: sessions.reduce((a, s) => a + s.drops, 0),
324
358
  } : {}),
325
359
  },
360
+ items: findings.items,
361
+ issues: findings.issues,
326
362
  };
327
363
  };
328
364
 
@@ -342,6 +378,7 @@
342
378
  );
343
379
 
344
380
  const cssSnapshot = auditScrollCSS();
381
+ const initialFindings = buildFindings(cssSnapshot);
345
382
  return {
346
383
  script: "Scroll-Performance",
347
384
  status: "tracking",
@@ -351,8 +388,11 @@
351
388
  smoothScrollElements: cssSnapshot.smoothScrollElements.length,
352
389
  willChangeElements: cssSnapshot.willChangeElements.length,
353
390
  contentVisibilityElements: cssSnapshot.contentVisibilityElements.length,
391
+ overscrollElements: cssSnapshot.overscrollElements.length,
354
392
  },
355
393
  },
394
+ items: initialFindings.items,
395
+ issues: initialFindings.issues,
356
396
  message: "Scroll performance tracking active. Scroll the page then call getScrollSummary() for FPS data.",
357
397
  getDataFn: "getScrollSummary",
358
398
  };
@@ -58,6 +58,7 @@ When animations feel sluggish or the user asks "debug animation performance":
58
58
  2. **Long-Animation-Frames-Helpers.js** - Detailed frame timing analysis
59
59
  3. **Long-Animation-Frames-Script-Attribution.js** - Find scripts delaying frames
60
60
  4. **Scroll-Performance.js** - If scroll animations are involved
61
+ 5. **DOM-Size-and-Depth.js** - Check whether a large or deep DOM makes each style and layout pass expensive
61
62
 
62
63
  ### Third-Party Script Impact on Interactions
63
64
 
@@ -117,6 +118,7 @@ Use this decision tree to automatically run follow-up snippets based on results:
117
118
  - Debouncing/throttling event handlers
118
119
  - Web Workers for heavy computation
119
120
  - **If forced reflow/layout detected** → Analyze DOM manipulation patterns
121
+ - **If time goes to style recalculation or layout** → Run **DOM-Size-and-Depth.js** to check the size of the tree
120
122
 
121
123
  ### After LongTask.js
122
124
 
@@ -130,6 +132,13 @@ Use this decision tree to automatically run follow-up snippets based on results:
130
132
  - Excessive DOM manipulation
131
133
  - **If long tasks correlate with interactions** → Run **Interactions.js** to see impact
132
134
 
135
+ ### After DOM-Size-and-Depth.js
136
+
137
+ - **If total elements > 1400** → Recommend virtualizing long lists and loading off-screen content on demand
138
+ - **If depth > 32** → Recommend flattening wrapper elements added by components
139
+ - **If a single parent has > 60 children** → Recommend pagination or virtualization for that container
140
+ - **If the DOM is within limits but interactions are still slow** → Run **Long-Animation-Frames-Script-Attribution.js** to find the scripts responsible
141
+
133
142
  ### After Scroll-Performance.js
134
143
 
135
144
  - **If scroll FPS < 30** → Severe jank, run: