@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.
- package/README.md +13 -2
- package/dist/assets.js +25 -0
- package/dist/audit/catalog/categories/best-practices/CATEGORY.md +21 -0
- package/dist/audit/catalog/categories/best-practices/rules/commerce-api-client.md +20 -0
- package/dist/audit/catalog/categories/best-practices/rules/error-boundaries.md +19 -0
- package/dist/audit/catalog/categories/best-practices/rules/production-integrations.md +20 -0
- package/dist/audit/catalog/categories/best-practices/rules/route-revalidation.md +19 -0
- package/dist/audit/catalog/categories/best-practices/rules/server-data-loading.md +19 -0
- package/dist/audit/catalog/categories/performance/CATEGORY.md +18 -0
- package/dist/audit/catalog/categories/performance/rules/app-config-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/app-config-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/cache-control.js +59 -0
- package/dist/audit/catalog/categories/performance/rules/cache-control.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/cls-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/cls-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/content-encoding.js +35 -0
- package/dist/audit/catalog/categories/performance/rules/content-encoding.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/dom-size.js +18 -0
- package/dist/audit/catalog/categories/performance/rules/dom-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/html-document-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/html-document-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/image-dimensions.js +141 -0
- package/dist/audit/catalog/categories/performance/rules/image-dimensions.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/image-transformation.js +117 -0
- package/dist/audit/catalog/categories/performance/rules/image-transformation.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/javascript-size.js +126 -0
- package/dist/audit/catalog/categories/performance/rules/javascript-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/large-images.js +187 -0
- package/dist/audit/catalog/categories/performance/rules/large-images.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.js +227 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.md +27 -0
- package/dist/audit/catalog/categories/performance/rules/page-data-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/page-data-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.js +97 -0
- package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.md +73 -0
- package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.js +201 -0
- package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/server-request-waterfall.md +85 -0
- package/dist/audit/catalog/categories/performance/rules/tbt-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/tbt-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/third-party-fonts.js +265 -0
- package/dist/audit/catalog/categories/performance/rules/third-party-fonts.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/ttfb-budget.js +17 -0
- package/dist/audit/catalog/categories/performance/rules/ttfb-budget.md +20 -0
- package/dist/audit/catalog/categories/security/CATEGORY.md +17 -0
- package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.js +37 -0
- package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.md +20 -0
- package/dist/audit/catalog/categories/security/rules/content-security-policy.js +40 -0
- package/dist/audit/catalog/categories/security/rules/content-security-policy.md +20 -0
- package/dist/audit/catalog/categories/security/rules/csp-runtime-compatibility.md +24 -0
- package/dist/audit/catalog/categories/security/rules/dependency-vulnerabilities.md +19 -0
- package/dist/audit/catalog/categories/security/rules/dynamic-code-execution.md +19 -0
- package/dist/audit/catalog/categories/security/rules/hardcoded-credentials.md +19 -0
- package/dist/audit/catalog/categories/security/rules/html-config-secrets.js +46 -0
- package/dist/audit/catalog/categories/security/rules/html-config-secrets.md +22 -0
- package/dist/audit/catalog/categories/security/rules/protected-route-authorization.md +19 -0
- package/dist/audit/catalog/categories/security/rules/public-client-secrets.md +20 -0
- package/dist/audit/catalog/categories/security/rules/raw-html-sinks.md +20 -0
- package/dist/audit/catalog/categories/security/rules/strict-transport-security.js +183 -0
- package/dist/audit/catalog/categories/security/rules/strict-transport-security.md +24 -0
- package/dist/audit/catalog/categories/seo/CATEGORY.md +17 -0
- package/dist/audit/catalog/categories/seo/rules/canonical-url.md +20 -0
- package/dist/audit/catalog/categories/seo/rules/locale-alternates.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/meta-description.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/page-title.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/robots-indexing.md +20 -0
- package/dist/audit/catalog/categories/seo/rules/structured-data.md +19 -0
- package/dist/audit/instructions/evaluate-rule.md +42 -0
- package/dist/audit/schemas/audit-result.schema.json +1237 -0
- package/dist/audit/ui/assets/banner-brand-default-CDRB3_S0.png +0 -0
- package/dist/audit/ui/assets/banner-group-public-default-BGRJzVY8.png +0 -0
- package/dist/audit/ui/assets/banner-user-default-BW81Ba3z.png +0 -0
- package/dist/audit/ui/assets/bg-info@2x-CzpP0vaE.png +0 -0
- package/dist/audit/ui/assets/einstein-figure-B1xFOo8O.svg +1 -0
- package/dist/audit/ui/assets/group_avatar_160-CrxVHlYA.png +0 -0
- package/dist/audit/ui/assets/group_avatar_200-CSLS8I0r.png +0 -0
- package/dist/audit/ui/assets/index-BKCWlO9b.css +1 -0
- package/dist/audit/ui/assets/index-CUPt8e4A.js +44 -0
- package/dist/audit/ui/assets/profile_avatar_160-CtQTUBo0.png +0 -0
- package/dist/audit/ui/assets/profile_avatar_200-DAztxshF.png +0 -0
- package/dist/audit/ui/assets/symbols-BRmpfDbO.svg +1 -0
- package/dist/audit/ui/assets/symbols-D0Ne6690.svg +1 -0
- package/dist/audit/ui/einstein.png +0 -0
- package/dist/audit/ui/index.html +13 -0
- package/dist/audit.js +1896 -0
- package/dist/cartridge-services/index.d.ts +5 -0
- package/dist/cartridge-services/index.d.ts.map +1 -1
- package/dist/cartridge-services/index.js +94 -7
- package/dist/cartridge-services/index.js.map +1 -1
- package/dist/catalog.js +89 -0
- package/dist/commands/audit/catalog.js +27 -0
- package/dist/commands/audit/finalize.js +42 -0
- package/dist/commands/audit/prepare.js +84 -0
- package/dist/commands/audit/progress.js +51 -0
- package/dist/commands/audit/serve.js +63 -0
- package/dist/commands/config/aggregate-extensions.js +1 -0
- package/dist/commands/create-bundle.js +2 -1
- package/dist/commands/create-instructions.js +1 -0
- package/dist/commands/create-storefront.js +1 -0
- package/dist/commands/deploy-cartridge.js +11 -3
- package/dist/commands/dev.js +2 -0
- package/dist/commands/extensions/create.js +1 -0
- package/dist/commands/extensions/install.js +1 -0
- package/dist/commands/extensions/list.js +1 -0
- package/dist/commands/extensions/remove.js +1 -0
- package/dist/commands/generate-cartridge.js +49 -5
- package/dist/commands/locales/aggregate-extensions.js +1 -0
- package/dist/commands/prepare-local.js +1 -0
- package/dist/commands/preview.js +2 -0
- package/dist/commands/push.js +3 -1
- package/dist/commands/validate-cartridge.js +13 -4
- package/dist/config.js +13 -2
- package/dist/document-size.js +231 -0
- package/dist/events.js +688 -0
- package/dist/flags2.js +34 -0
- package/dist/generate-cartridge.js +41 -6
- package/dist/har.js +205 -0
- package/dist/images.js +146 -0
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/live.js +277 -0
- package/dist/logger.js +1 -4
- package/dist/measured-run.js +91 -0
- package/dist/metric-budget.js +125 -0
- package/dist/mrt/ssr.mjs +19 -19
- package/dist/mrt/ssr.mjs.map +1 -1
- package/dist/mrt/streamingHandler.mjs +26 -26
- package/dist/mrt/streamingHandler.mjs.map +1 -1
- package/dist/package.js +5 -0
- package/dist/sanitize.js +185 -0
- package/dist/schema.js +121 -0
- package/dist/server.js +8 -60
- package/dist/server2.js +185 -0
- package/dist/ts-import.js +57 -0
- package/package.json +22 -5
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import "../../../../../har.js";
|
|
2
|
+
import { i as kib } from "../../../../../document-size.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
//#region src/audit/catalog/categories/performance/rules/render-blocking-font-size.ts
|
|
6
|
+
const BUDGET = 75 * 1024;
|
|
7
|
+
const CELL = "mobile-cold";
|
|
8
|
+
/** Fonts listed per failing page, in the summary and as evidence. */
|
|
9
|
+
const MAX_FILES = 4;
|
|
10
|
+
const summarySchema = z.object({ runs: z.array(z.object({
|
|
11
|
+
runId: z.string().optional(),
|
|
12
|
+
formFactor: z.string().optional(),
|
|
13
|
+
cacheState: z.string().optional(),
|
|
14
|
+
isMedian: z.boolean().optional()
|
|
15
|
+
}).passthrough()).optional().default([]) }).passthrough();
|
|
16
|
+
const requestSchema = z.object({
|
|
17
|
+
url: z.string(),
|
|
18
|
+
resourceType: z.string().nullish(),
|
|
19
|
+
statusCode: z.number().nullish(),
|
|
20
|
+
rendererStartTime: z.number().nullish(),
|
|
21
|
+
resourceSize: z.number().nullish(),
|
|
22
|
+
isLinkPreload: z.boolean().nullish(),
|
|
23
|
+
entity: z.string().nullish()
|
|
24
|
+
}).passthrough();
|
|
25
|
+
const lhrSchema = z.object({ audits: z.object({
|
|
26
|
+
"network-requests": z.object({ details: z.object({
|
|
27
|
+
items: z.array(requestSchema).optional(),
|
|
28
|
+
debugData: z.object({ networkStartTimeTs: z.number().nullish() }).passthrough().optional()
|
|
29
|
+
}).passthrough().optional() }).passthrough().optional(),
|
|
30
|
+
metrics: z.object({ details: z.object({ items: z.array(z.record(z.string(), z.unknown())).optional() }).passthrough().optional() }).passthrough().optional()
|
|
31
|
+
}).passthrough() }).passthrough();
|
|
32
|
+
const fontSchema = z.object({
|
|
33
|
+
url: z.string(),
|
|
34
|
+
bytes: z.number(),
|
|
35
|
+
requestMs: z.number(),
|
|
36
|
+
preloaded: z.boolean(),
|
|
37
|
+
renderBlocking: z.boolean(),
|
|
38
|
+
thirdParty: z.boolean(),
|
|
39
|
+
entry: z.number()
|
|
40
|
+
}).strict();
|
|
41
|
+
const pageSchema = z.object({
|
|
42
|
+
page: z.string(),
|
|
43
|
+
url: z.string(),
|
|
44
|
+
lighthouseArtifact: z.string(),
|
|
45
|
+
lcpMs: z.number(),
|
|
46
|
+
loadMs: z.number().nullable(),
|
|
47
|
+
renderBlockingBytes: z.number(),
|
|
48
|
+
laterBytes: z.number(),
|
|
49
|
+
fonts: z.array(fontSchema)
|
|
50
|
+
}).strict();
|
|
51
|
+
const dataSchema = z.object({
|
|
52
|
+
cell: z.string(),
|
|
53
|
+
budget: z.number(),
|
|
54
|
+
pages: z.array(pageSchema),
|
|
55
|
+
skipped: z.array(z.object({
|
|
56
|
+
page: z.string(),
|
|
57
|
+
reason: z.string()
|
|
58
|
+
}).strict())
|
|
59
|
+
}).strict();
|
|
60
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
61
|
+
/** The median mobile cold-cache run, so the fonts come from the same run as the page's reported metrics. */
|
|
62
|
+
async function medianRun(playhouse, page) {
|
|
63
|
+
const runs = ((await playhouse.readJson(page, "summary.json", summarySchema))?.runs ?? []).filter((run) => run.runId && `${run.formFactor}-${run.cacheState}` === CELL);
|
|
64
|
+
return (runs.find((run) => run.isMedian) ?? runs[0])?.runId ?? `${CELL}-1`;
|
|
65
|
+
}
|
|
66
|
+
function host(url) {
|
|
67
|
+
try {
|
|
68
|
+
return new URL(url).host;
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Every font one page downloaded in its median mobile cold-cache run, each URL once, marked render-blocking when preloaded or requested before both LCP and the load event. */
|
|
74
|
+
async function measurePage(playhouse, page) {
|
|
75
|
+
const lighthouseArtifact = `runs/${await medianRun(playhouse, page)}/lhr.json`;
|
|
76
|
+
const lhr = await playhouse.readJson(page, lighthouseArtifact, lhrSchema);
|
|
77
|
+
if (!lhr) return { reason: `${lighthouseArtifact} is missing.` };
|
|
78
|
+
const network = lhr.audits["network-requests"]?.details;
|
|
79
|
+
const requests = network?.items;
|
|
80
|
+
const metrics = lhr.audits.metrics?.details?.items?.[0];
|
|
81
|
+
if (!requests) return { reason: "The Lighthouse result has no network-requests audit." };
|
|
82
|
+
const lcpMs = metrics?.observedLargestContentfulPaint;
|
|
83
|
+
if (!finite(lcpMs)) return { reason: "The Lighthouse result has no observed LCP." };
|
|
84
|
+
const networkStart = network.debugData?.networkStartTimeTs;
|
|
85
|
+
const navigationStart = metrics?.observedNavigationStartTs;
|
|
86
|
+
const offsetMs = finite(networkStart) && finite(navigationStart) ? (networkStart - navigationStart) / 1e3 : 0;
|
|
87
|
+
const loadMs = metrics?.observedLoad;
|
|
88
|
+
const cutoffMs = finite(loadMs) ? Math.min(lcpMs, loadMs) : lcpMs;
|
|
89
|
+
const document = requests.find((request) => request.resourceType === "Document");
|
|
90
|
+
const fonts = /* @__PURE__ */ new Map();
|
|
91
|
+
for (const [entry, request] of requests.entries()) {
|
|
92
|
+
if (request.resourceType !== "Font" || !finite(request.resourceSize) || request.resourceSize <= 0) continue;
|
|
93
|
+
if (finite(request.statusCode) && request.statusCode >= 400) continue;
|
|
94
|
+
if (!finite(request.rendererStartTime)) return { reason: `The font request ${request.url} has no start time.` };
|
|
95
|
+
const requestMs = Math.round(request.rendererStartTime + offsetMs);
|
|
96
|
+
const preloaded = request.isLinkPreload === true;
|
|
97
|
+
const known = fonts.get(request.url);
|
|
98
|
+
if (known) {
|
|
99
|
+
if (requestMs < known.requestMs) Object.assign(known, {
|
|
100
|
+
requestMs,
|
|
101
|
+
entry
|
|
102
|
+
});
|
|
103
|
+
known.preloaded ||= preloaded;
|
|
104
|
+
known.renderBlocking = known.preloaded || known.requestMs < cutoffMs;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const thirdParty = !document ? false : document.entity && request.entity ? request.entity !== document.entity : host(request.url) !== host(document.url);
|
|
108
|
+
fonts.set(request.url, {
|
|
109
|
+
url: request.url,
|
|
110
|
+
bytes: request.resourceSize,
|
|
111
|
+
requestMs,
|
|
112
|
+
preloaded,
|
|
113
|
+
renderBlocking: preloaded || requestMs < cutoffMs,
|
|
114
|
+
thirdParty,
|
|
115
|
+
entry
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const list = [...fonts.values()];
|
|
119
|
+
const total = (items) => items.reduce((sum, font) => sum + font.bytes, 0);
|
|
120
|
+
return {
|
|
121
|
+
page,
|
|
122
|
+
url: document?.url ?? "",
|
|
123
|
+
lighthouseArtifact,
|
|
124
|
+
lcpMs: Math.round(lcpMs),
|
|
125
|
+
loadMs: finite(loadMs) ? Math.round(loadMs) : null,
|
|
126
|
+
renderBlockingBytes: total(list.filter((font) => font.renderBlocking)),
|
|
127
|
+
laterBytes: total(list.filter((font) => !font.renderBlocking)),
|
|
128
|
+
fonts: list
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** A size, marked "just over" when it would otherwise read the same as the budget it exceeds. */
|
|
132
|
+
const shown = (bytes) => bytes > BUDGET && kib(bytes) === kib(BUDGET) ? `just over ${kib(BUDGET)}` : kib(bytes);
|
|
133
|
+
function fileName(url) {
|
|
134
|
+
try {
|
|
135
|
+
return decodeURIComponent(new URL(url).pathname.split("/").at(-1) || url);
|
|
136
|
+
} catch {
|
|
137
|
+
return url;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const why = (font, lcpMs) => font.preloaded ? "preloaded" : `requested at ${font.requestMs} ms, before LCP at ${lcpMs} ms`;
|
|
141
|
+
const blocking = (size) => size.fonts.filter((font) => font.renderBlocking).slice().sort((a, b) => b.bytes - a.bytes);
|
|
142
|
+
const pageTotal = (size) => {
|
|
143
|
+
const count = blocking(size).length;
|
|
144
|
+
return count ? `${shown(size.renderBlockingBytes)} in ${count} file${count === 1 ? "" : "s"}` : "no render-blocking fonts";
|
|
145
|
+
};
|
|
146
|
+
/** "pdp (96 KiB: inter.woff2 48 KiB, playfair.woff2 48 KiB third-party)". */
|
|
147
|
+
function breakdown(size) {
|
|
148
|
+
const fonts = blocking(size);
|
|
149
|
+
const files = fonts.slice(0, MAX_FILES).map((font) => `${fileName(font.url)} ${kib(font.bytes)}${font.thirdParty ? " third-party" : ""}`);
|
|
150
|
+
if (fonts.length > MAX_FILES) files.push(`+${fonts.length - MAX_FILES} more`);
|
|
151
|
+
return `${size.page} (${shown(size.renderBlockingBytes)}: ${files.join(", ")})`;
|
|
152
|
+
}
|
|
153
|
+
var render_blocking_font_size_default = (async ({ pages, playhouse }) => {
|
|
154
|
+
const data = {
|
|
155
|
+
cell: CELL,
|
|
156
|
+
budget: BUDGET,
|
|
157
|
+
pages: [],
|
|
158
|
+
skipped: []
|
|
159
|
+
};
|
|
160
|
+
for (const page of pages) {
|
|
161
|
+
const size = await measurePage(playhouse, page.label);
|
|
162
|
+
if ("reason" in size) data.skipped.push({
|
|
163
|
+
page: page.label,
|
|
164
|
+
reason: size.reason
|
|
165
|
+
});
|
|
166
|
+
else data.pages.push(size);
|
|
167
|
+
}
|
|
168
|
+
if (!data.pages.length) return { data };
|
|
169
|
+
const over = data.pages.filter((size) => size.renderBlockingBytes > BUDGET);
|
|
170
|
+
if (!over.length) return {
|
|
171
|
+
data,
|
|
172
|
+
decision: {
|
|
173
|
+
status: "pass",
|
|
174
|
+
summary: `Render-blocking fonts are within ${kib(BUDGET)} on every measured page (${data.pages.map((size) => `${size.page} ${pageTotal(size)}`).join(", ")}).`,
|
|
175
|
+
evidence: data.pages.map((size) => ({
|
|
176
|
+
type: "playhouse",
|
|
177
|
+
page: size.page,
|
|
178
|
+
artifact: size.lighthouseArtifact,
|
|
179
|
+
pointer: "/audits/network-requests/details/items",
|
|
180
|
+
value: `Render-blocking fonts: ${pageTotal(size)} (budget ${kib(BUDGET)})`
|
|
181
|
+
}))
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
data,
|
|
186
|
+
decision: {
|
|
187
|
+
status: "fail",
|
|
188
|
+
summary: `Render-blocking fonts are over ${kib(BUDGET)} on ${over.map(breakdown).join("; ")}.`,
|
|
189
|
+
evidence: over.flatMap((size) => blocking(size).slice(0, MAX_FILES).map((font) => ({
|
|
190
|
+
type: "playhouse",
|
|
191
|
+
page: size.page,
|
|
192
|
+
artifact: size.lighthouseArtifact,
|
|
193
|
+
pointer: `/audits/network-requests/details/items/${font.entry}/resourceSize`,
|
|
194
|
+
value: `${fileName(font.url)} ${kib(font.bytes)}${font.thirdParty ? ", third-party" : ""}, ${why(font, size.lcpMs)}`
|
|
195
|
+
})))
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
//#endregion
|
|
201
|
+
export { dataSchema, render_blocking_font_size_default as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: performance/render-blocking-font-size
|
|
3
|
+
title: Render-Blocking Font Size
|
|
4
|
+
severity: medium
|
|
5
|
+
appliesTo: [home, plp, pdp]
|
|
6
|
+
inputs: [playhouse.summary, playhouse.lighthouse]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Intent
|
|
10
|
+
Checks that the web fonts each page needs for its first render add up to 75 KiB or less. A font counts as render-blocking when it is preloaded, or requested before both the page's Largest Contentful Paint (LCP) and its load event: text that uses it can't appear in its final font until the file arrives, and the download competes with CSS and the LCP image for bandwidth on mobile. Fonts requested later, such as those a tag manager or a third-party widget adds after the page has loaded, are not counted.
|
|
11
|
+
## Inspect
|
|
12
|
+
The rule script reads each page's median mobile cold-cache run from `summary.json` and that run's Lighthouse result (`runs/<run>/lhr.json`). From the `network-requests` audit it takes every request Chrome classified as a font, counting each URL once at its decoded size, and compares the request's start time with the observed LCP and load event of the same run from the `metrics` audit, whichever came first (LCP alone when the load event is missing). First- and third-party fonts both count when they are render-blocking. A page whose Lighthouse result lacks these audits, an observed LCP or a font's start time is skipped; if no page can be measured, the rule is not decided.
|
|
13
|
+
## Evaluate
|
|
14
|
+
Pass when every measured page's render-blocking fonts total 75 KiB or less, including pages with no web fonts; fail otherwise.
|
|
15
|
+
## Evidence
|
|
16
|
+
For each failing page, the Lighthouse network request of its largest render-blocking fonts (up to 4 per page), with each font's size and why it counts: preloaded, or requested before LCP and the load event.
|
|
17
|
+
## Remediation
|
|
18
|
+
Start with the largest file. Subset fonts to the characters the storefront uses (for example Latin, with `unicode-range` for other scripts), serve WOFF2, and replace several static weights with one variable font. Preload only the one or two fonts used above the fold; let other weights and families load without a preload so they arrive after the first render. Re-run Playhouse to confirm.
|
|
19
|
+
## Examples
|
|
20
|
+
Compliant: one preloaded 22 KiB variable font. Problematic: four preloaded static weights of 30 KiB each (120 KiB).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: performance/server-request-waterfall
|
|
3
|
+
title: Chained Server-Side Data Requests
|
|
4
|
+
severity: high
|
|
5
|
+
appliesTo: [home, plp, pdp]
|
|
6
|
+
inputs: [source.code, playhouse.summary]
|
|
7
|
+
---
|
|
8
|
+
## Intent
|
|
9
|
+
Traces the network calls each page makes while it renders on the server, and shows which calls wait for another call to finish before they start. Every chained call adds a full round trip before the page can be sent, so the report suggests chains that could run in parallel, be combined or be streamed. Some chains are necessary, when a call needs the earlier result.
|
|
10
|
+
## Inspect
|
|
11
|
+
For each page in `prepared.json`, find the routes that match its URL (`src/root.tsx`, the `_app` layout and the page route) and trace the server-side network calls an SSR request makes: SCAPI/SLAS/OCAPI clients, Einstein, `fetch`, MRT data store reads, and calls made by Page Designer component loaders. For each call, record:
|
|
12
|
+
|
|
13
|
+
- where it starts (file and line of the call, not of a wrapper);
|
|
14
|
+
- which other calls it waits on: an `await`, a `.then` on another call's result, or a middleware that runs earlier;
|
|
15
|
+
- whether it blocks the shell (awaited before the loader returns) or is returned as an un-awaited promise and streamed;
|
|
16
|
+
- whether it is conditional (for example, only without a valid access token);
|
|
17
|
+
- whether its data is critical: needed for LCP, above-the-fold content or SEO content (title, meta, structured data, the main product or product grid). Critical data should stay awaited.
|
|
18
|
+
|
|
19
|
+
React Router semantics: root middlewares run in order, and only I/O before `next()` delays the loaders. All matched loaders start together after the middleware chain. Request-scoped GET dedupe in `lib/api-clients.server.ts` turns identical reads into one call. List a deduped call once.
|
|
20
|
+
|
|
21
|
+
The calls Storefront Next makes by default are listed below to help you trace. Judge every call the same way, wherever it comes from.
|
|
22
|
+
|
|
23
|
+
### Default Storefront Next calls (template 2026.9)
|
|
24
|
+
|
|
25
|
+
- **Middleware (all pages).** `sitesConfigMiddleware` does one MRT data store read when `commerce.sitesFromDal` is on (the default). `auth` makes 0 SLAS calls with a valid access token, 1 (`POST /oauth2/token`, refresh) with an expired token, and 2 chained calls (`GET /oauth2/authorize` → `POST /oauth2/token`) for a public-client guest login. A failed refresh followed by a guest login is 3. Basket, shopper context, data store preferences, site URL config and Page Designer resolution do no I/O by default.
|
|
26
|
+
- **Root loader.** No I/O.
|
|
27
|
+
- **`_app` loader (streamed).** `getCategory(root, levels=1)` → `getCategories(children, levels=2)` (chained); `getComponent('header')` and `getComponent('mega-menu')` in parallel, each followed by its component loaders.
|
|
28
|
+
- **Home.** Bare `/` redirects to `/{site}/{locale}/` after the full middleware chain. On the prefixed URL, `getPage('homepage')` is awaited and blocking, followed by its component loaders (streamed). `productSearch(cgid=root)` and `getCategory(root)` start in parallel and stream; `getCategory(root)` is deduped with `_app`.
|
|
29
|
+
- **PLP.** `getCategory`, the critical `productSearch` (first 4 hits) and `getPages(plp)` start in parallel; the loader awaits the category, the critical search and the pages. The non-critical `productSearch` is chained after the critical search and streamed.
|
|
30
|
+
- **PDP.** `getProduct` (with expansions; it already includes variations, sets, bundles and the primary category) is awaited and blocking. `getPages(pdp)` is chained after it and streamed. A product-recommendations component makes Einstein → `getProducts` (chained, streamed). Reviews, BNPL and product content are mocks with no I/O; default recommendation carousels fetch client-side.
|
|
31
|
+
|
|
32
|
+
## Evaluate
|
|
33
|
+
The customer decides which suggestions to apply.
|
|
34
|
+
|
|
35
|
+
A chain is a call that waits on another data call; waiting only on middleware is the normal request pipeline. For every chained call and every awaited non-critical call, decide whether there is an opportunity:
|
|
36
|
+
|
|
37
|
+
- `parallelize`: the call doesn't need the earlier result (for example, it only needs a route param), so it can start alongside it.
|
|
38
|
+
- `scapi-hook`: the call fetches data that could come back with the earlier response, for example product custom attributes or inventory added to `getProduct` by a SCAPI `modifyGETResponse` hook, so one call replaces two.
|
|
39
|
+
- `stream`: the data isn't critical, so the loader can return the promise un-awaited and let it stream after the shell. Never suggest this for critical data.
|
|
40
|
+
|
|
41
|
+
Leave `opportunity` out when the chain is necessary (it needs the earlier result and the data is critical) and say why in `note`.
|
|
42
|
+
|
|
43
|
+
- `finding`: at least one opportunity.
|
|
44
|
+
- `pass`: every chain is streamed or necessary.
|
|
45
|
+
- `not-verified`: call sites that can't be resolved (opaque wrappers, compiled extensions).
|
|
46
|
+
|
|
47
|
+
TTFB from `summary.json` may corroborate the impact; never invent per-call timings.
|
|
48
|
+
|
|
49
|
+
## Evidence
|
|
50
|
+
Cite each call with an opportunity as `source` evidence (file and line), and optionally the page's TTFB as `playhouse` evidence (`summary.json`, `/cells/<n>/medianRun/ttfb`).
|
|
51
|
+
|
|
52
|
+
Also add a `waterfall` field to the output file with one entry per captured page you traced. The dashboard draws it; it does not carry timings:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
"waterfall": { "pages": [{
|
|
56
|
+
"page": "pdp", "route": "src/routes/_app.product.$productId.tsx",
|
|
57
|
+
"summary": "Auth, then getProduct, then an inventory call that only needs the product id.",
|
|
58
|
+
"steps": [
|
|
59
|
+
{ "id": "auth", "kind": "middleware", "label": "auth · SLAS guest login", "service": "slas", "source": { "file": "src/middlewares/auth.server.ts", "line": 965 }, "after": [], "blocking": true, "conditional": "only without a valid access token" },
|
|
60
|
+
{ "id": "get-product", "kind": "api", "label": "shopperProducts.getProduct", "service": "scapi", "source": { "file": "src/routes/_app.product.$productId.tsx", "line": 172 }, "after": ["auth"], "blocking": true, "critical": true },
|
|
61
|
+
{ "id": "get-inventory", "kind": "api", "label": "GET /inventory/{id}", "service": "other", "source": { "file": "src/routes/_app.product.$productId.tsx", "line": 196 }, "after": ["get-product"], "blocking": true, "critical": true, "opportunity": "parallelize", "note": "Only needs params.productId, so it can start alongside getProduct." },
|
|
62
|
+
{ "id": "get-pages", "kind": "api", "label": "shopperExperience.getPages(pdp)", "service": "scapi", "source": { "file": "src/routes/_app.product.$productId.tsx", "line": 230 }, "after": ["get-product"], "blocking": false },
|
|
63
|
+
{ "id": "render", "kind": "render", "label": "Shell render", "source": { "file": "src/entry.server.tsx", "line": 68 }, "after": ["get-inventory"], "blocking": true }
|
|
64
|
+
]
|
|
65
|
+
}] }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- One step per network call. When a component calls Einstein and then `getProducts`, that's two steps, the second with `after` the first.
|
|
69
|
+
- `id`: lowercase letters, digits and hyphens, unique within the page.
|
|
70
|
+
- `kind`: `middleware`, `loader`, `api` (a network call) or `render` (the shell flush; add one, waiting on the last blocking calls).
|
|
71
|
+
- `service` (optional): `scapi`, `slas`, `ocapi`, `einstein`, `data-store` or `other`.
|
|
72
|
+
- `after`: ids this step waits on, including the previous middleware with I/O. Empty means it starts with the request.
|
|
73
|
+
- `blocking`: `false` for streamed (un-awaited) promises.
|
|
74
|
+
- `critical` (optional): `true` for LCP, above-the-fold or SEO data; `false` for the rest. Set it on every call you suggest changing.
|
|
75
|
+
- `opportunity` (optional): `parallelize`, `scapi-hook` or `stream`, as described under Evaluate. `stream` requires `blocking: true` and `critical: false`; `parallelize` and `scapi-hook` require a non-empty `after`.
|
|
76
|
+
- `conditional` and `note` (optional): short plain text. In `note`, explain the opportunity concretely, or why a chain is necessary.
|
|
77
|
+
|
|
78
|
+
The customer reads `summary` and `note`. Describe what each call does and what it waits on, not where it came from: don't compare calls to the template or label them as added or customized. Describe opportunities as options, not mistakes.
|
|
79
|
+
- At most 60 steps per page. Use project-relative file paths. Leave out middlewares and loaders with no I/O.
|
|
80
|
+
|
|
81
|
+
## Remediation
|
|
82
|
+
These are suggestions for the customer to weigh, not required fixes. Start independent calls before the first `await` (or use `Promise.all`). Return extra data with the earlier response through a SCAPI hook instead of a second call. Return non-critical data as un-awaited promises so it streams, but keep awaiting critical data (LCP, above-the-fold and SEO content) so it's in the first HTML.
|
|
83
|
+
|
|
84
|
+
## Examples
|
|
85
|
+
Compliant: PDP awaits auth, then `getProduct`, with `getPages` streamed. Also compliant: a PLP promotions call that needs the category's promotion ids and renders the above-the-fold banner, so it stays chained and awaited. Problematic: a PDP loader does `await getProduct`, then `await getInventory(params.productId)` (could run in parallel), then `await getReviews(params.productId)` (below the fold, could stream). Ambiguous: a chain inside a vendor extension whose source isn't in the project.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import "../../../../../measured-run.js";
|
|
2
|
+
import { n as metricBudget, r as milliseconds, t as dataSchema } from "../../../../../metric-budget.js";
|
|
3
|
+
|
|
4
|
+
//#region src/audit/catalog/categories/performance/rules/tbt-budget.ts
|
|
5
|
+
var tbt_budget_default = metricBudget({
|
|
6
|
+
metric: "tbt",
|
|
7
|
+
label: "TBT",
|
|
8
|
+
threshold: 200,
|
|
9
|
+
format: milliseconds
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
13
|
+
export { dataSchema, tbt_budget_default as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: performance/tbt-budget
|
|
3
|
+
title: Total Blocking Time (TBT)
|
|
4
|
+
severity: high
|
|
5
|
+
appliesTo: [home, plp, pdp]
|
|
6
|
+
inputs: [playhouse.summary]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Intent
|
|
10
|
+
Checks that each page's median Total Blocking Time, measured by Lighthouse on mobile with a cold cache, is 200 ms or less, Lighthouse's "good" threshold. TBT adds up how long JavaScript blocks the main thread while the page loads, which delays the page's response to taps and clicks.
|
|
11
|
+
## Inspect
|
|
12
|
+
The rule script reads each page's `summary.json` and takes the mobile cold-cache cell's `medianRun.tbt`. Playhouse writes 0 for metrics it could not measure, and a rendered page always has a positive LCP, so a run with an LCP of 0 or less measured nothing. Such runs are left out: when any run failed, the script picks the median again from the measured runs the way Playhouse does (the run closest to the median LCP, ties to the earlier run) and reads that run's `runs[].row.tbt`.
|
|
13
|
+
## Evaluate
|
|
14
|
+
Pass when every measured page's median TBT is at or under 200 ms; fail otherwise. A page without a mobile cold-cache cell, or without any measured run, is skipped. If no page can be measured, the rule is not decided.
|
|
15
|
+
## Evidence
|
|
16
|
+
One `summary.json` pointer per page, `/cells/<i>/medianRun/tbt` (or `/runs/<j>/row/tbt` when runs were left out), with the measured value and how many runs were not measured.
|
|
17
|
+
## Remediation
|
|
18
|
+
Use `opportunities.json` and `summary-tbt.json` for the failing page to find the long tasks and the scripts behind them. Defer or remove non-critical third-party scripts, split large bundles, and move hydration-heavy work off the initial load. Re-run the same Playhouse capture to confirm the mobile cold-cache median is at or under 200 ms.
|
|
19
|
+
## Examples
|
|
20
|
+
Compliant: home median TBT 120 ms. Problematic: pdp median TBT 650 ms.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import "../../../../../har.js";
|
|
2
|
+
import { i as kib } from "../../../../../document-size.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
//#region src/audit/catalog/categories/performance/rules/third-party-fonts.ts
|
|
6
|
+
const CELL = "mobile-cold";
|
|
7
|
+
/** Requests listed per failing page, in the summary and as evidence. */
|
|
8
|
+
const MAX_ITEMS = 4;
|
|
9
|
+
const summarySchema = z.object({ runs: z.array(z.object({
|
|
10
|
+
runId: z.string().optional(),
|
|
11
|
+
formFactor: z.string().optional(),
|
|
12
|
+
cacheState: z.string().optional(),
|
|
13
|
+
isMedian: z.boolean().optional()
|
|
14
|
+
}).passthrough()).optional().default([]) }).passthrough();
|
|
15
|
+
const requestSchema = z.object({
|
|
16
|
+
url: z.string(),
|
|
17
|
+
resourceType: z.string().nullish(),
|
|
18
|
+
statusCode: z.number().nullish(),
|
|
19
|
+
rendererStartTime: z.number().nullish(),
|
|
20
|
+
resourceSize: z.number().nullish(),
|
|
21
|
+
isLinkPreload: z.boolean().nullish(),
|
|
22
|
+
entity: z.string().nullish()
|
|
23
|
+
}).passthrough();
|
|
24
|
+
const lhrSchema = z.object({ audits: z.object({
|
|
25
|
+
"network-requests": z.object({ details: z.object({
|
|
26
|
+
items: z.array(requestSchema).optional(),
|
|
27
|
+
debugData: z.object({ networkStartTimeTs: z.number().nullish() }).passthrough().optional()
|
|
28
|
+
}).passthrough().optional() }).passthrough().optional(),
|
|
29
|
+
metrics: z.object({ details: z.object({ items: z.array(z.record(z.string(), z.unknown())).optional() }).passthrough().optional() }).passthrough().optional()
|
|
30
|
+
}).passthrough() }).passthrough();
|
|
31
|
+
const harSchema = z.object({ log: z.object({ entries: z.array(z.object({
|
|
32
|
+
request: z.object({ url: z.string() }).passthrough(),
|
|
33
|
+
_initiator: z.unknown().optional()
|
|
34
|
+
}).passthrough()) }).passthrough() }).passthrough();
|
|
35
|
+
const itemSchema = z.object({
|
|
36
|
+
kind: z.enum(["font", "stylesheet"]),
|
|
37
|
+
url: z.string(),
|
|
38
|
+
host: z.string(),
|
|
39
|
+
bytes: z.number(),
|
|
40
|
+
requestMs: z.number(),
|
|
41
|
+
preloaded: z.boolean(),
|
|
42
|
+
renderBlocking: z.boolean(),
|
|
43
|
+
stylesheet: z.string().nullable(),
|
|
44
|
+
entry: z.number()
|
|
45
|
+
}).strict();
|
|
46
|
+
const pageSchema = z.object({
|
|
47
|
+
page: z.string(),
|
|
48
|
+
url: z.string(),
|
|
49
|
+
lighthouseArtifact: z.string(),
|
|
50
|
+
harArtifact: z.string().nullable(),
|
|
51
|
+
lcpMs: z.number(),
|
|
52
|
+
loadMs: z.number().nullable(),
|
|
53
|
+
firstPartyFonts: z.number(),
|
|
54
|
+
items: z.array(itemSchema)
|
|
55
|
+
}).strict();
|
|
56
|
+
const dataSchema = z.object({
|
|
57
|
+
cell: z.string(),
|
|
58
|
+
pages: z.array(pageSchema),
|
|
59
|
+
skipped: z.array(z.object({
|
|
60
|
+
page: z.string(),
|
|
61
|
+
reason: z.string()
|
|
62
|
+
}).strict())
|
|
63
|
+
}).strict();
|
|
64
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
65
|
+
/** The median mobile cold-cache run, so the requests come from the same run as the page's reported metrics. */
|
|
66
|
+
async function medianRun(playhouse, page) {
|
|
67
|
+
const runs = ((await playhouse.readJson(page, "summary.json", summarySchema))?.runs ?? []).filter((run) => run.runId && `${run.formFactor}-${run.cacheState}` === CELL);
|
|
68
|
+
return (runs.find((run) => run.isMedian) ?? runs[0])?.runId ?? `${CELL}-1`;
|
|
69
|
+
}
|
|
70
|
+
function host(url) {
|
|
71
|
+
try {
|
|
72
|
+
return new URL(url).host;
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Third-party fonts and font stylesheets one page requested in its median mobile cold-cache run, each URL once. */
|
|
78
|
+
async function measurePage(playhouse, page) {
|
|
79
|
+
const run = await medianRun(playhouse, page);
|
|
80
|
+
const lighthouseArtifact = `runs/${run}/lhr.json`;
|
|
81
|
+
const lhr = await playhouse.readJson(page, lighthouseArtifact, lhrSchema);
|
|
82
|
+
if (!lhr) return { reason: `${lighthouseArtifact} is missing.` };
|
|
83
|
+
const network = lhr.audits["network-requests"]?.details;
|
|
84
|
+
const requests = network?.items;
|
|
85
|
+
const metrics = lhr.audits.metrics?.details?.items?.[0];
|
|
86
|
+
if (!requests) return { reason: "The Lighthouse result has no network-requests audit." };
|
|
87
|
+
const lcpMs = metrics?.observedLargestContentfulPaint;
|
|
88
|
+
if (!finite(lcpMs)) return { reason: "The Lighthouse result has no observed LCP." };
|
|
89
|
+
const document = requests.find((request) => request.resourceType === "Document");
|
|
90
|
+
if (!document) return { reason: "The Lighthouse result has no document request to tell first- from third-party." };
|
|
91
|
+
const networkStart = network.debugData?.networkStartTimeTs;
|
|
92
|
+
const navigationStart = metrics?.observedNavigationStartTs;
|
|
93
|
+
const offsetMs = finite(networkStart) && finite(navigationStart) ? (networkStart - navigationStart) / 1e3 : 0;
|
|
94
|
+
const loadMs = metrics?.observedLoad;
|
|
95
|
+
const cutoffMs = finite(loadMs) ? Math.min(lcpMs, loadMs) : lcpMs;
|
|
96
|
+
const harArtifact = `runs/${run}/network.har`;
|
|
97
|
+
const har = await playhouse.readJson(page, harArtifact, harSchema);
|
|
98
|
+
const initiators = /* @__PURE__ */ new Map();
|
|
99
|
+
for (const entry of har?.log.entries ?? []) if (typeof entry._initiator === "string" && !initiators.has(entry.request.url)) initiators.set(entry.request.url, entry._initiator);
|
|
100
|
+
const stylesheets = new Set(requests.filter((request) => request.resourceType === "Stylesheet").map((request) => request.url));
|
|
101
|
+
const stylesheetOf = (url) => {
|
|
102
|
+
const initiator = initiators.get(url);
|
|
103
|
+
return initiator && stylesheets.has(initiator) ? initiator : null;
|
|
104
|
+
};
|
|
105
|
+
const thirdParty = (request) => document.entity && request.entity ? request.entity !== document.entity : host(request.url) !== host(document.url);
|
|
106
|
+
const found = /* @__PURE__ */ new Map();
|
|
107
|
+
let firstPartyFonts = 0;
|
|
108
|
+
const ok = (request) => !(finite(request.statusCode) && request.statusCode >= 400);
|
|
109
|
+
for (const [entry, request] of requests.entries()) {
|
|
110
|
+
if (request.resourceType !== "Font" || !ok(request)) continue;
|
|
111
|
+
if (!thirdParty(request)) {
|
|
112
|
+
firstPartyFonts++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!finite(request.rendererStartTime)) return { reason: `The font request ${request.url} has no start time.` };
|
|
116
|
+
const requestMs = Math.round(request.rendererStartTime + offsetMs);
|
|
117
|
+
const preloaded = request.isLinkPreload === true;
|
|
118
|
+
const known = found.get(request.url);
|
|
119
|
+
if (known) {
|
|
120
|
+
if (requestMs < known.requestMs) Object.assign(known, {
|
|
121
|
+
requestMs,
|
|
122
|
+
entry
|
|
123
|
+
});
|
|
124
|
+
known.preloaded ||= preloaded;
|
|
125
|
+
known.renderBlocking = known.preloaded || known.requestMs < cutoffMs;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
found.set(request.url, {
|
|
129
|
+
kind: "font",
|
|
130
|
+
url: request.url,
|
|
131
|
+
host: host(request.url) ?? request.url,
|
|
132
|
+
bytes: finite(request.resourceSize) ? request.resourceSize : 0,
|
|
133
|
+
requestMs,
|
|
134
|
+
preloaded,
|
|
135
|
+
renderBlocking: preloaded || requestMs < cutoffMs,
|
|
136
|
+
stylesheet: stylesheetOf(request.url),
|
|
137
|
+
entry
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const fontList = [...found.values()];
|
|
141
|
+
const fontSources = new Set(fontList.flatMap((font) => font.stylesheet ?? []));
|
|
142
|
+
const fontEntities = new Set(fontList.flatMap((font) => requests[font.entry]?.entity ?? []));
|
|
143
|
+
for (const [entry, request] of requests.entries()) {
|
|
144
|
+
if (request.resourceType !== "Stylesheet" || !ok(request) || !thirdParty(request) || found.has(request.url)) continue;
|
|
145
|
+
if (!(fontSources.has(request.url) || !har && request.entity && fontEntities.has(request.entity))) continue;
|
|
146
|
+
if (!finite(request.rendererStartTime)) continue;
|
|
147
|
+
const requestMs = Math.round(request.rendererStartTime + offsetMs);
|
|
148
|
+
const preloaded = request.isLinkPreload === true;
|
|
149
|
+
found.set(request.url, {
|
|
150
|
+
kind: "stylesheet",
|
|
151
|
+
url: request.url,
|
|
152
|
+
host: host(request.url) ?? request.url,
|
|
153
|
+
bytes: finite(request.resourceSize) ? request.resourceSize : 0,
|
|
154
|
+
requestMs,
|
|
155
|
+
preloaded,
|
|
156
|
+
renderBlocking: preloaded || requestMs < cutoffMs,
|
|
157
|
+
stylesheet: stylesheetOf(request.url),
|
|
158
|
+
entry
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
page,
|
|
163
|
+
url: document.url,
|
|
164
|
+
lighthouseArtifact,
|
|
165
|
+
harArtifact: har ? harArtifact : null,
|
|
166
|
+
lcpMs: Math.round(lcpMs),
|
|
167
|
+
loadMs: finite(loadMs) ? Math.round(loadMs) : null,
|
|
168
|
+
firstPartyFonts,
|
|
169
|
+
items: [...found.values()].sort((a, b) => a.requestMs - b.requestMs)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function fileName(url) {
|
|
173
|
+
try {
|
|
174
|
+
return decodeURIComponent(new URL(url).pathname.split("/").at(-1) || url);
|
|
175
|
+
} catch {
|
|
176
|
+
return url;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** "fonts.googleapis.com/css2?family=Inter" for a stylesheet, "inter-latin.woff2 48 KiB" for a font. */
|
|
180
|
+
function label(item) {
|
|
181
|
+
if (item.kind === "font") return item.bytes > 0 ? `${fileName(item.url)} ${kib(item.bytes)}` : fileName(item.url);
|
|
182
|
+
try {
|
|
183
|
+
const url = new URL(item.url);
|
|
184
|
+
return `${url.host}${url.pathname}${url.search.split("&")[0]}`;
|
|
185
|
+
} catch {
|
|
186
|
+
return item.url;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** A stylesheet by file name on the page's own host ("app.css"), by host and path elsewhere ("fonts.googleapis.com/css2"). */
|
|
190
|
+
function sheetName(url, size) {
|
|
191
|
+
try {
|
|
192
|
+
const parsed = new URL(url);
|
|
193
|
+
return parsed.host === host(size.url) ? fileName(url) : `${parsed.host}${parsed.pathname}`;
|
|
194
|
+
} catch {
|
|
195
|
+
return url;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** "imported by app.css" for a stylesheet, "loaded by fonts.googleapis.com/css2" for a font; empty when the HAR doesn't say. */
|
|
199
|
+
function source(item, size) {
|
|
200
|
+
if (!item.stylesheet) return "";
|
|
201
|
+
return `${item.kind === "stylesheet" ? "imported by" : "loaded by"} ${sheetName(item.stylesheet, size)}`;
|
|
202
|
+
}
|
|
203
|
+
function why(item, size) {
|
|
204
|
+
const from = source(item, size);
|
|
205
|
+
return `${item.preloaded ? "preloaded" : `requested at ${item.requestMs} ms, before LCP at ${size.lcpMs} ms`}${from ? `, ${from}` : ""}`;
|
|
206
|
+
}
|
|
207
|
+
const blocking = (size) => size.items.filter((item) => item.renderBlocking);
|
|
208
|
+
/** "home (fonts.googleapis.com/css2?family=Inter imported by app.css; inter-latin.woff2 48 KiB from fonts.gstatic.com)". */
|
|
209
|
+
function breakdown(size) {
|
|
210
|
+
const items = blocking(size);
|
|
211
|
+
const shown = items.slice(0, MAX_ITEMS).map((item) => item.kind === "font" ? `${label(item)} from ${item.host}` : [label(item), source(item, size)].filter(Boolean).join(" "));
|
|
212
|
+
if (items.length > MAX_ITEMS) shown.push(`+${items.length - MAX_ITEMS} more`);
|
|
213
|
+
return `${size.page} (${shown.join("; ")})`;
|
|
214
|
+
}
|
|
215
|
+
function passDetail(size) {
|
|
216
|
+
const later = size.items.filter((item) => item.kind === "font").length;
|
|
217
|
+
const own = `${size.firstPartyFonts} first-party font${size.firstPartyFonts === 1 ? "" : "s"}`;
|
|
218
|
+
return `${size.page} ${own}${later ? `, ${later} third-party after LCP or load` : ""}`;
|
|
219
|
+
}
|
|
220
|
+
var third_party_fonts_default = (async ({ pages, playhouse }) => {
|
|
221
|
+
const data = {
|
|
222
|
+
cell: CELL,
|
|
223
|
+
pages: [],
|
|
224
|
+
skipped: []
|
|
225
|
+
};
|
|
226
|
+
const measured = await Promise.all(pages.map((page) => measurePage(playhouse, page.label)));
|
|
227
|
+
for (const [index, size] of measured.entries()) if ("reason" in size) data.skipped.push({
|
|
228
|
+
page: pages[index].label,
|
|
229
|
+
reason: size.reason
|
|
230
|
+
});
|
|
231
|
+
else data.pages.push(size);
|
|
232
|
+
if (!data.pages.length) return { data };
|
|
233
|
+
const failing = data.pages.filter((size) => blocking(size).length);
|
|
234
|
+
if (!failing.length) return {
|
|
235
|
+
data,
|
|
236
|
+
decision: {
|
|
237
|
+
status: "pass",
|
|
238
|
+
summary: `No measured page loads fonts from another origin before LCP (${data.pages.map(passDetail).join(", ")}).`,
|
|
239
|
+
evidence: data.pages.map((size) => ({
|
|
240
|
+
type: "playhouse",
|
|
241
|
+
page: size.page,
|
|
242
|
+
artifact: size.lighthouseArtifact,
|
|
243
|
+
pointer: "/audits/network-requests/details/items",
|
|
244
|
+
value: `Third-party fonts before LCP: none (${passDetail(size)})`
|
|
245
|
+
}))
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
data,
|
|
250
|
+
decision: {
|
|
251
|
+
status: "fail",
|
|
252
|
+
summary: `Fonts load from another origin before LCP on ${failing.map(breakdown).join("; ")}.`,
|
|
253
|
+
evidence: failing.flatMap((size) => blocking(size).slice(0, MAX_ITEMS).map((item) => ({
|
|
254
|
+
type: "playhouse",
|
|
255
|
+
page: size.page,
|
|
256
|
+
artifact: size.lighthouseArtifact,
|
|
257
|
+
pointer: `/audits/network-requests/details/items/${item.entry}/url`,
|
|
258
|
+
value: `${item.kind === "font" ? "Font" : "Font stylesheet"} ${label(item)} from ${item.host}, ${why(item, size)}`
|
|
259
|
+
})))
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
//#endregion
|
|
265
|
+
export { dataSchema, third_party_fonts_default as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: performance/third-party-fonts
|
|
3
|
+
title: Third-Party Fonts
|
|
4
|
+
severity: medium
|
|
5
|
+
appliesTo: [home, plp, pdp]
|
|
6
|
+
inputs: [playhouse.summary, playhouse.lighthouse, playhouse.network-har]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Intent
|
|
10
|
+
Checks that the fonts a page needs for its first render are served from the storefront's own origin. A font from a font service such as Google Fonts or Adobe Fonts usually arrives through a stylesheet from that service, often pulled in with a CSS `@import`: the browser has to fetch the page's CSS, then the service's stylesheet, then the font, each from a new origin with its own DNS and TLS setup. Text that uses the font can't render in its final font until the whole chain finishes, and the page shifts when it swaps in.
|
|
11
|
+
## Inspect
|
|
12
|
+
The rule script reads each page's median mobile cold-cache run from `summary.json`, that run's Lighthouse result (`runs/<run>/lhr.json`) and its HAR (`runs/<run>/network.har`). From the `network-requests` audit it takes every font, and every stylesheet that loads one, from another site than the document (Lighthouse's entity, else the host). It counts one when it is preloaded, or requested before both the run's observed LCP and its load event, the same cutoff as `performance/render-blocking-font-size`. The HAR's initiators name the stylesheet that requested each font, and whether that stylesheet was itself imported by another one. Third-party fonts requested later, such as those a tag manager or a widget adds, are reported but not counted. A page whose Lighthouse result lacks these audits, an observed LCP or a document request is skipped; if no page can be measured, the rule is not decided.
|
|
13
|
+
## Evaluate
|
|
14
|
+
Pass when no measured page requests a third-party font or font stylesheet before LCP and the load event, including pages with no web fonts; fail otherwise.
|
|
15
|
+
## Evidence
|
|
16
|
+
For each failing page, the Lighthouse network request of each third-party font and font stylesheet in its first render (up to 4 per page), with its origin, when it was requested or that it was preloaded, and the stylesheet that requested or imported it.
|
|
17
|
+
## Remediation
|
|
18
|
+
Self-host the fonts when their license allows it (Google Fonts are open-source licensed; check commercial fonts with their vendor). Download WOFF2 files subset to the characters the storefront uses (for example Latin, with `unicode-range` for other scripts), preferably one variable font instead of several static weights. In a Storefront Next project, put the files in `public/fonts`, declare them with `@font-face` and `font-display: swap` in the theme CSS, remove the `@import` or `<link>` to the font service and its preconnect, and preload only the face used above the fold from the root route's `links`. If the license only allows the vendor's hosted service, load its stylesheet with a `<link>` in the document instead of an `@import`, and preconnect to the font origin. Re-run Playhouse to confirm the fonts now come from the storefront's origin.
|
|
19
|
+
## Examples
|
|
20
|
+
Compliant: Inter self-hosted as one 35 KiB subset variable WOFF2, preloaded from the root route. Problematic: `@import url('https://fonts.googleapis.com/css2?family=Inter…')` in the theme CSS, which loads the font from `fonts.gstatic.com` after two stylesheet round trips.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import "../../../../../measured-run.js";
|
|
2
|
+
import { n as metricBudget, r as milliseconds, t as dataSchema } from "../../../../../metric-budget.js";
|
|
3
|
+
|
|
4
|
+
//#region src/audit/catalog/categories/performance/rules/ttfb-budget.ts
|
|
5
|
+
var ttfb_budget_default = metricBudget({
|
|
6
|
+
metric: "ttfb",
|
|
7
|
+
label: "TTFB",
|
|
8
|
+
threshold: 800,
|
|
9
|
+
format: milliseconds,
|
|
10
|
+
severityForFailure: (value) => value > 1800 ? "high" : "medium",
|
|
11
|
+
validValue: (value) => value > 0,
|
|
12
|
+
includeSpread: true,
|
|
13
|
+
measurementLabel: "representative-run"
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
export { dataSchema, ttfb_budget_default as default };
|