webperf-snippets 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -14
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +44 -9
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +72 -24
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +86 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +74 -11
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1179 -0
- package/snippets/_shared/helpers.js +61 -0
- package/src/bin.js +72 -45
- package/src/crux.js +84 -0
- package/src/decision-tree.js +55 -7
- package/src/interactions.js +13 -3
- package/src/load-snippet.js +1 -1
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +27 -1
- package/src/runner.js +75 -21
- package/src/snippet-names.js +89 -0
- package/src/tracking.js +6 -0
- package/src/workflows/audit.js +17 -0
- package/src/workflows/interaction.js +30 -0
- package/src/workflows/loading.js +9 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Oversized Images
|
|
2
|
+
// https://webperf-snippets.nucliweb.net
|
|
3
|
+
|
|
4
|
+
(async () => {
|
|
5
|
+
const OVERSIZED_RATIO = 1.5; // file has at least 1.5x the pixels it needs on each axis
|
|
6
|
+
const UNDERSIZED_RATIO = 0.9; // file has less than 90% of the pixels it needs
|
|
7
|
+
const SEVERE_OVERSIZED = 2;
|
|
8
|
+
const SEVERE_UNDERSIZED = 0.75;
|
|
9
|
+
const MAX_ITEMS = 50;
|
|
10
|
+
const LOAD_TIMEOUT_MS = 3000;
|
|
11
|
+
|
|
12
|
+
const dpr = window.devicePixelRatio || 1;
|
|
13
|
+
|
|
14
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
15
|
+
const px = (v) => parseFloat(v) || 0;
|
|
16
|
+
|
|
17
|
+
const shortUrl = (url) => (url.startsWith("data:") ? url.slice(0, 60) : url);
|
|
18
|
+
const fileName = (url) => url.split("?")[0].split("/").pop() || url;
|
|
19
|
+
|
|
20
|
+
function selectorOf(el) {
|
|
21
|
+
if (el.id) return `img#${el.id}`;
|
|
22
|
+
const cls = Array.from(el.classList).slice(0, 2);
|
|
23
|
+
return cls.length ? `img.${cls.join(".")}` : "img";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isVector(url) {
|
|
27
|
+
return /^data:image\/svg/i.test(url) || /\.svg([?#]|$)/i.test(url);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The size of the file in pixels. HTMLImageElement.naturalWidth is corrected by the srcset
|
|
31
|
+
// density (a 400px file picked as "2x" reports 200), so a fresh Image, which has no srcset,
|
|
32
|
+
// gives the real pixels. The file comes from the browser cache.
|
|
33
|
+
function loadPixels(url) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const probe = new Image();
|
|
36
|
+
const timer = setTimeout(() => resolve(null), LOAD_TIMEOUT_MS);
|
|
37
|
+
probe.onload = () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
|
|
40
|
+
};
|
|
41
|
+
probe.onerror = () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
resolve(null);
|
|
44
|
+
};
|
|
45
|
+
probe.src = url;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Bytes of each image URL; null when the browser hides them (cross-origin without
|
|
50
|
+
// Timing-Allow-Origin).
|
|
51
|
+
const transferByUrl = new Map();
|
|
52
|
+
performance.getEntriesByType("resource").forEach((e) => {
|
|
53
|
+
if (e.initiatorType !== "img" && e.initiatorType !== "css" && e.initiatorType !== "other") return;
|
|
54
|
+
const bytes = e.transferSize > 0 ? e.transferSize : e.encodedBodySize > 0 ? e.encodedBodySize : null;
|
|
55
|
+
if (bytes !== null) transferByUrl.set(e.name, bytes);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Scale from file pixels to CSS pixels on each axis, following object-fit.
|
|
59
|
+
function scaleOf(img, boxW, boxH, fileW, fileH) {
|
|
60
|
+
const fit = getComputedStyle(img).objectFit;
|
|
61
|
+
const sx = boxW / fileW;
|
|
62
|
+
const sy = boxH / fileH;
|
|
63
|
+
const contain = Math.min(sx, sy);
|
|
64
|
+
const none = img.naturalWidth / fileW; // CSS pixels per file pixel, density-corrected
|
|
65
|
+
if (fit === "cover") return { sx: Math.max(sx, sy), sy: Math.max(sx, sy) };
|
|
66
|
+
if (fit === "contain") return { sx: contain, sy: contain };
|
|
67
|
+
if (fit === "none") return { sx: none, sy: none };
|
|
68
|
+
if (fit === "scale-down") {
|
|
69
|
+
const s = Math.min(contain, none);
|
|
70
|
+
return { sx: s, sy: s };
|
|
71
|
+
}
|
|
72
|
+
return { sx, sy }; // fill
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const images = Array.from(document.querySelectorAll("img"));
|
|
76
|
+
let skippedHidden = 0;
|
|
77
|
+
let skippedNotLoaded = 0;
|
|
78
|
+
let skippedVector = 0;
|
|
79
|
+
|
|
80
|
+
const candidates = [];
|
|
81
|
+
images.forEach((img) => {
|
|
82
|
+
const rect = img.getBoundingClientRect();
|
|
83
|
+
if (rect.width === 0 || rect.height === 0) return void skippedHidden++;
|
|
84
|
+
const url = img.currentSrc || img.src || "";
|
|
85
|
+
if (!img.complete || img.naturalWidth === 0 || !url) return void skippedNotLoaded++;
|
|
86
|
+
if (isVector(url)) return void skippedVector++;
|
|
87
|
+
candidates.push({ img, rect, url });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const pixelsByUrl = new Map();
|
|
91
|
+
await Promise.all(
|
|
92
|
+
[...new Set(candidates.map((c) => c.url))].map(async (url) => {
|
|
93
|
+
pixelsByUrl.set(url, await loadPixels(url));
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const flagged = [];
|
|
98
|
+
let checked = 0;
|
|
99
|
+
let wastedPixels = 0;
|
|
100
|
+
const wastedBytesByUrl = new Map();
|
|
101
|
+
|
|
102
|
+
candidates.forEach(({ img, rect, url }) => {
|
|
103
|
+
const file = pixelsByUrl.get(url);
|
|
104
|
+
if (!file || !file.width || !file.height) return void skippedNotLoaded++;
|
|
105
|
+
checked++;
|
|
106
|
+
|
|
107
|
+
const style = getComputedStyle(img);
|
|
108
|
+
const boxW = rect.width - px(style.paddingLeft) - px(style.paddingRight) - px(style.borderLeftWidth) - px(style.borderRightWidth);
|
|
109
|
+
const boxH = rect.height - px(style.paddingTop) - px(style.paddingBottom) - px(style.borderTopWidth) - px(style.borderBottomWidth);
|
|
110
|
+
if (boxW <= 0 || boxH <= 0) return;
|
|
111
|
+
|
|
112
|
+
const { sx, sy } = scaleOf(img, boxW, boxH, file.width, file.height);
|
|
113
|
+
const neededWidth = Math.round(file.width * sx * dpr);
|
|
114
|
+
const neededHeight = Math.round(file.height * sy * dpr);
|
|
115
|
+
// Pixels of the file per device pixel drawn, on the tighter axis
|
|
116
|
+
const ratio = Math.min(1 / (sx * dpr), 1 / (sy * dpr));
|
|
117
|
+
|
|
118
|
+
let verdict = null;
|
|
119
|
+
if (ratio >= OVERSIZED_RATIO) verdict = "oversized";
|
|
120
|
+
else if (ratio < UNDERSIZED_RATIO) verdict = "undersized";
|
|
121
|
+
if (!verdict) return;
|
|
122
|
+
|
|
123
|
+
const filePixels = file.width * file.height;
|
|
124
|
+
const wasted = verdict === "oversized" ? Math.max(0, filePixels - neededWidth * neededHeight) : 0;
|
|
125
|
+
const transferBytes = transferByUrl.get(url) ?? null;
|
|
126
|
+
const wastedBytes = transferBytes === null ? null : Math.round(transferBytes * (wasted / filePixels));
|
|
127
|
+
|
|
128
|
+
wastedPixels += wasted;
|
|
129
|
+
if (wastedBytes !== null) wastedBytesByUrl.set(url, Math.max(wastedBytesByUrl.get(url) ?? 0, wastedBytes));
|
|
130
|
+
|
|
131
|
+
flagged.push({
|
|
132
|
+
selector: selectorOf(img),
|
|
133
|
+
url: shortUrl(url),
|
|
134
|
+
verdict,
|
|
135
|
+
naturalWidth: file.width,
|
|
136
|
+
naturalHeight: file.height,
|
|
137
|
+
renderedWidth: Math.round(boxW),
|
|
138
|
+
renderedHeight: Math.round(boxH),
|
|
139
|
+
neededWidth,
|
|
140
|
+
neededHeight,
|
|
141
|
+
ratio: round2(ratio),
|
|
142
|
+
wastedPixels: wasted,
|
|
143
|
+
transferBytes,
|
|
144
|
+
wastedBytes,
|
|
145
|
+
hasSrcset: img.hasAttribute("srcset"),
|
|
146
|
+
hasSizes: img.hasAttribute("sizes"),
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Most waste first; undersized images (no waste) follow, the blurriest first
|
|
151
|
+
flagged.sort((a, b) => b.wastedPixels - a.wastedPixels || a.ratio - b.ratio);
|
|
152
|
+
|
|
153
|
+
const oversized = flagged.filter((f) => f.verdict === "oversized").length;
|
|
154
|
+
const undersized = flagged.length - oversized;
|
|
155
|
+
// An image used several times downloads once, so each URL counts once
|
|
156
|
+
const wastedBytes = [...wastedBytesByUrl.values()].reduce((a, b) => a + b, 0);
|
|
157
|
+
|
|
158
|
+
const issues = flagged.map((f) => {
|
|
159
|
+
const size = `${f.naturalWidth}x${f.naturalHeight}`;
|
|
160
|
+
const shown = `${f.neededWidth}x${f.neededHeight}`;
|
|
161
|
+
if (f.verdict === "oversized") {
|
|
162
|
+
const kb = f.wastedBytes === null ? "" : `, about ${Math.round(f.wastedBytes / 1024)} KB wasted`;
|
|
163
|
+
return {
|
|
164
|
+
severity: f.ratio >= SEVERE_OVERSIZED ? "warning" : "info",
|
|
165
|
+
message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (${f.ratio}x too large${kb})`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
severity: f.ratio < SEVERE_UNDERSIZED ? "warning" : "info",
|
|
170
|
+
message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (blurry, needs ${round2(1 / f.ratio)}x more pixels)`,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const items = flagged.slice(0, MAX_ITEMS);
|
|
175
|
+
|
|
176
|
+
console.group("%cOversized Images", "font-weight: bold; font-size: 14px;");
|
|
177
|
+
console.log(` Images checked : ${checked} (devicePixelRatio ${dpr})`);
|
|
178
|
+
console.log(` Oversized : ${oversized}`);
|
|
179
|
+
console.log(` Undersized : ${undersized}`);
|
|
180
|
+
console.log(` Wasted pixels : ${wastedPixels.toLocaleString()}`);
|
|
181
|
+
console.log(` Wasted bytes : ${Math.round(wastedBytes / 1024)} KB (estimated)`);
|
|
182
|
+
if (items.length > 0) {
|
|
183
|
+
console.table(
|
|
184
|
+
items.map((f) => ({
|
|
185
|
+
image: fileName(f.url).slice(0, 40),
|
|
186
|
+
verdict: f.verdict,
|
|
187
|
+
file: `${f.naturalWidth}x${f.naturalHeight}`,
|
|
188
|
+
needed: `${f.neededWidth}x${f.neededHeight}`,
|
|
189
|
+
ratio: f.ratio,
|
|
190
|
+
"wasted KB": f.wastedBytes === null ? "n/a" : Math.round(f.wastedBytes / 1024),
|
|
191
|
+
})),
|
|
192
|
+
);
|
|
193
|
+
} else {
|
|
194
|
+
console.log("%cNo oversized or undersized images found.", "color: #22c55e; font-weight: bold;");
|
|
195
|
+
}
|
|
196
|
+
console.groupEnd();
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
script: "Oversized-Images",
|
|
200
|
+
status: "ok",
|
|
201
|
+
count: flagged.length,
|
|
202
|
+
details: {
|
|
203
|
+
devicePixelRatio: dpr,
|
|
204
|
+
totalImages: images.length,
|
|
205
|
+
imagesChecked: checked,
|
|
206
|
+
oversized,
|
|
207
|
+
undersized,
|
|
208
|
+
wastedPixels,
|
|
209
|
+
wastedBytes,
|
|
210
|
+
skippedHidden,
|
|
211
|
+
skippedNotLoaded,
|
|
212
|
+
skippedVector,
|
|
213
|
+
},
|
|
214
|
+
items,
|
|
215
|
+
issues,
|
|
216
|
+
};
|
|
217
|
+
})();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SVG Embedded Bitmap Analysis
|
|
2
2
|
// https://webperf-snippets.nucliweb.net
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
(async () => {
|
|
5
5
|
function formatSize(bytes) {
|
|
6
6
|
if (!bytes || bytes === 0) return "—";
|
|
7
7
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -20,7 +20,7 @@ void (async () => {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function compressionFromEntry(entry) {
|
|
23
|
-
|
|
23
|
+
// Cross-origin resources without Timing-Allow-Origin report every size as 0
|
|
24
24
|
if (entry.encodedBodySize === 0) return "unknown";
|
|
25
25
|
if (entry.encodedBodySize < entry.decodedBodySize) return "compressed";
|
|
26
26
|
return "none";
|
|
@@ -109,7 +109,14 @@ void (async () => {
|
|
|
109
109
|
|
|
110
110
|
if (svgEntries.length === 0 && inlineSvgTotal === 0) {
|
|
111
111
|
console.log("No SVG resources found on this page.");
|
|
112
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
script: "SVG-Embedded-Bitmap-Analysis",
|
|
114
|
+
status: "ok",
|
|
115
|
+
count: 0,
|
|
116
|
+
details: { externalSvgCount: 0, inlineSvgTotal: 0, svgsWithUse: 0, standaloneInlineSvgs: 0, spriteOpportunity: false },
|
|
117
|
+
items: [],
|
|
118
|
+
issues: [],
|
|
119
|
+
};
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
console.group("%c🖼️ SVG Embedded Bitmap Analysis", "font-weight: bold; font-size: 14px;");
|
|
@@ -210,6 +217,14 @@ void (async () => {
|
|
|
210
217
|
script: "SVG-Embedded-Bitmap-Analysis",
|
|
211
218
|
status: "ok",
|
|
212
219
|
count: withBitmaps.length,
|
|
220
|
+
details: {
|
|
221
|
+
externalSvgCount: svgEntries.length,
|
|
222
|
+
inlineSvgTotal,
|
|
223
|
+
svgsWithUse,
|
|
224
|
+
standaloneInlineSvgs,
|
|
225
|
+
// Five or more standalone inline SVGs are worth moving into a <symbol> sprite
|
|
226
|
+
spriteOpportunity: standaloneInlineSvgs >= 5,
|
|
227
|
+
},
|
|
213
228
|
items: withBitmaps.map((r) => ({
|
|
214
229
|
url: r.url || r.name,
|
|
215
230
|
name: r.name,
|
|
@@ -19,6 +19,15 @@ When the user asks "optimize images" or "check image performance":
|
|
|
19
19
|
- Find-Images-With-Lazy-and-Fetchpriority.js (contradictory attributes)
|
|
20
20
|
- Priority-Hints-Audit.js (LCP image should have fetchpriority="high")
|
|
21
21
|
|
|
22
|
+
### Oversized Images Audit
|
|
23
|
+
|
|
24
|
+
When the user asks about images larger than needed, wasted bytes, blurry images, or `srcset`/`sizes` problems:
|
|
25
|
+
|
|
26
|
+
1. **Oversized-Images.js** - Compare each image file with the pixels drawn (oversized and undersized)
|
|
27
|
+
2. **Image-Element-Audit.js** - Check format, lazy loading and `srcset` usage of the flagged images
|
|
28
|
+
3. Cross-reference with **webperf-core-web-vitals** skill:
|
|
29
|
+
- LCP.js (check whether an oversized image is the LCP element)
|
|
30
|
+
|
|
22
31
|
### Video Performance Audit
|
|
23
32
|
|
|
24
33
|
When the user asks "optimize videos" or "check video performance":
|
|
@@ -106,6 +115,19 @@ Use this decision tree to automatically run follow-up snippets based on results:
|
|
|
106
115
|
|
|
107
116
|
- **If images missing alt text** → Accessibility issue, recommend adding descriptive alt text
|
|
108
117
|
|
|
118
|
+
### After Oversized-Images.js
|
|
119
|
+
|
|
120
|
+
- **If oversized images found** → Recommend:
|
|
121
|
+
- `srcset` with several widths and an accurate `sizes` attribute
|
|
122
|
+
- Resizing at build time or with an image CDN
|
|
123
|
+
- Run **Image-Element-Audit.js** to check format and lazy loading of the same images
|
|
124
|
+
|
|
125
|
+
- **If an oversized image is the LCP element** → Run **webperf-core-web-vitals:LCP.js** and **LCP-Subparts.js** (large files inflate resource load duration)
|
|
126
|
+
|
|
127
|
+
- **If undersized (blurry) images found** → Recommend a source with at least the needed pixels and a `2x` candidate for high-density screens
|
|
128
|
+
|
|
129
|
+
- **If skipped images are lazy-loaded** → Scroll to the end of the page and run the snippet again
|
|
130
|
+
|
|
109
131
|
### After Video-Element-Audit.js
|
|
110
132
|
|
|
111
133
|
- **If video is LCP candidate** → Run:
|
|
@@ -89,9 +89,16 @@
|
|
|
89
89
|
|
|
90
90
|
console.groupEnd();
|
|
91
91
|
|
|
92
|
+
const connectionRating =
|
|
93
|
+
effectiveType === "4g" ? "good"
|
|
94
|
+
: effectiveType === "3g" ? "needs-improvement"
|
|
95
|
+
: effectiveType === "2g" || effectiveType === "slow-2g" ? "poor"
|
|
96
|
+
: undefined;
|
|
97
|
+
|
|
92
98
|
return {
|
|
93
99
|
script: "Network-Bandwidth-Connection-Quality",
|
|
94
100
|
status: "ok",
|
|
101
|
+
...(connectionRating !== undefined ? { rating: connectionRating } : {}),
|
|
95
102
|
details: {
|
|
96
103
|
effectiveType,
|
|
97
104
|
downlink: connection.downlink,
|