webperf-snippets 1.4.0 → 1.5.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 (48) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
  4. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
  5. package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
  6. package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
  7. package/snippets/Interaction/Input-Latency-Breakdown.js +2 -0
  8. package/snippets/Interaction/Interactions.js +2 -0
  9. package/snippets/Interaction/Scroll-Performance.js +2 -0
  10. package/snippets/Loading/Back-Forward-Cache.js +7 -0
  11. package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
  12. package/snippets/Loading/Cache-Strategy-Analysis.js +12 -0
  13. package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
  14. package/snippets/Loading/Compression-Audit.js +3 -0
  15. package/snippets/Loading/Content-Visibility.js +2 -2
  16. package/snippets/Loading/Critical-CSS-Detection.js +6 -0
  17. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
  18. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  19. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
  20. package/snippets/Loading/Find-render-blocking-resources.js +1 -1
  21. package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
  22. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
  23. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
  24. package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
  25. package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
  26. package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
  27. package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
  28. package/snippets/Loading/Priority-Hints-Audit.js +10 -0
  29. package/snippets/Loading/Resource-Hints-Validation.js +3 -3
  30. package/snippets/Loading/Resource-Hints.js +7 -1
  31. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
  32. package/snippets/Loading/Script-Loading.js +6 -2
  33. package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
  34. package/snippets/Loading/Service-Worker-Analysis.js +12 -2
  35. package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
  36. package/snippets/Loading/TTFB-Resources.js +1 -1
  37. package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
  38. package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
  39. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
  40. package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
  41. package/snippets/Media/Image-Element-Audit.js +16 -13
  42. package/snippets/Media/Oversized-Images.js +4 -0
  43. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
  44. package/snippets/Media/Video-Element-Audit.js +13 -2
  45. package/snippets/SCHEMA.md +425 -3
  46. package/src/baseline.js +30 -6
  47. package/src/blocking.js +1 -1
  48. package/src/mcp/documents.js +41 -21
@@ -53,8 +53,10 @@ Every script must return an object matching this shape:
53
53
 
54
54
  // Issues detected (for audit scripts)
55
55
  issues?: Array<{
56
+ code: string; // What the issue is, kebab-case, stable across runs and versions (see Issue codes)
56
57
  severity: "error" | "warning" | "info";
57
58
  message: string;
59
+ target?: string; // The URL, selector or name the issue is about, when there is one issue per thing
58
60
  }>;
59
61
 
60
62
  // Tracking scripts (status: "tracking")
@@ -505,8 +507,8 @@ When the rating is not good, `issues` holds one entry that names the TTFB and th
505
507
  }
506
508
  ],
507
509
  "issues": [
508
- { "severity": "error", "message": "2 blocking scripts in <head>" },
509
- { "severity": "error", "message": "1 third-party blocking script" }
510
+ { "code": "blocking-in-head", "severity": "error", "message": "2 blocking script(s) in <head>" },
511
+ { "code": "third-party-blocking", "severity": "error", "message": "1 third-party blocking script(s)" }
510
512
  ]
511
513
  }
512
514
  ```
@@ -1189,6 +1191,424 @@ Same shape as Image-Element-Audit but for video elements.
1189
1191
 
1190
1192
  ---
1191
1193
 
1194
+ ## Issue codes
1195
+
1196
+ Every issue has a `code` that says what it is, so a comparison pairs the issues of two runs without reading their text: the message carries figures, and its wording can change from a version to the next. The code is kebab-case and unique within its snippet, so `script` and `code` together name an issue across the collection. A code never changes once published, and is never reused for another situation. The same situation keeps its code when its severity depends on a threshold.
1197
+
1198
+ An issue about one thing among several, one issue per thing, also names it in `target`: the URL of a resource, a CSS selector, or a name such as an event type or a CSS property. Two issues with the same code and different targets are two issues.
1199
+
1200
+ ### LCP-Image-Entropy
1201
+
1202
+ | Code | Severity | When |
1203
+ |------|----------|------|
1204
+ | `low-entropy-images` | warning | Images under 0.05 bits per pixel, which Chrome does not take as the LCP |
1205
+ | `lcp-low-entropy` | error | The LCP image itself has low entropy |
1206
+ | `lcp-size-unknown` | info | The LCP `<img>` has no file size to read (a `data:` URL, or cross-origin without `Timing-Allow-Origin`) |
1207
+ | `lcp-not-img` | info | The LCP is the image of another element, such as a CSS background or a video poster |
1208
+ | `lcp-is-text` | info | The LCP element is text |
1209
+
1210
+ ### LCP-Video-Candidate
1211
+
1212
+ | Code | Severity | When |
1213
+ |------|----------|------|
1214
+ | `lcp-not-video` | info | The LCP element is not a `<video>` |
1215
+ | `lcp-element-removed` | info | The LCP element is no longer in the DOM |
1216
+ | `late-candidate` | warning | The LCP candidate rendered more than 10 s after the load |
1217
+ | `first-frame-lcp` | info | The LCP is the first frame of the video, without a poster |
1218
+ | `first-frame-not-autoplay-muted` | warning | A first-frame LCP on a video without `autoplay` and `muted` |
1219
+ | `poster-not-preloaded` | warning | The poster has no `<link rel="preload" as="image">` |
1220
+ | `poster-preload-no-fetchpriority` | info | The preload of the poster has no `fetchpriority="high"` |
1221
+ | `poster-legacy-format` | info | The poster is not AVIF or WebP |
1222
+ | `render-time-hidden` | info | `renderTime` is 0: the video is cross-origin without `Timing-Allow-Origin` |
1223
+ | `preload-none-delays-poster` | warning | `preload="none"` on a video without `autoplay` |
1224
+
1225
+ ### Script-Loading
1226
+
1227
+ | Code | Severity | When |
1228
+ |------|----------|------|
1229
+ | `blocking-in-head` | error | Classic scripts without `async`, `defer` or `type="module"` in `<head>` |
1230
+ | `third-party-blocking` | error | Blocking scripts from a third party |
1231
+ | `large-blocking` | warning | Blocking scripts larger than 50 KB |
1232
+ | `could-defer` | info | Blocking scripts other than the last three of `<body>`, which could try `defer` |
1233
+ | `unknown-size` | info | Scripts whose size is hidden by a missing `Timing-Allow-Origin`, so the total is a lower bound |
1234
+
1235
+ ### CSS-Media-Queries-Analysis
1236
+
1237
+ | Code | Severity | When |
1238
+ |------|----------|------|
1239
+ | `no-wide-rules` | info | No `@media` rules for viewports wider than the breakpoint (768 px by default) |
1240
+ | `wide-rules` | info or warning | `@media` rules for wider viewports that narrower screens download without using, a warning above 50 KB |
1241
+ | `repeated-queries` | info | Few distinct queries repeated across many `@media` blocks |
1242
+ | `unreadable-stylesheets` | info | Cross-origin stylesheets whose rules cannot be read |
1243
+
1244
+ ### Content-Visibility
1245
+
1246
+ | Code | Severity | When |
1247
+ |------|----------|------|
1248
+ | `missing-intrinsic-size` | warning | An element with `content-visibility: auto` and no `contain-intrinsic-size`; `target` is its selector |
1249
+ | `not-used` | info | No `content-visibility` on the page |
1250
+
1251
+ ### Critical-CSS-Detection
1252
+
1253
+ | Code | Severity | When |
1254
+ |------|----------|------|
1255
+ | `no-inline-critical-css` | error | Render-blocking stylesheets and no inline critical CSS |
1256
+ | `render-blocking-over-budget` | error | Render-blocking CSS above the 14 KB critical budget |
1257
+ | `large-not-preloaded` | warning | Render-blocking stylesheets above 10 KB without preload |
1258
+ | `inline-over-budget` | error | Inline CSS above the 14 KB critical budget |
1259
+ | `styles-in-body` | warning | Inline style blocks in `<body>` |
1260
+
1261
+ ### Find-Above-The-Fold-Lazy-Loaded-Images
1262
+
1263
+ | Code | Severity | When |
1264
+ |------|----------|------|
1265
+ | `lcp-lazy` | error | The LCP candidate image has `loading="lazy"` |
1266
+ | `lazy-above-fold` | warning | Other images above the fold with `loading="lazy"` |
1267
+
1268
+ ### Find-Images-With-Lazy-and-Fetchpriority
1269
+
1270
+ | Code | Severity | When |
1271
+ |------|----------|------|
1272
+ | `lazy-and-high-priority` | error | Elements with both `loading="lazy"` and `fetchpriority="high"` |
1273
+
1274
+ ### Find-non-Lazy-Loaded-Images-outside-of-the-viewport
1275
+
1276
+ | Code | Severity | When |
1277
+ |------|----------|------|
1278
+ | `eager-off-viewport` | warning | Images outside the viewport without `loading="lazy"` |
1279
+
1280
+ ### Find-render-blocking-resources
1281
+
1282
+ | Code | Severity | When |
1283
+ |------|----------|------|
1284
+ | `render-blocking` | error | Resources that block rendering |
1285
+
1286
+ ### Fonts-Preloaded-Loaded-and-used-above-the-fold
1287
+
1288
+ Each issue names its font in `target`.
1289
+
1290
+ | Code | Severity | When |
1291
+ |------|----------|------|
1292
+ | `preloaded-unused` | warning | A preloaded font that the content above the fold does not use |
1293
+ | `used-not-preloaded` | warning | A font used above the fold without preload |
1294
+ | `preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice |
1295
+
1296
+ ### Inline-CSS-Info-and-Size
1297
+
1298
+ | Code | Severity | When |
1299
+ |------|----------|------|
1300
+ | `over-budget` | warning | Inline CSS above the 14 KB critical budget |
1301
+ | `duplicate-blocks` | warning | Style blocks with identical content |
1302
+ | `large-blocks` | warning | Style blocks above 5 KB |
1303
+ | `styles-in-body` | warning | Style blocks in `<body>` |
1304
+
1305
+ ### Webfont-Usage-Analyzer
1306
+
1307
+ `load-failed`, `loaded-unused` and `redeclared` name the font file by its URL in `target`, or the face when it has none.
1308
+
1309
+ | Code | Severity | When |
1310
+ |------|----------|------|
1311
+ | `load-failed` | error | A font that failed to load |
1312
+ | `loaded-unused` | warning | A loaded font that no rendered element uses |
1313
+ | `redeclared` | warning | A loaded font declared again later with the same descriptors, so the page uses the other file |
1314
+ | `blocking-font-display` | warning | Faces with `font-display` `auto` or `block`, which can keep text invisible |
1315
+ | `heavy-fonts` | warning | Web fonts above 300 KB in total |
1316
+ | `never-loaded` | info | Faces declared and never loaded |
1317
+ | `shadowed` | info | Faces declared more than once, whose earlier declarations never loaded |
1318
+ | `unused-subsets` | info | `unicode-range` subsets the page did not need |
1319
+ | `size-unknown` | info | Cross-origin font files without `Timing-Allow-Origin`, so the byte totals are a lower bound |
1320
+ | `unreadable-stylesheets` | info | Cross-origin stylesheets whose font files cannot be matched to faces |
1321
+ | `elements-capped` | info | Only the first 20,000 elements were scanned for text |
1322
+
1323
+ ### First-And-Third-Party-Script-Info
1324
+
1325
+ | Code | Severity | When |
1326
+ |------|----------|------|
1327
+ | `unknown-size` | info | Scripts whose size is hidden by a missing `Timing-Allow-Origin`, so the totals are a lower bound |
1328
+ | `render-blocking-unsupported` | info | The browser has no `renderBlockingStatus` (only Chromium has it) |
1329
+ | `third-party-blocking` | error | Third-party scripts that block rendering |
1330
+ | `many-third-party-hosts` | warning | Scripts from more than 3 third-party hosts |
1331
+ | `large-third-party` | warning | Third-party scripts above 100 KB in total |
1332
+
1333
+ ### First-And-Third-Party-Script-Timings
1334
+
1335
+ | Code | Severity | When |
1336
+ |------|----------|------|
1337
+ | `slow-scripts` | warning | Scripts that take more than 500 ms to load |
1338
+ | `slow-dns` | warning | A DNS lookup above 100 ms |
1339
+ | `slow-connection` | warning | A TCP connection above 100 ms |
1340
+ | `slow-server` | warning | A server response above 200 ms |
1341
+
1342
+ ### Inline-Script-Info-and-Size
1343
+
1344
+ | Code | Severity | When |
1345
+ |------|----------|------|
1346
+ | `parser-blocking-in-head` | error | Inline scripts in `<head>` that block the parser |
1347
+ | `large-inline` | warning | Inline scripts above 2 KB |
1348
+ | `duplicate-scripts` | warning | Inline scripts with identical content |
1349
+ | `analytics-in-head` | warning | Analytics scripts that block in `<head>` |
1350
+
1351
+ ### JS-Execution-Time-Breakdown
1352
+
1353
+ | Code | Severity | When |
1354
+ |------|----------|------|
1355
+ | `render-blocking` | error | Scripts that block HTML parsing |
1356
+ | `high-parse-cost` | warning | An estimated parse cost above 500 ms on mobile |
1357
+ | `split-candidates` | warning | Scripts above 50 KB decoded, candidates for code splitting |
1358
+ | `low-compression` | warning | A compression ratio below 2x on more than 10 KB of scripts |
1359
+
1360
+ ### SSR-Hydration-Data-Analysis
1361
+
1362
+ | Code | Severity | When |
1363
+ |------|----------|------|
1364
+ | `large-hydration-data` | warning | The hydration data of a framework above its threshold; `target` is the framework |
1365
+ | `large-arrays` | warning | `pageProps` arrays with more than 50 entries |
1366
+ | `deep-nesting` | warning | `pageProps` nested more than 5 levels |
1367
+ | `sensitive-keys` | warning | `pageProps` keys that look sensitive |
1368
+
1369
+ ### Third-Party-Impact-by-Domain
1370
+
1371
+ | Code | Severity | When |
1372
+ |------|----------|------|
1373
+ | `render-blocking-domains` | warning | Third-party domains that serve render-blocking resources |
1374
+ | `heavy-domain` | warning | A domain whose scripts take more than 250 ms in long animation frames; `target` is the domain |
1375
+ | `render-blocking-unsupported` | info | The browser has no `renderBlockingStatus` (only Chromium has it) |
1376
+ | `loaf-unattributed` | info | Long animation frames with no third-party script attributed |
1377
+ | `unknown-size` | info | Third-party requests without `Timing-Allow-Origin`, so the transfer size is a lower bound |
1378
+
1379
+ ### Validate-Preload-Async-Defer-Scripts
1380
+
1381
+ Each issue names the preloaded script in `target`.
1382
+
1383
+ | Code | Severity | When |
1384
+ |------|----------|------|
1385
+ | `orphan-preload` | warning | A preloaded script that the page neither has nor loaded |
1386
+ | `async-defer-preload` | error | A script with `async` or `defer` that is also preloaded, which raises its priority |
1387
+ | `module-preload` | warning | An ES module preloaded with `rel="preload"` instead of `rel="modulepreload"` |
1388
+
1389
+ ### Back-Forward-Cache
1390
+
1391
+ | Code | Severity | When |
1392
+ |------|----------|------|
1393
+ | `not-restored` | error | A reason the browser gave for not restoring the page from bfcache, after a back or forward navigation; `target` is the reason and its frame |
1394
+ | `unload-handler` | error | A `window.onunload` handler |
1395
+ | `cache-control-no-store` | warning | `Cache-Control: no-store` on the page response |
1396
+ | `iframes` | info | Iframes on the page, whose blockers block the whole page |
1397
+ | `service-worker` | info | A Service Worker controls the page, which bfcache allows |
1398
+
1399
+ ### Cache-Strategy-Analysis
1400
+
1401
+ Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes.
1402
+
1403
+ | Code | Severity | When |
1404
+ |------|----------|------|
1405
+ | `static-no-store` | error | A static asset with `no-store` |
1406
+ | `versioned-short-cache` | warning | A URL with a hash and a short `max-age` |
1407
+ | `versioned-not-immutable` | info | A URL with a hash and no `immutable` |
1408
+ | `large-no-cache` | error | A large resource with no effective cache |
1409
+ | `expires-without-cc` | warning | An `Expires` header without `Cache-Control` |
1410
+ | `maxage-zero-static` | warning | A static asset with `max-age=0` |
1411
+ | `short-cache-static` | warning | A static asset with a `max-age` under an hour |
1412
+ | `uncompressed` | warning | A text resource sent uncompressed |
1413
+ | `duplicate-uncached` | warning | A resource loaded several times from the network |
1414
+ | `header-coverage` | info | The share of resources whose cache headers could be read |
1415
+
1416
+ ### Client-Side-Redirect-Detection
1417
+
1418
+ | Code | Severity | When |
1419
+ |------|----------|------|
1420
+ | `server-redirects` | warning | Server-side redirects before the document |
1421
+ | `document-navigation` | error | Same-origin document navigations, likely a client-side redirect |
1422
+ | `same-origin-navigation` | warning | A same-origin referrer with another path |
1423
+ | `fast-minimal-navigation` | warning | A small same-origin page that loads fast, possibly a redirect page |
1424
+
1425
+ ### Compression-Audit
1426
+
1427
+ | Code | Severity | When |
1428
+ |------|----------|------|
1429
+ | `uncompressed` | warning | Text resources served without compression |
1430
+ | `unknown-size` | info | Cross-origin text resources without `Timing-Allow-Origin`, whose compression is unknown |
1431
+ | `content-encoding-unavailable` | info | No `contentEncoding` in Resource Timing, so compression is inferred from the sizes |
1432
+
1433
+ ### Prefetch-Resource-Validation
1434
+
1435
+ Each issue about one prefetch names its URL in `target`.
1436
+
1437
+ | Code | Severity | When |
1438
+ |------|----------|------|
1439
+ | `no-prefetch` | info | No `rel="prefetch"` hints on the page |
1440
+ | `missing-as` | warning | A prefetch without `as` |
1441
+ | `invalid-as` | warning | A prefetch with an `as` value that does not exist |
1442
+ | `duplicate-prefetch` | warning | A URL prefetched more than once |
1443
+ | `used-on-current-page` | error | A prefetched resource that the current page uses, which needs `preload` |
1444
+ | `large-file` | warning | A prefetched file above 500 KB |
1445
+ | `inappropriate-type` | warning | A prefetched type that may not suit prefetch |
1446
+ | `too-many-prefetches` | warning | More than 10 prefetch hints |
1447
+ | `large-total-size` | warning or error | Prefetches above 2 MB in total, an error above 5 MB |
1448
+
1449
+ ### Priority-Hints-Audit
1450
+
1451
+ Each issue names its resource in `target`.
1452
+
1453
+ | Code | Severity | When |
1454
+ |------|----------|------|
1455
+ | `preload-low-priority` | error | A preload with `fetchpriority="low"` |
1456
+ | `low-priority-loaded-early` | warning | A `fetchpriority="low"` resource that still loaded early |
1457
+ | `high-priority-loaded-late` | warning | A `fetchpriority="high"` resource that still loaded late |
1458
+ | `lcp-no-fetchpriority` | warning | The LCP candidate image without `fetchpriority="high"` |
1459
+
1460
+ ### Resource-Hints
1461
+
1462
+ Each issue about one hint names its URL in `target`.
1463
+
1464
+ | Code | Severity | When |
1465
+ |------|----------|------|
1466
+ | `preload-missing-as` | error | A preload without `as` |
1467
+ | `font-preload-no-crossorigin` | error | A font preload without `crossorigin`, which fetches the font twice |
1468
+ | `preconnect-no-crossorigin` | warning | A preconnect without `crossorigin` to an origin that serves CORS resources |
1469
+ | `unused-preload` | warning | A preload that the page did not use during the load |
1470
+ | `unused-preconnect` | warning | A preconnect to an origin the page loaded nothing from |
1471
+ | `too-many-preloads` | warning | More than 6 preloads |
1472
+
1473
+ ### Resource-Hints-Validation
1474
+
1475
+ | Code | Severity | When |
1476
+ |------|----------|------|
1477
+ | `unused-hints` | warning | Hints the page did not use |
1478
+ | `missing-connection-hints` | info | Domains that could use a connection hint |
1479
+ | `redundant-hints` | warning | Domains with both `preconnect` and `dns-prefetch` |
1480
+
1481
+ ### Server-Timing-Early-Hints
1482
+
1483
+ | Code | Severity | When |
1484
+ |------|----------|------|
1485
+ | `no-server-timing` | info | The document response has no `Server-Timing` header |
1486
+ | `timing-hidden` | info | Cross-origin resources without `Timing-Allow-Origin`, whose Server-Timing cannot be read |
1487
+ | `no-early-hints` | info | No 103 Early Hints response for the document |
1488
+
1489
+ ### Service-Worker-Analysis
1490
+
1491
+ | Code | Severity | When |
1492
+ |------|----------|------|
1493
+ | `unsupported` | error | The browser has no Service Workers |
1494
+ | `no-service-worker` | info | No Service Worker registered |
1495
+ | `not-controlled` | warning | The page is not controlled by its Service Worker, after a hard reload or on a first visit |
1496
+ | `update-pending` | warning | A waiting update; `target` is the scope |
1497
+ | `navigation-preload-disabled` | info | Navigation Preload is off; `target` is the scope |
1498
+ | `slow-startup` | warning or error | A Service Worker startup above 50 ms, an error above 100 ms |
1499
+ | `low-cache-hit-rate` | warning or error | A cache hit rate under 80%, an error under 50% |
1500
+ | `unknown-source` | info | Intercepted cross-origin resources without `Timing-Allow-Origin`, left out of the hit rate |
1501
+
1502
+ ### Speculation-Rules-Inspector
1503
+
1504
+ | Code | Severity | When |
1505
+ |------|----------|------|
1506
+ | `invalid-json` | error | A speculation rules script that is not valid JSON |
1507
+ | `eager-list-capped` | warning | An eager prerender list with more than 10 URLs, above what Chrome prerenders |
1508
+ | `eager-document-rule` | warning | A document prerender rule with eager eagerness |
1509
+ | `no-rules` | info | No speculation rules in the DOM |
1510
+ | `no-prefetch-or-prerender` | info | Speculation rules scripts without prefetch or prerender rules |
1511
+ | `unsupported` | info | The browser does not support speculation rules |
1512
+ | `prerendered` | info | The page was prerendered, so its timings start at `activationStart` |
1513
+
1514
+ ### TTFB-Resources
1515
+
1516
+ | Code | Severity | When |
1517
+ |------|----------|------|
1518
+ | `no-resources` | info | No resources with TTFB data |
1519
+
1520
+ ### TTFB-Sub-Parts
1521
+
1522
+ | Code | Severity | When |
1523
+ |------|----------|------|
1524
+ | `slow-ttfb` | warning or error | A TTFB that is not good, an error when poor, naming its slowest phase |
1525
+
1526
+ ### DOM-Size-and-Depth
1527
+
1528
+ | Code | Severity | When |
1529
+ |------|----------|------|
1530
+ | `too-many-nodes` | warning or error | More than 800 nodes, an error above 1,400 |
1531
+ | `too-deep` | warning | A DOM deeper than 32 levels |
1532
+ | `too-many-children` | warning | An element with more than 60 children |
1533
+
1534
+ ### Forced-Synchronous-Layout
1535
+
1536
+ | Code | Severity | When |
1537
+ |------|----------|------|
1538
+ | `forced-layout` | warning or error | A layout property read after a write forced a layout, an error above 5 times; `target` is the property |
1539
+
1540
+ ### Input-Latency-Breakdown
1541
+
1542
+ | Code | Severity | When |
1543
+ |------|----------|------|
1544
+ | `slow-event-type` | warning or error | An event type whose P75 is not good, an error when poor; `target` is the event type |
1545
+
1546
+ ### Interactions
1547
+
1548
+ | Code | Severity | When |
1549
+ |------|----------|------|
1550
+ | `slow-interaction` | warning or error | An interaction above 200 ms, an error above 500 ms; `target` is its type |
1551
+
1552
+ ### Scroll-Performance
1553
+
1554
+ | Code | Severity | When |
1555
+ |------|----------|------|
1556
+ | `non-passive-listeners` | warning | Scroll or touch listeners without `{ passive: true }` |
1557
+ | `will-change` | info | Elements with `will-change` set |
1558
+
1559
+ ### Image-Element-Audit
1560
+
1561
+ Each issue names its image in `target`, except `lcp-not-img`.
1562
+
1563
+ | Code | Severity | When |
1564
+ |------|----------|------|
1565
+ | `lcp-not-img` | info | The LCP element is not an `<img>` |
1566
+ | `lcp-no-fetchpriority` | error | The LCP image has no `fetchpriority="high"` |
1567
+ | `lcp-lazy` | error | The LCP image has `loading="lazy"` |
1568
+ | `lcp-not-decoding-sync` | warning | The LCP image has no `decoding="sync"` |
1569
+ | `lcp-not-preloaded` | warning | The LCP image has no `<link rel="preload" as="image">` |
1570
+ | `lcp-preload-no-fetchpriority` | info | The preload of the LCP image has no `fetchpriority="high"` |
1571
+ | `lazy-in-viewport` | warning | An image in the viewport with `loading="lazy"` |
1572
+ | `eager-off-viewport` | warning | An image outside the viewport without `loading="lazy"` |
1573
+ | `high-priority-off-viewport` | error | An image outside the viewport with `fetchpriority="high"` |
1574
+ | `lazy-and-high-priority` | error | An image with both `loading="lazy"` and `fetchpriority="high"` |
1575
+ | `missing-dimensions` | warning | An image without `width` and `height` |
1576
+ | `no-modern-format` | info | An image that is not WebP, AVIF, JPEG XL or SVG, outside a `<picture>` |
1577
+
1578
+ ### Oversized-Images
1579
+
1580
+ Each issue names its image in `target`.
1581
+
1582
+ | Code | Severity | When |
1583
+ |------|----------|------|
1584
+ | `oversized` | info or warning | A file larger than its slot needs, a warning when it is far larger |
1585
+ | `undersized` | info or warning | A file smaller than its slot needs, so it looks blurry, a warning when it is far smaller |
1586
+
1587
+ ### SVG-Embedded-Bitmap-Analysis
1588
+
1589
+ | Code | Severity | When |
1590
+ |------|----------|------|
1591
+ | `embedded-bitmaps` | warning | SVG files that contain bitmaps |
1592
+ | `sprite-opportunity` | info | Standalone inline SVGs that could share one `<symbol>` sprite |
1593
+
1594
+ ### Video-Element-Audit
1595
+
1596
+ Each issue names its video in `target`, except `no-videos`.
1597
+
1598
+ | Code | Severity | When |
1599
+ |------|----------|------|
1600
+ | `no-videos` | info | No `<video>` elements on the page |
1601
+ | `preload-auto` | error | `preload="auto"` on a video without `autoplay` |
1602
+ | `off-viewport-preload` | warning | A video outside the viewport without `preload="none"` |
1603
+ | `autoplay-not-muted` | error | `autoplay` without `muted` |
1604
+ | `autoplay-no-playsinline` | warning | `autoplay` without `playsinline` |
1605
+ | `missing-poster` | warning | A video without a poster |
1606
+ | `missing-dimensions` | warning | A video without `width` and `height` |
1607
+ | `no-modern-codec` | info | No WebM source (VP9 or AV1) |
1608
+ | `no-controls` | info | A video without `controls` and without `autoplay` |
1609
+
1610
+ ---
1611
+
1192
1612
  ## Guidelines for Agents
1193
1613
 
1194
1614
  ### Reading results
@@ -1223,6 +1643,7 @@ data = evaluate_script("getINP()")
1223
1643
  - `rating === "poor"` → high priority fix, check `issues` for specific problems
1224
1644
  - `count > 0` and `issues.length > 0` → audit found actionable problems
1225
1645
  - `count === 0` → nothing to audit (no render-blocking resources, no images, etc.)
1646
+ - To tell whether an issue is new, fixed or the same as in an earlier run, compare `script`, `code` and `target`, not `message`
1226
1647
 
1227
1648
  ---
1228
1649
 
@@ -1240,5 +1661,6 @@ data = evaluate_script("getINP()")
1240
1661
  10. **Valid statuses only** — `ok`, `tracking`, `error` or `unsupported`, with an `error` message when the status is `error` or `unsupported`.
1241
1662
  11. **Hidden cross-origin data is reported, not guessed** — a resource whose timing or size is hidden (no `Timing-Allow-Origin`) reports zeros for every size and timing. Count it (`sizeKnown: false`, `corsRestrictedCount`) instead of treating the zero as a measurement.
1242
1663
  12. **Partial analysis is flagged** — when hidden cross-origin data leaves part of the set unanalyzed, set the top-level `corsLimitedAnalysis: true` and report how many resources were skipped in `details` (`sizeUnknownCount`, `corsRestrictedCount`). Set it to `false` when every resource was analyzed.
1664
+ 13. **Issues carry a stable code**: every issue has a kebab-case `code`, written as a literal in the source (`code: "blocking-in-head"`) and listed under Issue codes, and a `target` string when it is about one resource.
1243
1665
 
1244
- The rules are checked by `cli/tests/e2e/snippet-contract.test.js`, which runs every script, its `getDataFn` and every other getter it exposes on `window` (such as `getINPDetails()` and `checkBfcache()`), on an empty page, a seeded page and a heavy page.
1666
+ The rules are checked by `cli/tests/e2e/snippet-contract.test.js`, which runs every script, its `getDataFn` and every other getter it exposes on `window` (such as `getINPDetails()` and `checkBfcache()`), on an empty page, a seeded page and a heavy page. `cli/tests/unit/issue-codes.test.js` checks that the codes in the source of each snippet match its table under Issue codes.
package/src/baseline.js CHANGED
@@ -116,14 +116,38 @@ function compareMetrics(base, current, thresholds) {
116
116
  return metrics;
117
117
  }
118
118
 
119
- // Two errors are the same when they share a code or, without one, when their messages differ only in
120
- // their figures: "Render-blocking CSS (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…"
121
- const keyOf = (error) => `${error.id}\u0000${error.code ?? error.message.replace(/\d+(?:[.,]\d+)*/g, "#")}`;
119
+ // A resource of the page itself by its path and query, so the review app of a PR, on another host,
120
+ // matches main; any other target as it is
121
+ function targetOf(target, pageUrl) {
122
+ if (!target) return "";
123
+ try {
124
+ const url = new URL(target);
125
+ return url.origin === new URL(pageUrl).origin ? url.pathname + url.search : url.href;
126
+ } catch {
127
+ return target;
128
+ }
129
+ }
130
+
131
+ // Two errors are the same when they share a code and a target or, when a snippet has no codes on one
132
+ // side (a baseline of 1.4.0), when their messages differ only in their figures: "Render-blocking CSS
133
+ // (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…"
134
+ const byMessage = (error) => `${error.id}\u0000${error.message.replace(/\d+(?:[.,]\d+)*/g, "#")}`;
122
135
 
123
136
  function compareErrors(base, current) {
124
- const byKey = (payload) => new Map(blockingErrors(payload.results ?? []).map((error) => [keyOf(error), error]));
125
- const b = byKey(base);
126
- const c = byKey(current);
137
+ const baseErrors = blockingErrors(base.results ?? []);
138
+ const currentErrors = blockingErrors(current.results ?? []);
139
+ // The snippets whose errors on one side all lack a code pair by message on both
140
+ const uncoded = (errors) => {
141
+ const ids = new Set(errors.map((e) => e.id));
142
+ for (const e of errors) if (e.code) ids.delete(e.id);
143
+ return ids;
144
+ };
145
+ const byMessageOnly = new Set([...uncoded(baseErrors), ...uncoded(currentErrors)]);
146
+ const keyOf = (error, pageUrl) =>
147
+ error.code && !byMessageOnly.has(error.id) ? `${error.id}\u0000${error.code}\u0000${targetOf(error.target, pageUrl)}` : byMessage(error);
148
+ const byKey = (errors, pageUrl) => new Map(errors.map((error) => [keyOf(error, pageUrl), error]));
149
+ const b = byKey(baseErrors, base.url);
150
+ const c = byKey(currentErrors, current.url);
127
151
  return {
128
152
  new: [...c].filter(([key]) => !b.has(key)).map(([, error]) => error),
129
153
  fixed: [...b].filter(([key]) => !c.has(key)).map(([, error]) => error),
package/src/blocking.js CHANGED
@@ -7,7 +7,7 @@ export function blockingErrors(results) {
7
7
  if (r.status === "error") errors.push({ id: r.id, message: r.error });
8
8
  if (r.reason || !Array.isArray(r.issues)) continue;
9
9
  for (const issue of r.issues) {
10
- if (issue.severity === "error") errors.push({ id: r.id, message: issue.message, ...(issue.code ? { code: issue.code } : {}) });
10
+ if (issue.severity === "error") errors.push({ id: r.id, message: issue.message, ...(issue.code ? { code: issue.code } : {}), ...(issue.target ? { target: issue.target } : {}) });
11
11
  }
12
12
  }
13
13
  return errors;
@@ -19,30 +19,50 @@ function readDocument(relativePath) {
19
19
  }
20
20
 
21
21
  // Splits SCHEMA.md into the general contract and the section of each snippet ("#### LCP" up to the next
22
- // heading of that level or above). The general contract is everything outside "## Script-Specific Schemas".
22
+ // heading of that level or above). The general contract is everything outside "## Script-Specific Schemas"
23
+ // and outside the tables of "## Issue codes", whose rules it keeps. The table of a snippet ("### LCP"
24
+ // under Issue codes) goes with its section, or makes one when the snippet has none.
23
25
  export function splitSchema(markdown) {
24
26
  const lines = markdown.split("\n");
25
- const start = lines.findIndex((l) => l.startsWith("## Script-Specific Schemas"));
26
- const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
27
- const general = [...lines.slice(0, start), ...lines.slice(end)].join("\n");
27
+ const sectionOf = (heading) => {
28
+ const start = lines.findIndex((l) => l.startsWith(heading));
29
+ if (start === -1) return [lines.length, lines.length];
30
+ const next = lines.findIndex((l, i) => i > start && l.startsWith("## "));
31
+ return [start, next === -1 ? lines.length : next];
32
+ };
33
+ const [schemasStart, schemasEnd] = sectionOf("## Script-Specific Schemas");
34
+ const [codesStart, codesEnd] = sectionOf("## Issue codes");
35
+ const firstTable = lines.findIndex((l, i) => i > codesStart && i < codesEnd && l.startsWith("### "));
36
+ const tablesStart = firstTable === -1 ? codesEnd : firstTable;
37
+ const general = lines
38
+ .filter((_, i) => !(i >= schemasStart && i < schemasEnd) && !(i >= tablesStart && i < codesEnd))
39
+ .join("\n");
28
40
 
29
41
  const sections = new Map();
30
- let name = null;
31
- let current = [];
32
- const flush = () => name && sections.set(name, current.join("\n").trim());
33
- for (const line of lines.slice(start, end)) {
34
- if (line.startsWith("#### ")) {
35
- flush();
36
- name = line.slice(5).split(" ")[0];
37
- current = [line];
38
- } else if (/^#{1,3} /.test(line)) {
39
- flush();
40
- name = null;
41
- } else if (name) {
42
- current.push(line);
42
+ const collect = (from, to, level, add) => {
43
+ let name = null;
44
+ let current = [];
45
+ const flush = () => name && add(name, current.join("\n").trim());
46
+ for (const line of lines.slice(from, to)) {
47
+ if (line.startsWith(`${level} `)) {
48
+ flush();
49
+ name = line.slice(level.length + 1).split(" ")[0];
50
+ current = [line];
51
+ } else if (/^#{1,4} /.test(line)) {
52
+ flush();
53
+ name = null;
54
+ } else if (name) {
55
+ current.push(line);
56
+ }
43
57
  }
44
- }
45
- flush();
58
+ flush();
59
+ };
60
+ collect(schemasStart, schemasEnd, "####", (name, text) => sections.set(name, text));
61
+ collect(tablesStart, codesEnd, "###", (name, text) => {
62
+ // The last table runs into the rule that closes the section
63
+ const table = `Issue codes:\n${text.split("\n").slice(1).join("\n").replace(/\n+---$/, "")}`;
64
+ sections.set(name, sections.has(name) ? `${sections.get(name)}\n\n${table}` : `#### ${name}\n\n${table}`);
65
+ });
46
66
  return { general, sections };
47
67
  }
48
68
 
@@ -65,7 +85,7 @@ export function registerResources(server) {
65
85
  "webperf://schema",
66
86
  {
67
87
  title: "Snippet result contract",
68
- description: "What every snippet returns: the base shape, the statuses, the execution patterns and how to read a result. The fields of each snippet are in webperf://schema/{snippet}.",
88
+ description: "What every snippet returns: the base shape, the statuses, the execution patterns, how to read a result and the rules of the issue codes. The fields and the issue codes of each snippet are in webperf://schema/{snippet}.",
69
89
  mimeType: MARKDOWN,
70
90
  },
71
91
  async (uri) => markdown(uri, schemaSection())
@@ -80,7 +100,7 @@ export function registerResources(server) {
80
100
  }),
81
101
  {
82
102
  title: "Result of one snippet",
83
- description: "The fields one snippet returns, with an example, from snippets/SCHEMA.md.",
103
+ description: "The fields one snippet returns, with an example, and the codes of its issues, from snippets/SCHEMA.md.",
84
104
  mimeType: MARKDOWN,
85
105
  },
86
106
  async (uri, { snippet }) => {