@salesforce/storefront-next-dev 1.4.0 → 1.4.1-alpha.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 (137) hide show
  1. package/README.md +13 -2
  2. package/dist/assets.js +25 -0
  3. package/dist/audit/catalog/categories/best-practices/CATEGORY.md +21 -0
  4. package/dist/audit/catalog/categories/best-practices/rules/commerce-api-client.md +20 -0
  5. package/dist/audit/catalog/categories/best-practices/rules/error-boundaries.md +19 -0
  6. package/dist/audit/catalog/categories/best-practices/rules/production-integrations.md +20 -0
  7. package/dist/audit/catalog/categories/best-practices/rules/route-revalidation.md +19 -0
  8. package/dist/audit/catalog/categories/best-practices/rules/server-data-loading.md +19 -0
  9. package/dist/audit/catalog/categories/performance/CATEGORY.md +18 -0
  10. package/dist/audit/catalog/categories/performance/rules/app-config-size.js +15 -0
  11. package/dist/audit/catalog/categories/performance/rules/app-config-size.md +22 -0
  12. package/dist/audit/catalog/categories/performance/rules/cache-control.js +59 -0
  13. package/dist/audit/catalog/categories/performance/rules/cache-control.md +20 -0
  14. package/dist/audit/catalog/categories/performance/rules/cls-budget.js +13 -0
  15. package/dist/audit/catalog/categories/performance/rules/cls-budget.md +20 -0
  16. package/dist/audit/catalog/categories/performance/rules/content-encoding.js +35 -0
  17. package/dist/audit/catalog/categories/performance/rules/content-encoding.md +20 -0
  18. package/dist/audit/catalog/categories/performance/rules/dom-size.js +18 -0
  19. package/dist/audit/catalog/categories/performance/rules/dom-size.md +22 -0
  20. package/dist/audit/catalog/categories/performance/rules/html-document-size.js +15 -0
  21. package/dist/audit/catalog/categories/performance/rules/html-document-size.md +20 -0
  22. package/dist/audit/catalog/categories/performance/rules/image-dimensions.js +141 -0
  23. package/dist/audit/catalog/categories/performance/rules/image-dimensions.md +22 -0
  24. package/dist/audit/catalog/categories/performance/rules/image-transformation.js +117 -0
  25. package/dist/audit/catalog/categories/performance/rules/image-transformation.md +22 -0
  26. package/dist/audit/catalog/categories/performance/rules/javascript-size.js +126 -0
  27. package/dist/audit/catalog/categories/performance/rules/javascript-size.md +20 -0
  28. package/dist/audit/catalog/categories/performance/rules/large-images.js +187 -0
  29. package/dist/audit/catalog/categories/performance/rules/large-images.md +22 -0
  30. package/dist/audit/catalog/categories/performance/rules/lcp-budget.js +13 -0
  31. package/dist/audit/catalog/categories/performance/rules/lcp-budget.md +20 -0
  32. package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.js +227 -0
  33. package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.md +27 -0
  34. package/dist/audit/catalog/categories/performance/rules/page-data-size.js +15 -0
  35. package/dist/audit/catalog/categories/performance/rules/page-data-size.md +22 -0
  36. package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.js +97 -0
  37. package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.md +73 -0
  38. package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.js +201 -0
  39. package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.md +20 -0
  40. package/dist/audit/catalog/categories/performance/rules/server-request-waterfall.md +85 -0
  41. package/dist/audit/catalog/categories/performance/rules/tbt-budget.js +13 -0
  42. package/dist/audit/catalog/categories/performance/rules/tbt-budget.md +20 -0
  43. package/dist/audit/catalog/categories/performance/rules/third-party-fonts.js +265 -0
  44. package/dist/audit/catalog/categories/performance/rules/third-party-fonts.md +20 -0
  45. package/dist/audit/catalog/categories/performance/rules/ttfb-budget.js +17 -0
  46. package/dist/audit/catalog/categories/performance/rules/ttfb-budget.md +20 -0
  47. package/dist/audit/catalog/categories/security/CATEGORY.md +17 -0
  48. package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.js +37 -0
  49. package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.md +20 -0
  50. package/dist/audit/catalog/categories/security/rules/content-security-policy.js +40 -0
  51. package/dist/audit/catalog/categories/security/rules/content-security-policy.md +20 -0
  52. package/dist/audit/catalog/categories/security/rules/csp-runtime-compatibility.md +24 -0
  53. package/dist/audit/catalog/categories/security/rules/dependency-vulnerabilities.md +19 -0
  54. package/dist/audit/catalog/categories/security/rules/dynamic-code-execution.md +19 -0
  55. package/dist/audit/catalog/categories/security/rules/hardcoded-credentials.md +19 -0
  56. package/dist/audit/catalog/categories/security/rules/html-config-secrets.js +46 -0
  57. package/dist/audit/catalog/categories/security/rules/html-config-secrets.md +22 -0
  58. package/dist/audit/catalog/categories/security/rules/protected-route-authorization.md +19 -0
  59. package/dist/audit/catalog/categories/security/rules/public-client-secrets.md +20 -0
  60. package/dist/audit/catalog/categories/security/rules/raw-html-sinks.md +20 -0
  61. package/dist/audit/catalog/categories/security/rules/strict-transport-security.js +183 -0
  62. package/dist/audit/catalog/categories/security/rules/strict-transport-security.md +24 -0
  63. package/dist/audit/catalog/categories/seo/CATEGORY.md +17 -0
  64. package/dist/audit/catalog/categories/seo/rules/canonical-url.md +20 -0
  65. package/dist/audit/catalog/categories/seo/rules/locale-alternates.md +19 -0
  66. package/dist/audit/catalog/categories/seo/rules/meta-description.md +19 -0
  67. package/dist/audit/catalog/categories/seo/rules/page-title.md +19 -0
  68. package/dist/audit/catalog/categories/seo/rules/robots-indexing.md +20 -0
  69. package/dist/audit/catalog/categories/seo/rules/structured-data.md +19 -0
  70. package/dist/audit/instructions/evaluate-rule.md +42 -0
  71. package/dist/audit/schemas/audit-result.schema.json +1237 -0
  72. package/dist/audit/ui/assets/banner-brand-default-CDRB3_S0.png +0 -0
  73. package/dist/audit/ui/assets/banner-group-public-default-BGRJzVY8.png +0 -0
  74. package/dist/audit/ui/assets/banner-user-default-BW81Ba3z.png +0 -0
  75. package/dist/audit/ui/assets/bg-info@2x-CzpP0vaE.png +0 -0
  76. package/dist/audit/ui/assets/einstein-figure-B1xFOo8O.svg +1 -0
  77. package/dist/audit/ui/assets/group_avatar_160-CrxVHlYA.png +0 -0
  78. package/dist/audit/ui/assets/group_avatar_200-CSLS8I0r.png +0 -0
  79. package/dist/audit/ui/assets/index-BKCWlO9b.css +1 -0
  80. package/dist/audit/ui/assets/index-CUPt8e4A.js +44 -0
  81. package/dist/audit/ui/assets/profile_avatar_160-CtQTUBo0.png +0 -0
  82. package/dist/audit/ui/assets/profile_avatar_200-DAztxshF.png +0 -0
  83. package/dist/audit/ui/assets/symbols-BRmpfDbO.svg +1 -0
  84. package/dist/audit/ui/assets/symbols-D0Ne6690.svg +1 -0
  85. package/dist/audit/ui/einstein.png +0 -0
  86. package/dist/audit/ui/index.html +13 -0
  87. package/dist/audit.js +1896 -0
  88. package/dist/cartridge-services/index.d.ts +5 -0
  89. package/dist/cartridge-services/index.d.ts.map +1 -1
  90. package/dist/cartridge-services/index.js +94 -7
  91. package/dist/cartridge-services/index.js.map +1 -1
  92. package/dist/catalog.js +89 -0
  93. package/dist/commands/audit/catalog.js +27 -0
  94. package/dist/commands/audit/finalize.js +42 -0
  95. package/dist/commands/audit/prepare.js +84 -0
  96. package/dist/commands/audit/progress.js +51 -0
  97. package/dist/commands/audit/serve.js +63 -0
  98. package/dist/commands/config/aggregate-extensions.js +1 -0
  99. package/dist/commands/create-bundle.js +2 -1
  100. package/dist/commands/create-instructions.js +1 -0
  101. package/dist/commands/create-storefront.js +1 -0
  102. package/dist/commands/deploy-cartridge.js +11 -3
  103. package/dist/commands/dev.js +2 -0
  104. package/dist/commands/extensions/create.js +1 -0
  105. package/dist/commands/extensions/install.js +1 -0
  106. package/dist/commands/extensions/list.js +1 -0
  107. package/dist/commands/extensions/remove.js +1 -0
  108. package/dist/commands/generate-cartridge.js +49 -5
  109. package/dist/commands/locales/aggregate-extensions.js +1 -0
  110. package/dist/commands/prepare-local.js +1 -0
  111. package/dist/commands/preview.js +2 -0
  112. package/dist/commands/push.js +3 -1
  113. package/dist/commands/validate-cartridge.js +13 -4
  114. package/dist/config.js +13 -2
  115. package/dist/document-size.js +231 -0
  116. package/dist/events.js +688 -0
  117. package/dist/flags2.js +34 -0
  118. package/dist/generate-cartridge.js +41 -6
  119. package/dist/har.js +205 -0
  120. package/dist/images.js +146 -0
  121. package/dist/index.js +3 -3
  122. package/dist/index.js.map +1 -1
  123. package/dist/live.js +277 -0
  124. package/dist/logger.js +1 -4
  125. package/dist/measured-run.js +91 -0
  126. package/dist/metric-budget.js +125 -0
  127. package/dist/mrt/ssr.mjs +19 -19
  128. package/dist/mrt/ssr.mjs.map +1 -1
  129. package/dist/mrt/streamingHandler.mjs +26 -26
  130. package/dist/mrt/streamingHandler.mjs.map +1 -1
  131. package/dist/package.js +5 -0
  132. package/dist/sanitize.js +185 -0
  133. package/dist/schema.js +121 -0
  134. package/dist/server.js +8 -60
  135. package/dist/server2.js +185 -0
  136. package/dist/ts-import.js +57 -0
  137. package/package.json +22 -5
@@ -0,0 +1,227 @@
1
+ import { a as kib, i as isImage, n as bytesDuring, o as readImageRun, r as fileName, s as withoutQuery, t as MIN_SAVINGS_BYTES } from "../../../../../images.js";
2
+ import { z } from "zod";
3
+
4
+ //#region src/audit/catalog/categories/performance/rules/lcp-image-priority.ts
5
+ /** Delays shorter than this aren't worth a suggestion. */
6
+ const MIN_DELAY_MS = 100;
7
+ const LCP_GOOD_MS = 2500;
8
+ const HIGH = new Set(["High", "VeryHigh"]);
9
+ const issueSchema = z.object({
10
+ check: z.enum([
11
+ "not-in-html",
12
+ "lazy",
13
+ "low-priority",
14
+ "preload-mismatch",
15
+ "competing-images",
16
+ "bandwidth",
17
+ "render-blocking"
18
+ ]),
19
+ detail: z.string(),
20
+ artifact: z.string(),
21
+ pointer: z.string()
22
+ }).strict();
23
+ const dataSchema = z.object({
24
+ cell: z.string(),
25
+ pages: z.array(z.object({
26
+ page: z.string(),
27
+ lcpMs: z.number().nullable(),
28
+ image: z.object({
29
+ url: z.string(),
30
+ entry: z.number(),
31
+ bytes: z.number(),
32
+ startMs: z.number(),
33
+ loadMs: z.number(),
34
+ initialPriority: z.string().nullable(),
35
+ priority: z.string().nullable(),
36
+ initiator: z.string().nullable(),
37
+ preloaded: z.boolean(),
38
+ concurrentImageBytes: z.number(),
39
+ concurrentScriptBytes: z.number(),
40
+ imageDelayMs: z.number()
41
+ }).strict().nullable(),
42
+ issues: z.array(issueSchema)
43
+ }).strict()),
44
+ skipped: z.array(z.object({
45
+ page: z.string(),
46
+ reason: z.string()
47
+ }).strict())
48
+ }).strict();
49
+ /** The `{value}` of one of Lighthouse's LCP discovery checks, or undefined when it wasn't reported. */
50
+ function discovery(run$1, check) {
51
+ for (const item of run$1.lhr.audits["lcp-discovery-insight"]?.details?.items ?? []) {
52
+ const value = item.items?.[check]?.value;
53
+ if (typeof value === "boolean") return value;
54
+ }
55
+ }
56
+ function checkPage(run$1) {
57
+ if (!run$1.lcpUrl) return {
58
+ page: run$1.page,
59
+ lcpMs: run$1.lcpMs,
60
+ image: null,
61
+ issues: []
62
+ };
63
+ const index = run$1.entries.findIndex((entry) => entry.request.url === run$1.lcpUrl);
64
+ const lcp = run$1.entries[index];
65
+ if (!lcp || lcp._requestTime === void 0) return { reason: "The LCP image request was not found in the median run HAR." };
66
+ const har = (entry, path$1 = "") => ({
67
+ artifact: run$1.harArtifact,
68
+ pointer: `/log/entries/${entry}${path$1}`
69
+ });
70
+ const lighthouse = (id) => ({
71
+ artifact: run$1.lighthouseArtifact,
72
+ pointer: `/audits/${id}`
73
+ });
74
+ const issues = [];
75
+ const name = fileName(lcp.request.url);
76
+ if (discovery(run$1, "requestDiscoverable") === false || lcp._initiator_type === "script") issues.push({
77
+ check: "not-in-html",
78
+ detail: `The LCP image ${name} is requested by JavaScript instead of being in the HTML, so the browser finds it late.`,
79
+ ...lighthouse("lcp-discovery-insight")
80
+ });
81
+ if (discovery(run$1, "eagerlyLoaded") === false) issues.push({
82
+ check: "lazy",
83
+ detail: `The LCP image ${name} uses loading="lazy", which waits for layout before downloading it.`,
84
+ ...lighthouse("lcp-discovery-insight")
85
+ });
86
+ const initial = lcp._initialPriority ?? null;
87
+ if (initial && !HIGH.has(initial)) issues.push({
88
+ check: "low-priority",
89
+ detail: `The LCP image ${name} starts at ${initial} priority${lcp._priority && lcp._priority !== initial ? ` and is raised to ${lcp._priority} only after layout` : ""}; it has no fetchpriority="high".`,
90
+ ...har(index)
91
+ });
92
+ const path = withoutQuery(lcp.request.url);
93
+ const mismatch = run$1.entries.findIndex((entry) => entry.request._isLinkPreload && entry.request.url !== lcp.request.url && withoutQuery(entry.request.url) === path);
94
+ if (mismatch >= 0) issues.push({
95
+ check: "preload-mismatch",
96
+ detail: `The page preloads ${name} at a different size from the one shown, so it downloads twice (${run$1.entries[mismatch].request.url}).`,
97
+ ...har(mismatch)
98
+ });
99
+ const competing = run$1.entries.flatMap((entry, at) => entry !== lcp && isImage(entry) && HIGH.has(entry._priority ?? "") && (entry._requestTime ?? Infinity) <= (lcp._requestTime ?? -Infinity) && (entry.response.bodySize ?? 0) >= MIN_SAVINGS_BYTES ? [at] : []);
100
+ if (competing.length) issues.push({
101
+ check: "competing-images",
102
+ detail: `${competing.length} other image${competing.length === 1 ? "" : "s"} at High priority start before the LCP image: ${competing.slice(0, 3).map((at) => `${fileName(run$1.entries[at].request.url)} (${kib(run$1.entries[at].response.bodySize ?? 0)})`).join(", ")}.`,
103
+ ...har(competing[0])
104
+ });
105
+ const start = lcp._requestTime;
106
+ const end = start + lcp.time / 1e3;
107
+ const during = run$1.entries.map((entry, at) => ({
108
+ entry,
109
+ at,
110
+ bytes: entry === lcp ? 0 : bytesDuring(entry, start, end)
111
+ }));
112
+ const total = during.reduce((sum, item) => sum + item.bytes, lcp.response.bodySize ?? 0);
113
+ const images = during.filter((item) => item.bytes > 0 && isImage(item.entry)).slice().sort((a, b) => b.bytes - a.bytes);
114
+ const imageBytes = images.reduce((sum, item) => sum + item.bytes, 0);
115
+ const scriptBytes = during.filter((item) => item.entry._resourceType === "script").reduce((sum, item) => sum + item.bytes, 0);
116
+ const imageDelayMs = total > 0 ? Math.round(lcp.time * imageBytes / total) : 0;
117
+ if (imageDelayMs >= MIN_DELAY_MS) issues.push({
118
+ check: "bandwidth",
119
+ detail: `Other images download ${kib(imageBytes)} while the ${kib(lcp.response.bodySize ?? 0)} LCP image loads, delaying it by about ${imageDelayMs} ms; the largest is ${fileName(images[0].entry.request.url)} (${kib(images[0].bytes)} during the LCP download).`,
120
+ ...har(images[0].at)
121
+ });
122
+ const blocking = run$1.lhr.audits["render-blocking-insight"];
123
+ const blockingMs = blocking?.metricSavings?.LCP ?? 0;
124
+ if (blockingMs >= MIN_DELAY_MS) {
125
+ const urls = (blocking?.details?.items ?? []).map((item) => fileName(item.url));
126
+ issues.push({
127
+ check: "render-blocking",
128
+ detail: `${urls.length} render-blocking request${urls.length === 1 ? "" : "s"} (${urls.slice(0, 3).join(", ")}) delay the LCP by about ${Math.round(blockingMs)} ms.`,
129
+ ...lighthouse("render-blocking-insight")
130
+ });
131
+ }
132
+ return {
133
+ page: run$1.page,
134
+ lcpMs: run$1.lcpMs,
135
+ image: {
136
+ url: lcp.request.url,
137
+ entry: index,
138
+ bytes: lcp.response.bodySize ?? 0,
139
+ startMs: run$1.startMs(lcp),
140
+ loadMs: Math.round(lcp.time),
141
+ initialPriority: initial,
142
+ priority: lcp._priority ?? null,
143
+ initiator: lcp._initiator_type ?? null,
144
+ preloaded: Boolean(lcp.request._isLinkPreload),
145
+ concurrentImageBytes: imageBytes,
146
+ concurrentScriptBytes: scriptBytes,
147
+ imageDelayMs
148
+ },
149
+ issues
150
+ };
151
+ }
152
+ const run = async ({ pages, playhouse }) => {
153
+ const data = {
154
+ cell: "mobile-cold",
155
+ pages: [],
156
+ skipped: []
157
+ };
158
+ const harArtifacts = /* @__PURE__ */ new Map();
159
+ for (const page of pages) {
160
+ const imageRun = await readImageRun(playhouse, page.label);
161
+ if ("reason" in imageRun) {
162
+ data.skipped.push({
163
+ page: page.label,
164
+ reason: imageRun.reason
165
+ });
166
+ continue;
167
+ }
168
+ const result = checkPage(imageRun);
169
+ if ("reason" in result) {
170
+ data.skipped.push({
171
+ page: page.label,
172
+ reason: result.reason
173
+ });
174
+ continue;
175
+ }
176
+ data.pages.push(result);
177
+ harArtifacts.set(page.label, imageRun.harArtifact);
178
+ }
179
+ if (!data.pages.length) return { data };
180
+ const withImage = data.pages.filter((item) => item.image);
181
+ const failing = withImage.filter((item) => item.issues.length);
182
+ const text = data.pages.filter((item) => !item.image).map((item) => item.page);
183
+ const textNote = text.length ? ` The LCP element is text on ${text.join(", ")}.` : "";
184
+ if (!failing.length) return {
185
+ data,
186
+ decision: {
187
+ status: "pass",
188
+ summary: withImage.length ? `The LCP image on ${withImage.map((item) => item.page).join(", ")} is in the HTML, starts at High priority, isn't lazy-loaded and has no image competing for bandwidth.${textNote}` : `The LCP element is text on every measured page (${text.join(", ")}), so there is no LCP image to prioritize.`,
189
+ evidence: withImage.length ? withImage.flatMap((item) => {
190
+ const image = item.image;
191
+ const artifact = harArtifacts.get(item.page);
192
+ if (!image || !artifact) return [];
193
+ return [{
194
+ type: "playhouse",
195
+ page: item.page,
196
+ artifact,
197
+ pointer: `/log/entries/${image.entry}`,
198
+ value: `${fileName(image.url)}: ${image.initialPriority ?? "unknown"} priority, ${image.preloaded ? "preloaded" : `found by the ${image.initiator ?? "parser"}`}, ${kib(image.bytes)} in ${image.loadMs} ms`
199
+ }];
200
+ }) : data.pages.map((item) => ({
201
+ type: "playhouse",
202
+ page: item.page,
203
+ artifact: "summary-lcp.json",
204
+ value: "LCP element is text"
205
+ }))
206
+ }
207
+ };
208
+ return {
209
+ data,
210
+ decision: {
211
+ status: "fail",
212
+ severity: failing.some((item) => (item.lcpMs ?? 0) > LCP_GOOD_MS) ? "high" : "medium",
213
+ summary: failing.map((item) => `${item.page} (LCP ${item.lcpMs === null ? "unknown" : `${Math.round(item.lcpMs)} ms`}): ${item.issues.map((issue) => issue.detail).join(" ")}`).join(" ") + textNote,
214
+ evidence: failing.flatMap((item) => item.issues.map((issue) => ({
215
+ type: "playhouse",
216
+ page: item.page,
217
+ artifact: issue.artifact,
218
+ pointer: issue.pointer,
219
+ value: issue.detail
220
+ })))
221
+ }
222
+ };
223
+ };
224
+ var lcp_image_priority_default = run;
225
+
226
+ //#endregion
227
+ export { dataSchema, lcp_image_priority_default as default };
@@ -0,0 +1,27 @@
1
+ ---
2
+ id: performance/lcp-image-priority
3
+ title: LCP Image Priority
4
+ severity: high
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [playhouse.summary-lcp, playhouse.lighthouse, playhouse.network-har]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks that when the largest element on screen is an image, the browser finds it in the HTML and downloads it first. The LCP image should be in the server-rendered HTML (or preloaded), start at High priority with `fetchpriority="high"`, not be lazy-loaded, be downloaded once at the size shown, and not share the connection with other large images. Pages whose largest element is text are left out.
11
+ ## Inspect
12
+ The rule script takes the LCP image from each page's `summary-lcp.json` (mobile cold-cache median run) and finds its request in that run's HAR and Lighthouse result. It checks:
13
+ - `not-in-html`: Lighthouse's LCP discovery insight says the request isn't discoverable in the HTML, or JavaScript started it.
14
+ - `lazy`: the image uses `loading="lazy"`.
15
+ - `low-priority`: the request didn't start at High priority (it may be raised once layout shows it's on screen, which is too late).
16
+ - `preload-mismatch`: the page preloads the same image at a different URL, so it downloads twice.
17
+ - `competing-images`: other images of 10 KiB or more start at High priority before it.
18
+ - `bandwidth`: other images downloading at the same time delay it by 100 ms or more, estimated from their share of the bytes downloaded while it loads.
19
+ - `render-blocking`: Lighthouse estimates render-blocking requests delay the LCP by 100 ms or more.
20
+ ## Evaluate
21
+ Pass when no page's LCP image has an issue; fail otherwise, at high severity when a page with an issue has an LCP over 2.5 s and medium when it is within. Undecided only when no page could be measured.
22
+ ## Evidence
23
+ One `playhouse` item per issue, citing the HAR entry or Lighthouse audit, with the issue as its `value`.
24
+ ## Remediation
25
+ Render the LCP image in the server HTML with `fetchpriority="high"` and without `loading="lazy"` (in the storefront, `DynamicImage` with `priority="high"`), make sure any preload uses the same URL and widths as the image, give other images default priority, and lazy-load images below the fold so they don't share the connection. Re-run Playhouse to confirm.
26
+ ## Examples
27
+ Compliant: a home hero preloaded at High priority that downloads before the below-the-fold images start. Problematic: a PDP whose main image downloads alongside a 2.7 MB animated GIF thumbnail, or a hero image added after hydration that starts at Low priority.
@@ -0,0 +1,15 @@
1
+ import "../../../../../har.js";
2
+ import { i as kib, n as documentSizeDataSchema, r as documentSizeRule } from "../../../../../document-size.js";
3
+
4
+ //#region src/audit/catalog/categories/performance/rules/page-data-size.ts
5
+ var page_data_size_default = documentSizeRule({
6
+ label: "Page data",
7
+ budget: 200 * 1024,
8
+ value: (size) => size.pageDataBytes,
9
+ format: kib,
10
+ evidence: (size) => size.scripts.pageData[0] ? { artifact: size.scripts.pageData[0] } : null,
11
+ decideFailures: false
12
+ });
13
+
14
+ //#endregion
15
+ export { documentSizeDataSchema as dataSchema, page_data_size_default as default };
@@ -0,0 +1,22 @@
1
+ ---
2
+ id: performance/page-data-size
3
+ title: Page Data Size
4
+ severity: medium
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [playhouse.scripts, source.code]
7
+ ---
8
+
9
+ ## Intent
10
+ Checks that the loader data React Router embeds in each page's HTML is 200 KiB or less. Everything a loader returns is serialized into the page so the browser can hydrate it, including fields the page never shows. Loaders that return raw Commerce API responses are the usual cause: every product's full image list, long absolute image URLs, variation and promotion details, custom attributes and whole category trees. When a page is over budget, the report points to the loaders and fields that could be trimmed.
11
+ ## Inspect
12
+ The rule script adds up the page data scripts Playhouse archived from each page's HTML (`window.__reactRouterContext.streamController.enqueue(...)`, including streamed data). If every page is within 200 KiB, it passes and this rule needs no analysis.
13
+
14
+ Otherwise, use the `data` file (sizes are in bytes): each page lists its sizes and `scripts.pageData`, the archived page data scripts largest first (relative to the page's `archiveDir`). Read the largest ones; they hold React Router's turbo-stream encoding, in which strings appear once and objects refer to them by index. Find which route's data the large strings belong to (keys such as `loaderData` and route ids such as `routes/_app.category.$categoryId`) and which fields take the most space. Then read that route's loaders in the project and the components that render their data, and list the returned fields the components never read.
15
+ ## Evaluate
16
+ Report one `finding` for the rule, covering every page over 200 KiB. Lead with the fields that would save the most, with an approximate size for each, and the loader that returns them. Typical trims: map the API response to the fields the page renders before returning it from the loader; request fewer `expand` options; return only the images a product tile shows instead of every image group; return image paths instead of repeated absolute URLs. Data the page renders, or that client code needs later (for example, every color's images so a shopper can switch colors without a request), or that SEO or client-side navigation needs, stays. The customer decides which trims to apply, so phrase each one as a suggestion.
17
+ ## Evidence
18
+ For each page over budget: its largest page data script (`playhouse`, with its size as `value`), and `source` evidence for each loader that returns the unused fields and for the components that read the fields that stay.
19
+ ## Remediation
20
+ Shape loader data into what the page renders before returning it, so only that is serialized into the HTML. Re-run Playhouse to confirm each page's data is at or under 200 KiB.
21
+ ## Examples
22
+ Compliant: a PLP loader that returns, for each product tile, its name, price, link and one image. Problematic: a PLP loader that returns the raw `productSearch` response, embedding every hit's swatch image URLs and variant UPC lists the tiles never show.
@@ -0,0 +1,97 @@
1
+ import { a as MIN_RECOMMENDED_BYTES, c as bundlePackageSchema, l as bundlePageSchema, s as bundleCandidateSchema } from "../../../../../schema.js";
2
+ import { z } from "zod";
3
+
4
+ //#region src/audit/catalog/categories/performance/rules/reduce-javascript-on-load.ts
5
+ const MAX_PACKAGES = 30;
6
+ /**
7
+ * Only what could save `MIN_RECOMMENDED_BYTES` reaches the AI. An npm package the app's start needs (`deferrableBytes`
8
+ * 0, or entry.client's own import) can't load later, so it's left out unless two versions of it are bundled.
9
+ */
10
+ function worthALook(owner, graph) {
11
+ if (owner.kind === "build" || owner.bytes < MIN_RECOMMENDED_BYTES) return false;
12
+ if (owner.kind === "project" || !graph || owner.versions) return true;
13
+ return (owner.deferrableBytes ?? 0) >= MIN_RECOMMENDED_BYTES;
14
+ }
15
+ const pageSchema = z.object({
16
+ page: z.string(),
17
+ route: bundlePageSchema.shape.route,
18
+ liveDecodedBytes: z.number(),
19
+ local: z.object({
20
+ chunks: z.number().int(),
21
+ bytes: z.number(),
22
+ candidates: z.array(bundleCandidateSchema),
23
+ packages: z.array(bundlePackageSchema)
24
+ }).strict()
25
+ }).strict();
26
+ const dataSchema = z.object({
27
+ status: z.enum([
28
+ "complete",
29
+ "partial",
30
+ "unavailable"
31
+ ]),
32
+ notes: z.array(z.string()),
33
+ visualizer: z.string().nullable(),
34
+ minSavedBytes: z.number(),
35
+ pages: z.array(pageSchema),
36
+ skipped: z.array(z.object({
37
+ page: z.string(),
38
+ reason: z.string()
39
+ }).strict())
40
+ }).strict();
41
+ /** Without a local build there is nothing to recommend from, so the AI isn't asked. */
42
+ function notVerified(data, reason) {
43
+ return {
44
+ data,
45
+ decision: {
46
+ status: "not-verified",
47
+ summary: `${reason} Re-run the audit once the project's build script succeeds.`,
48
+ evidence: []
49
+ }
50
+ };
51
+ }
52
+ function reduceJavascriptOnLoad({ bundle }) {
53
+ if (!bundle) return notVerified({
54
+ status: "unavailable",
55
+ notes: ["prepare produced no bundle analysis."],
56
+ visualizer: null,
57
+ minSavedBytes: MIN_RECOMMENDED_BYTES,
58
+ pages: [],
59
+ skipped: []
60
+ }, "The run has no bundle analysis.");
61
+ const data = {
62
+ status: bundle.status,
63
+ notes: bundle.notes,
64
+ visualizer: bundle.build?.visualizer ?? null,
65
+ minSavedBytes: MIN_RECOMMENDED_BYTES,
66
+ skipped: bundle.skipped,
67
+ pages: bundle.pages.flatMap((page) => page.local ? [{
68
+ page: page.page,
69
+ route: page.route,
70
+ liveDecodedBytes: page.live.decodedBytes,
71
+ local: {
72
+ chunks: page.local.chunks,
73
+ bytes: page.local.bytes,
74
+ candidates: page.local.candidates.filter((item) => item.savedBytes >= MIN_RECOMMENDED_BYTES),
75
+ packages: page.local.packages.filter((owner) => worthALook(owner, bundle.status === "complete")).slice(0, MAX_PACKAGES)
76
+ }
77
+ }] : [])
78
+ };
79
+ if (bundle.status === "unavailable") return notVerified(data, `The project wasn't built, so its bundle wasn't analyzed: ${bundle.notes.join(" ")}`);
80
+ if (!data.pages.length) return notVerified(data, "No page's route was found in the local build, so no page was analyzed.");
81
+ if (data.pages.some((page) => page.local.candidates.length || page.local.packages.some((owner) => owner.kind === "npm"))) return { data };
82
+ return {
83
+ data,
84
+ decision: {
85
+ status: "pass",
86
+ summary: `In the local build, nothing that ${data.pages.map((page) => page.page).join(", ")} load up front could load later and save ${MIN_RECOMMENDED_BYTES / 1024} KiB or more.`,
87
+ evidence: data.pages.map((page) => ({
88
+ type: "manual",
89
+ description: `Local build: ${page.local.chunks} chunks, ${Math.round(page.local.bytes / 1024)} KiB up front.`,
90
+ location: page.page
91
+ }))
92
+ }
93
+ };
94
+ }
95
+
96
+ //#endregion
97
+ export { dataSchema, reduceJavascriptOnLoad as default };
@@ -0,0 +1,73 @@
1
+ ---
2
+ id: performance/reduce-javascript-on-load
3
+ title: Reduce JavaScript on Load
4
+ severity: medium
5
+ appliesTo: [home, plp, pdp]
6
+ inputs: [project.build, source.code]
7
+ ---
8
+
9
+ ## Intent
10
+ Recommends changes to the storefront's code that make each page download, parse and run less JavaScript before it responds. Almost always that means loading code later: components and libraries the page bundles up front but needs only after an interaction, further down the page, or on another route. Occasionally code shouldn't reach the client at all. Every number comes from a local build of the project folder, not from the live site, which may run other code. `performance/javascript-size` reports what the live site downloads.
11
+ ## Inspect
12
+ The rule script's data comes from `bundle/analysis.json`, which `prepare` writes after building the project folder: the plain build's chunks with their source maps (minified bytes per module), the module graph from the build's `BUNDLES_SIZE_ANALYZE` visualizer report, and the build's React Router manifest. A page is matched to the build by its routes only: the route ids its live HTML names (route ids are route file names, so they survive a rebuild), else the local route whose path matches its URL (`route.source` says which). Never compare the local build with the live site's files or sizes: they come from different builds. `liveDecodedBytes` is context, not something to reconcile.
13
+
14
+ For each page, `local` describes what its routes load up front in the local build: the entry, each route module and their static imports (`chunks`, `bytes`), and the imports and packages that could save at least `minSavedBytes` (30 KiB minified, about 10 KiB over the network). Anything smaller has already been left out, along with packages the app's start needs (React, React Router):
15
+
16
+ - `candidates`: static imports in project code (`importer` with file, line and the import statement as `excerpt`; the imported `target` and its `package`), each with the minified bytes (`savedBytes`) and modules the page would no longer load up front if that one import became lazy. An import inside a larger candidate is listed only when it saves noticeably less. `lazyElsewhere` names project files that already load the target with `import()`: this static import defeats that split.
17
+ - `packages`: npm packages and project folders in the page's up-front code: `bytes`, `modules`, `largestModules` (what of it the page pulls in), `versions` when more than one installed version is bundled, the project files that import an npm package statically (`importers`, the first 8 of `importerCount`) and the bytes freed if all of them became lazy (`deferrableBytes`; 0 means something the app needs to start also imports it).
18
+
19
+ The numbers are exact for the local build; don't recompute them. The audited project is read-only: read and `grep` its source, never build or change it. `<run>/bundle/client-bundle-size.html` is the visualizer report for people; you don't need it.
20
+
21
+ **Spend your effort only where the bytes are.** The recommendations worth making move 100 KiB or more off a page: a large library or component tree that loads up front but is needed only after a tap, further down the page, or on another route. Sort every page's candidates and packages by `savedBytes` (or `deferrableBytes`) and work down from the top; stop once the remaining ones are close to `minSavedBytes`. There is no quota: recommend only changes you traced and would make yourself, and if the page genuinely needs what it loads up front, recommend nothing. A well-optimized build has no recommendations, and that is a good result, not a gap to fill. One 150 KiB recommendation is worth more than ten 30 KiB ones. Code under `root` or `routes/_app` loads on every page, so one change helps them all. For each one worth a look:
22
+
23
+ 1. **Find what it's used for.** Open the importer at its line and follow the imported component, hook or function to every place it renders or runs. For a package, `grep -rn "from '<name>" src` finds every import, not just the first 8. Read enough to know when the code actually runs.
24
+ 2. **Decide when the page first needs it**, and pick the trigger:
25
+ - Rendered on the first render, in view without scrolling, or needed to make the visible page interactive (the header, the visible navigation bar, the product gallery's first image, the add-to-cart button): the page needs it now. Leave that code up front. Never defer the LCP element or anything above the fold.
26
+ - Rendered only after a tap, click, hover or focus (dialogs, drawers, the cart and search overlays, mega-menu panels, popovers, unselected tabs, closed accordions, toasts, a lightbox or zoom, a video before play, share menus, store-locator maps, chat): `interaction`.
27
+ - Part of the target renders up front and part only later (a navigation bar whose flyout panels open on hover or whose mobile drawer opens on tap, a gallery whose zoom viewer opens on tap): don't dismiss it because of the visible part. Recommend moving the later part into its own module and loading that lazily (`lazy-component` on the candidate's `target`, with its trigger), and say in `how` what stays up front. Do this only when the later part is a large share of the target.
28
+ - Below the fold and not needed for search or the LCP (recommendation carousels, recently viewed items, social feeds): `visible`, mounted when it nears the viewport. Content that matters for SEO (product details, reviews that are indexed) must stay server-rendered: don't recommend moving it to the client.
29
+ - Used only by other routes, but imported from a shared module (`root.tsx`, `_app.tsx`, a provider, a barrel `index.ts`, a `utils` file): `navigation`. Move the import into the route that uses it, and React Router loads it with that route's chunk.
30
+ - Needed after load but by nothing the shopper sees first (analytics, monitoring, experiment SDKs, prefetchers): `idle`, imported after the load event or in `requestIdleCallback`.
31
+ - Run only on the server (loaders, actions, SDK clients, server config) but reaching the client through a shared module: `remove` it from the client bundle by moving it into a `.server.ts` module or the loader.
32
+ 3. **Only when the code is needed up front, look for a narrower fix.** Most opportunities are step 2: the library stays, it just loads later. The changes below are exceptions; recommend one only on specific evidence you found in the source, never as a general preference for fewer dependencies, and never as an alternative to a lazy load that works:
33
+ - `narrower-import`: `largestModules` shows parts of a package the project never imports, because it imports a barrel or the package's root entry (`lodash` rather than `lodash/debounce`, a meta-package rather than `@radix-ui/react-x`). Name the entry point to import instead.
34
+ - `remove`: a second installed version of the same package (`versions`; align the ranges or add a `pnpm.overrides` entry), a polyfill for something every supported browser has, dev-only code (devtools, mocks), or an import kept only for a side effect nothing uses.
35
+ - `replace`: rare. Only when the project uses one or two small functions of a large library that must load up front, and a platform API does the same thing exactly (for example one `Intl.DateTimeFormat` call instead of a date library imported for one format). Replacing a library costs the team time and risk, so don't recommend rewriting a library's functionality in project code, and never for security- or correctness-critical code (sanitizing, cryptography, time zones, payments).
36
+ 4. **Make sure it works with server rendering.** A `React.lazy` component that renders on the server still downloads during hydration, right after load, so it saves nothing up front. A lazy component saves bytes only when it isn't rendered until the trigger: behind the `open` state, after the tab is selected, or once it's visible. Give it a fallback the same size as the content so the layout doesn't shift. Preload on intent (`onPointerEnter` or `onFocus` calling the same `import()`) so the first open still feels instant. Never defer `entry.client`, React, React Router, providers the first render needs, or styles the first paint needs.
37
+ 5. **Check the saving is real.** A candidate's `savedBytes` already excludes code something else still imports. For a package-wide change use `deferrableBytes`. `bytes` is only an upper bound for narrowing, replacing or removing. Don't recommend a change you couldn't trace to its use in the source.
38
+ ## Evaluate
39
+ The customer decides which recommendations to apply; they are options, not defects.
40
+
41
+ - `finding`: at least one change you traced makes a page load `minSavedBytes` or more less up front. Severity `medium` when a recommendation saves 100 KiB or more on a page, `low` otherwise.
42
+ - `pass`: you checked the largest candidates and packages, and the page needs them up front or uses them well. Cite the ones you checked and leave out `recommendations`. Prefer a pass to a weak or speculative recommendation.
43
+
44
+ Judge only from the local build and the source; the live site's sizes are reported by `performance/javascript-size`.
45
+ ## Evidence
46
+ Cite each recommendation's main import as `source` evidence (file, line, and the import statement as `excerpt`). Add one `manual` evidence entry per page with recommendations, saying the figures come from the local build (for example `Local build of the project folder: home loads 61 chunks, 1,097 KiB up front`).
47
+
48
+ Also add a `recommendations` field to the output file: the changes you recommend, best first (at most 30), each saving at least `minSavedBytes` on a page; `finalize` rejects smaller ones. Expect most of them to be `lazy-component` or `dynamic-import`; a list made mostly of `replace` changes means you skipped step 2. The dashboard shows them as AI recommendations. `finalize` adds each one's savings per page from `bundle/analysis.json`, so don't include sizes:
49
+
50
+ ```json
51
+ "recommendations": [
52
+ { "pages": ["home", "plp", "pdp"], "change": "lazy-component", "trigger": "interaction",
53
+ "target": "src/components/navigation-menu-mega/index.tsx",
54
+ "title": "Load the mega menu's panels when a top-level category is first hovered or focused",
55
+ "how": "The panels render only while a category is open (navigation-menu-mega/index.tsx:88). Keep the top-level bar static, move the panel into its own module and render it with React.lazy inside Suspense once `openCategory` is set; call the same import() from onPointerEnter and onFocus so it's ready before the first open.",
56
+ "files": [{ "file": "src/routes/_app.tsx", "line": 28 }, { "file": "src/components/navigation-menu-mega/index.tsx", "line": 88 }] },
57
+ { "pages": ["pdp"], "change": "dynamic-import", "trigger": "interaction",
58
+ "target": "zod",
59
+ "title": "Load the review form's validation when the form is submitted",
60
+ "how": "zod is used only by the write-a-review form, which is closed on load (review-form.tsx:5). Import the schema module with `await import('./review-schema')` in the submit handler instead of at the top of the file, so the PDP no longer loads zod up front.",
61
+ "files": [{ "file": "src/components/reviews/review-form.tsx", "line": 5 }] }
62
+ ]
63
+ ```
64
+
65
+ - `pages`: every page whose `local` data lists `target` and that the change helps.
66
+ - `target`: exactly a candidate's `target` or a package's `name` from those pages' data. Recommend each piece of code once: not both a candidate and the package it belongs to.
67
+ - `change`: `lazy-component` (a component, rendered with `React.lazy` once the trigger happens), `dynamic-import` (a function, library or data file, loaded with `await import()` where it's first used), `narrower-import`, `replace` or `remove`.
68
+ - `trigger`: `interaction`, `visible`, `navigation` or `idle`, only for `lazy-component` and `dynamic-import`.
69
+ - `title`: one line naming the change and when the code loads. `how`: what to change, in which file, and what keeps the page working, concretely enough to start on (a few sentences). `files`: where to make it, with lines.
70
+ ## Remediation
71
+ Make each change in the files the recommendation names, then rebuild with `BUNDLES_SIZE_ANALYZE=true` and open `build/client-bundle-size.html` to confirm the code moved out of the page's up-front chunks. Defer a component by rendering it only once it's needed: `const CartDrawer = lazy(() => import('./cart-drawer'))`, then `{open && <Suspense fallback={<DrawerSkeleton />}><CartDrawer /></Suspense>}`. Defer a library to where it's used: `const { z } = await import('zod')` in the submit handler. Move route-specific imports out of `root.tsx`, `_app.tsx` and shared barrels into the routes that use them. Re-run the audit to see each page's local up-front JavaScript go down.
72
+ ## Examples
73
+ Compliant: the cart drawer's contents load with `React.lazy` the first time it opens, and the review form loads its validation library on submit. Problematic: `src/routes/_app.tsx:28` imports the mega menu statically, so every page loads 41 KiB of menu panels that render only after a hover, even though `src/lib/page-designer/static-registry.ts` already imports the same component lazily.