lighthouse 8.6.0-dev.20211102 → 8.6.0-dev.20211106

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 (33) hide show
  1. package/dist/report/flow.js +8 -8
  2. package/dist/report/standalone.js +40 -34
  3. package/flow-report/assets/standalone-flow-template.html +3 -2
  4. package/flow-report/src/icons.tsx +9 -0
  5. package/flow-report/src/sidebar/sidebar.tsx +18 -5
  6. package/flow-report/src/wrappers/report-renderer.tsx +1 -0
  7. package/flow-report/src/wrappers/report.tsx +2 -3
  8. package/flow-report/standalone-flow.tsx +4 -3
  9. package/flow-report/test/sidebar/sidebar-test.tsx +43 -1
  10. package/package.json +1 -1
  11. package/report/assets/standalone-template.html +2 -3
  12. package/report/assets/styles.css +1 -1
  13. package/report/clients/bundle.js +1 -0
  14. package/report/clients/standalone.js +6 -13
  15. package/report/renderer/api.js +32 -0
  16. package/report/renderer/category-renderer.js +1 -1
  17. package/report/renderer/components.js +26 -26
  18. package/report/renderer/dom.js +16 -1
  19. package/report/renderer/drop-down-menu.js +2 -2
  20. package/report/renderer/element-screenshot-renderer.js +6 -6
  21. package/report/renderer/features-util.js +1 -1
  22. package/report/renderer/report-renderer.js +32 -9
  23. package/report/renderer/report-ui-features.js +34 -30
  24. package/report/renderer/swap-locale-feature.js +3 -3
  25. package/report/renderer/topbar-features.js +52 -48
  26. package/report/test/clients/bundle-test.js +8 -0
  27. package/report/test/renderer/report-renderer-axe-test.js +5 -2
  28. package/report/test/renderer/report-renderer-test.js +13 -10
  29. package/report/test/renderer/report-ui-features-test.js +10 -1
  30. package/report/test-assets/faux-psi-template.html +2 -7
  31. package/report/test-assets/faux-psi.js +15 -34
  32. package/report/types/html-renderer.d.ts +2 -0
  33. package/report/types/report-renderer.d.ts +23 -0
@@ -18,7 +18,7 @@
18
18
  /**
19
19
  * @typedef InstallOverlayFeatureParams
20
20
  * @property {DOM} dom
21
- * @property {Element} reportEl
21
+ * @property {Element} rootEl
22
22
  * @property {Element} overlayContainerEl
23
23
  * @property {LH.Audit.Details.FullPageScreenshot} fullPageScreenshot
24
24
  */
@@ -135,7 +135,7 @@ export class ElementScreenshotRenderer {
135
135
  * @param {LH.Audit.Details.FullPageScreenshot['screenshot']} screenshot
136
136
  */
137
137
  static installFullPageScreenshot(el, screenshot) {
138
- el.style.setProperty('--element-screenshot-url', `url(${screenshot.data})`);
138
+ el.style.setProperty('--element-screenshot-url', `url('${screenshot.data}')`);
139
139
  }
140
140
 
141
141
  /**
@@ -143,14 +143,14 @@ export class ElementScreenshotRenderer {
143
143
  * @param {InstallOverlayFeatureParams} opts
144
144
  */
145
145
  static installOverlayFeature(opts) {
146
- const {dom, reportEl, overlayContainerEl, fullPageScreenshot} = opts;
146
+ const {dom, rootEl, overlayContainerEl, fullPageScreenshot} = opts;
147
147
  const screenshotOverlayClass = 'lh-screenshot-overlay--enabled';
148
148
  // Don't install the feature more than once.
149
- if (reportEl.classList.contains(screenshotOverlayClass)) return;
150
- reportEl.classList.add(screenshotOverlayClass);
149
+ if (rootEl.classList.contains(screenshotOverlayClass)) return;
150
+ rootEl.classList.add(screenshotOverlayClass);
151
151
 
152
152
  // Add a single listener to the provided element to handle all clicks within (event delegation).
153
- reportEl.addEventListener('click', e => {
153
+ rootEl.addEventListener('click', e => {
154
154
  const target = /** @type {?HTMLElement} */ (e.target);
155
155
  if (!target) return;
156
156
  // Only activate the overlay for clicks on the screenshot *preview* of an element, not the full-size too.
@@ -14,7 +14,7 @@
14
14
  * @param {boolean} [force]
15
15
  */
16
16
  export function toggleDarkTheme(dom, force) {
17
- const el = dom.find('.lh-vars', dom.document());
17
+ const el = dom.rootEl;
18
18
  // This seems unnecessary, but in DevTools, passing "undefined" as the second
19
19
  // parameter acts like passing "false".
20
20
  // https://github.com/ChromeDevTools/devtools-frontend/blob/dd6a6d4153647c2a4203c327c595692c5e0a4256/front_end/dom_extension/DOMExtension.js#L809-L819
@@ -36,22 +36,43 @@ export class ReportRenderer {
36
36
  constructor(dom) {
37
37
  /** @type {DOM} */
38
38
  this._dom = dom;
39
+ /** @type {LH.Renderer.Options} */
40
+ this._opts = {};
39
41
  }
40
42
 
41
43
  /**
42
44
  * @param {LH.Result} lhr
43
- * @param {Element} container Parent element to render the report into.
45
+ * @param {HTMLElement?} rootEl Report root element containing the report
46
+ * @param {LH.Renderer.Options=} opts
44
47
  * @return {!Element}
45
48
  */
46
- renderReport(lhr, container) {
49
+ renderReport(lhr, rootEl, opts) {
50
+ // Allow legacy report rendering API
51
+ if (!this._dom.rootEl && rootEl) {
52
+ console.warn('Please adopt the new report API in renderer/api.js.');
53
+ const closestRoot = rootEl.closest('.lh-root');
54
+ if (closestRoot) {
55
+ this._dom.rootEl = /** @type {HTMLElement} */ (closestRoot);
56
+ } else {
57
+ rootEl.classList.add('lh-root', 'lh-vars');
58
+ this._dom.rootEl = rootEl;
59
+ }
60
+ } else if (this._dom.rootEl && rootEl) {
61
+ // Handle legacy flow-report case
62
+ this._dom.rootEl = rootEl;
63
+ }
64
+ if (opts) {
65
+ this._opts = opts;
66
+ }
67
+
47
68
  this._dom.setLighthouseChannel(lhr.configSettings.channel || 'unknown');
48
69
 
49
70
  const report = Util.prepareReportResult(lhr);
50
71
 
51
- container.textContent = ''; // Remove previous report.
52
- container.appendChild(this._renderReport(report));
72
+ this._dom.rootEl.textContent = ''; // Remove previous report.
73
+ this._dom.rootEl.appendChild(this._renderReport(report));
53
74
 
54
- return container;
75
+ return this._dom.rootEl;
55
76
  }
56
77
 
57
78
  /**
@@ -197,7 +218,7 @@ export class ReportRenderer {
197
218
  gaugeWrapperEl.addEventListener('click', e => {
198
219
  if (!gaugeWrapperEl.matches('[href^="#"]')) return;
199
220
  const selector = gaugeWrapperEl.getAttribute('href');
200
- const reportRoot = gaugeWrapperEl.closest('.lh-vars');
221
+ const reportRoot = this._dom.rootEl;
201
222
  if (!selector || !reportRoot) return;
202
223
  const destEl = this._dom.find(selector, reportRoot);
203
224
  e.preventDefault();
@@ -301,9 +322,11 @@ export class ReportRenderer {
301
322
 
302
323
  const reportFragment = this._dom.createFragment();
303
324
  reportFragment.append(this._dom.createComponent('styles'));
304
- const topbarDocumentFragment = this._renderReportTopbar(report);
305
325
 
306
- reportFragment.appendChild(topbarDocumentFragment);
326
+ if (!this._opts.omitTopbar) {
327
+ reportFragment.appendChild(this._renderReportTopbar(report));
328
+ }
329
+
307
330
  reportFragment.appendChild(reportContainer);
308
331
  reportContainer.appendChild(headerContainer);
309
332
  reportContainer.appendChild(reportSection);
@@ -311,7 +334,7 @@ export class ReportRenderer {
311
334
 
312
335
  if (fullPageScreenshot) {
313
336
  ElementScreenshotRenderer.installFullPageScreenshot(
314
- reportContainer, fullPageScreenshot.screenshot);
337
+ this._dom.rootEl, fullPageScreenshot.screenshot);
315
338
  }
316
339
 
317
340
  return reportFragment;
@@ -40,16 +40,17 @@ function getTableRows(tableEl) {
40
40
  export class ReportUIFeatures {
41
41
  /**
42
42
  * @param {DOM} dom
43
+ * @param {LH.Renderer.Options} opts
43
44
  */
44
- constructor(dom) {
45
+ constructor(dom, opts = {}) {
45
46
  /** @type {LH.Result} */
46
47
  this.json; // eslint-disable-line no-unused-expressions
47
48
  /** @type {DOM} */
48
49
  this._dom = dom;
49
- /** @type {Document} */
50
- this._document = this._dom.document();
51
- this._topbar = new TopbarFeatures(this, dom);
52
50
 
51
+ this._opts = opts;
52
+
53
+ this._topbar = opts.omitTopbar ? null : new TopbarFeatures(this, dom);
53
54
  this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
54
55
  }
55
56
 
@@ -61,16 +62,19 @@ export class ReportUIFeatures {
61
62
  initFeatures(lhr) {
62
63
  this.json = lhr;
63
64
 
64
- this._topbar.enable(lhr);
65
- this._topbar.resetUIState();
65
+ if (this._topbar) {
66
+ this._topbar.enable(lhr);
67
+ this._topbar.resetUIState();
68
+ }
66
69
  this._setupMediaQueryListeners();
67
70
  this._setupThirdPartyFilter();
68
- this._setupElementScreenshotOverlay(this._dom.find('.lh-container', this._document));
71
+ this._setupElementScreenshotOverlay(this._dom.rootEl);
69
72
 
70
73
  let turnOffTheLights = false;
71
74
  // Do not query the system preferences for DevTools - DevTools should only apply dark theme
72
75
  // if dark is selected in the settings panel.
73
- if (!this._dom.isDevTools() && window.matchMedia('(prefers-color-scheme: dark)').matches) {
76
+ const disableDarkMode = this._dom.isDevTools() || this._opts.disableAutoDarkModeAndFireworks;
77
+ if (!disableDarkMode && window.matchMedia('(prefers-color-scheme: dark)').matches) {
74
78
  turnOffTheLights = true;
75
79
  }
76
80
 
@@ -94,7 +98,7 @@ export class ReportUIFeatures {
94
98
  const hasMetricError = lhr.categories.performance && lhr.categories.performance.auditRefs
95
99
  .some(audit => Boolean(audit.group === 'metrics' && lhr.audits[audit.id].errorMessage));
96
100
  if (hasMetricError) {
97
- const toggleInputEl = this._dom.find('input.lh-metrics-toggle__input', this._document);
101
+ const toggleInputEl = this._dom.find('input.lh-metrics-toggle__input', this._dom.rootEl);
98
102
  toggleInputEl.checked = true;
99
103
  }
100
104
 
@@ -109,7 +113,7 @@ export class ReportUIFeatures {
109
113
  }
110
114
 
111
115
  // Fill in all i18n data.
112
- for (const node of this._dom.findAll('[data-i18n]', this._dom.document())) {
116
+ for (const node of this._dom.findAll('[data-i18n]', this._dom.rootEl)) {
113
117
  // These strings are guaranteed to (at least) have a default English string in Util.UIStrings,
114
118
  // so this cannot be undefined as long as `report-ui-features.data-i18n` test passes.
115
119
  const i18nKey = node.getAttribute('data-i18n');
@@ -119,18 +123,15 @@ export class ReportUIFeatures {
119
123
  }
120
124
 
121
125
  /**
122
- * @param {{container?: Element, text: string, icon?: string, onClick: () => void}} opts
126
+ * @param {{text: string, icon?: string, onClick: () => void}} opts
123
127
  */
124
128
  addButton(opts) {
125
- // report-ui-features doesn't have a reference to the root report el, and PSI has
126
- // 2 reports on the page (and not even attached to DOM when installFeatures is called..)
127
- // so we need a container option to specify where the element should go.
128
- const metricsEl = this._document.querySelector('.lh-audit-group--metrics');
129
- const containerEl = opts.container || metricsEl;
130
- if (!containerEl) return;
129
+ // Use qSA directly to as we don't want to throw (if this element is missing).
130
+ const metricsEl = this._dom.rootEl.querySelector('.lh-audit-group--metrics');
131
+ if (!metricsEl) return;
131
132
 
132
- let buttonsEl = containerEl.querySelector('.lh-buttons');
133
- if (!buttonsEl) buttonsEl = this._dom.createChildOf(containerEl, 'div', 'lh-buttons');
133
+ let buttonsEl = metricsEl.querySelector('.lh-buttons');
134
+ if (!buttonsEl) buttonsEl = this._dom.createChildOf(metricsEl, 'div', 'lh-buttons');
134
135
 
135
136
  const classes = [
136
137
  'lh-button',
@@ -150,8 +151,10 @@ export class ReportUIFeatures {
150
151
  * @return {string}
151
152
  */
152
153
  getReportHtml() {
153
- this._topbar.resetUIState();
154
- return this._document.documentElement.outerHTML;
154
+ if (this._topbar) {
155
+ this._topbar.resetUIState();
156
+ }
157
+ return `<!doctype html><body>${this._dom.rootEl.outerHTML}`;
155
158
  }
156
159
 
157
160
  /**
@@ -163,7 +166,7 @@ export class ReportUIFeatures {
163
166
  }
164
167
 
165
168
  _enableFireworks() {
166
- const scoresContainer = this._dom.find('.lh-scores-container', this._document);
169
+ const scoresContainer = this._dom.find('.lh-scores-container', this._dom.rootEl);
167
170
  scoresContainer.classList.add('lh-score100');
168
171
  scoresContainer.addEventListener('click', _ => {
169
172
  scoresContainer.classList.toggle('lh-fireworks-paused');
@@ -183,7 +186,9 @@ export class ReportUIFeatures {
183
186
  * be in their closed state (not opened) and the templates should be unstamped.
184
187
  */
185
188
  _resetUIState() {
186
- this._topbar.resetUIState();
189
+ if (this._topbar) {
190
+ this._topbar.resetUIState();
191
+ }
187
192
  }
188
193
 
189
194
  /**
@@ -191,8 +196,7 @@ export class ReportUIFeatures {
191
196
  * @param {MediaQueryList|MediaQueryListEvent} mql
192
197
  */
193
198
  onMediaQueryChange(mql) {
194
- const root = this._dom.find('.lh-root', this._document);
195
- root.classList.toggle('lh-narrow', mql.matches);
199
+ this._dom.rootEl.classList.toggle('lh-narrow', mql.matches);
196
200
  }
197
201
 
198
202
  _setupThirdPartyFilter() {
@@ -209,7 +213,7 @@ export class ReportUIFeatures {
209
213
  ];
210
214
 
211
215
  // Get all tables with a text url column.
212
- const tables = Array.from(this._document.querySelectorAll('table.lh-table'));
216
+ const tables = Array.from(this._dom.rootEl.querySelectorAll('table.lh-table'));
213
217
  const tablesWithUrls = tables
214
218
  .filter(el =>
215
219
  el.querySelector('td.lh-table-column--url, td.lh-table-column--source-location'))
@@ -275,9 +279,9 @@ export class ReportUIFeatures {
275
279
  }
276
280
 
277
281
  /**
278
- * @param {Element} el
282
+ * @param {Element} rootEl
279
283
  */
280
- _setupElementScreenshotOverlay(el) {
284
+ _setupElementScreenshotOverlay(rootEl) {
281
285
  const fullPageScreenshot =
282
286
  this.json.audits['full-page-screenshot'] &&
283
287
  this.json.audits['full-page-screenshot'].details &&
@@ -287,8 +291,8 @@ export class ReportUIFeatures {
287
291
 
288
292
  ElementScreenshotRenderer.installOverlayFeature({
289
293
  dom: this._dom,
290
- reportEl: el,
291
- overlayContainerEl: el,
294
+ rootEl: rootEl,
295
+ overlayContainerEl: rootEl,
292
296
  fullPageScreenshot,
293
297
  });
294
298
  }
@@ -33,17 +33,17 @@ export class SwapLocaleFeature {
33
33
  throw new Error('missing icuMessagePaths');
34
34
  }
35
35
 
36
- this._dom.find('.lh-tools-locale', this._dom.document()).classList.remove('lh-hidden');
36
+ this._dom.find('.lh-tools-locale', this._dom.rootEl).classList.remove('lh-hidden');
37
37
 
38
38
  const currentLocale = this._reportUIFeatures.json.configSettings.locale;
39
39
 
40
- const containerEl = this._dom.find('.lh-tools-locale__selector-wrapper', this._dom.document());
40
+ const containerEl = this._dom.find('.lh-tools-locale__selector-wrapper', this._dom.rootEl);
41
41
  containerEl.removeAttribute('aria-hidden');
42
42
  const selectEl = this._dom.createChildOf(containerEl, 'select', 'lh-locale-selector');
43
43
  selectEl.name = 'lh-locale-list';
44
44
  selectEl.setAttribute('role', 'menuitem');
45
45
 
46
- const toggleEl = this._dom.find('.lh-tool-locale__button', this._dom.document());
46
+ const toggleEl = this._dom.find('.lh-tool-locale__button', this._dom.rootEl);
47
47
  toggleEl.addEventListener('click', () => {
48
48
  toggleEl.classList.toggle('lh-active');
49
49
  });
@@ -24,15 +24,13 @@ export class TopbarFeatures {
24
24
  this.lhr; // eslint-disable-line no-unused-expressions
25
25
  this._reportUIFeatures = reportUIFeatures;
26
26
  this._dom = dom;
27
- /** @type {Document} */
28
- this._document = this._dom.document();
29
27
  this._dropDownMenu = new DropDownMenu(this._dom);
30
28
  this._copyAttempt = false;
31
29
  /** @type {HTMLElement} */
32
30
  this.topbarEl; // eslint-disable-line no-unused-expressions
33
31
  /** @type {HTMLElement} */
34
- this.scoreScaleEl; // eslint-disable-line no-unused-expressions
35
- /** @type {HTMLElement} */
32
+ this.categoriesEl; // eslint-disable-line no-unused-expressions
33
+ /** @type {HTMLElement?} */
36
34
  this.stickyHeaderEl; // eslint-disable-line no-unused-expressions
37
35
  /** @type {HTMLElement} */
38
36
  this.highlightEl; // eslint-disable-line no-unused-expressions
@@ -40,7 +38,6 @@ export class TopbarFeatures {
40
38
  this.onKeyUp = this.onKeyUp.bind(this);
41
39
  this.onCopy = this.onCopy.bind(this);
42
40
  this.collapseAllDetails = this.collapseAllDetails.bind(this);
43
- this._updateStickyHeaderOnScroll = this._updateStickyHeaderOnScroll.bind(this);
44
41
  }
45
42
 
46
43
  /**
@@ -48,33 +45,15 @@ export class TopbarFeatures {
48
45
  */
49
46
  enable(lhr) {
50
47
  this.lhr = lhr;
51
- this._document.addEventListener('keyup', this.onKeyUp);
52
- this._document.addEventListener('copy', this.onCopy);
48
+ this._dom.rootEl.addEventListener('keyup', this.onKeyUp);
49
+ this._dom.document().addEventListener('copy', this.onCopy);
53
50
  this._dropDownMenu.setup(this.onDropDownMenuClick);
54
51
  this._setUpCollapseDetailsAfterPrinting();
55
52
 
56
- const topbarLogo = this._dom.find('.lh-topbar__logo', this._document);
53
+ const topbarLogo = this._dom.find('.lh-topbar__logo', this._dom.rootEl);
57
54
  topbarLogo.addEventListener('click', () => toggleDarkTheme(this._dom));
58
55
 
59
- // There is only a sticky header when at least 2 categories are present.
60
- if (Object.keys(this.lhr.categories).length >= 2) {
61
- this._setupStickyHeaderElements();
62
- const containerEl = this._dom.find('.lh-container', this._document);
63
- const elToAddScrollListener = this._getScrollParent(containerEl);
64
- elToAddScrollListener.addEventListener('scroll', this._updateStickyHeaderOnScroll);
65
-
66
- // Use ResizeObserver where available.
67
- // TODO: there is an issue with incorrect position numbers and, as a result, performance
68
- // issues due to layout thrashing.
69
- // See https://github.com/GoogleChrome/lighthouse/pull/9023/files#r288822287 for details.
70
- // For now, limit to DevTools.
71
- if (this._dom.isDevTools()) {
72
- const resizeObserver = new window.ResizeObserver(this._updateStickyHeaderOnScroll);
73
- resizeObserver.observe(containerEl);
74
- } else {
75
- window.addEventListener('resize', this._updateStickyHeaderOnScroll);
76
- }
77
- }
56
+ this._setupStickyHeader();
78
57
  }
79
58
 
80
59
  /**
@@ -112,7 +91,7 @@ export class TopbarFeatures {
112
91
  try {
113
92
  this._reportUIFeatures._saveFile(new Blob([htmlStr], {type: 'text/html'}));
114
93
  } catch (e) {
115
- this._dom.fireEventOn('lh-log', this._document, {
94
+ this._dom.fireEventOn('lh-log', this._dom.document(), {
116
95
  cmd: 'error', msg: 'Could not export as HTML. ' + e.message,
117
96
  });
118
97
  }
@@ -152,7 +131,7 @@ export class TopbarFeatures {
152
131
  e.preventDefault();
153
132
  e.clipboardData.setData('text/plain', JSON.stringify(this.lhr, null, 2));
154
133
 
155
- this._dom.fireEventOn('lh-log', this._document, {
134
+ this._dom.fireEventOn('lh-log', this._dom.document(), {
156
135
  cmd: 'log', msg: 'Report JSON copied to clipboard',
157
136
  });
158
137
  }
@@ -164,28 +143,28 @@ export class TopbarFeatures {
164
143
  * Copies the report JSON to the clipboard (if supported by the browser).
165
144
  */
166
145
  onCopyButtonClick() {
167
- this._dom.fireEventOn('lh-analytics', this._document, {
146
+ this._dom.fireEventOn('lh-analytics', this._dom.document(), {
168
147
  cmd: 'send',
169
148
  fields: {hitType: 'event', eventCategory: 'report', eventAction: 'copy'},
170
149
  });
171
150
 
172
151
  try {
173
- if (this._document.queryCommandSupported('copy')) {
152
+ if (this._dom.document().queryCommandSupported('copy')) {
174
153
  this._copyAttempt = true;
175
154
 
176
155
  // Note: In Safari 10.0.1, execCommand('copy') returns true if there's
177
156
  // a valid text selection on the page. See http://caniuse.com/#feat=clipboard.
178
- if (!this._document.execCommand('copy')) {
157
+ if (!this._dom.document().execCommand('copy')) {
179
158
  this._copyAttempt = false; // Prevent event handler from seeing this as a copy attempt.
180
159
 
181
- this._dom.fireEventOn('lh-log', this._document, {
160
+ this._dom.fireEventOn('lh-log', this._dom.document(), {
182
161
  cmd: 'warn', msg: 'Your browser does not support copy to clipboard.',
183
162
  });
184
163
  }
185
164
  }
186
165
  } catch (e) {
187
166
  this._copyAttempt = false;
188
- this._dom.fireEventOn('lh-log', this._document, {cmd: 'log', msg: e.message});
167
+ this._dom.fireEventOn('lh-log', this._dom.document(), {cmd: 'log', msg: e.message});
189
168
  }
190
169
  }
191
170
 
@@ -206,7 +185,7 @@ export class TopbarFeatures {
206
185
  * open a `<details>` element.
207
186
  */
208
187
  expandAllDetails() {
209
- const details = this._dom.findAll('.lh-categories details', this._document);
188
+ const details = this._dom.findAll('.lh-categories details', this._dom.rootEl);
210
189
  details.map(detail => detail.open = true);
211
190
  }
212
191
 
@@ -215,7 +194,7 @@ export class TopbarFeatures {
215
194
  * open a `<details>` element.
216
195
  */
217
196
  collapseAllDetails() {
218
- const details = this._dom.findAll('.lh-categories details', this._document);
197
+ const details = this._dom.findAll('.lh-categories details', this._dom.rootEl);
219
198
  details.map(detail => detail.open = false);
220
199
  }
221
200
 
@@ -235,7 +214,7 @@ export class TopbarFeatures {
235
214
  /**
236
215
  * Finds the first scrollable ancestor of `element`. Falls back to the document.
237
216
  * @param {Element} element
238
- * @return {Node}
217
+ * @return {Element | Document}
239
218
  */
240
219
  _getScrollParent(element) {
241
220
  const {overflowY} = window.getComputedStyle(element);
@@ -273,24 +252,49 @@ export class TopbarFeatures {
273
252
  }
274
253
  }
275
254
 
276
- _setupStickyHeaderElements() {
277
- this.topbarEl = this._dom.find('div.lh-topbar', this._document);
278
- this.scoreScaleEl = this._dom.find('div.lh-scorescale', this._document);
279
- this.stickyHeaderEl = this._dom.find('div.lh-sticky-header', this._document);
255
+ _setupStickyHeader() {
256
+ // Cache these elements to avoid qSA on each onscroll.
257
+ this.topbarEl = this._dom.find('div.lh-topbar', this._dom.rootEl);
258
+ this.categoriesEl = this._dom.find('div.lh-categories', this._dom.rootEl);
259
+
260
+ // Defer behind rAF to avoid forcing layout.
261
+ window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
262
+ // Only present in the DOM if it'll be used (>=2 categories)
263
+ try {
264
+ this.stickyHeaderEl = this._dom.find('div.lh-sticky-header', this._dom.rootEl);
265
+ } catch {
266
+ return;
267
+ }
280
268
 
281
- // Highlighter will be absolutely positioned at first gauge, then transformed on scroll.
282
- this.highlightEl = this._dom.createChildOf(this.stickyHeaderEl, 'div', 'lh-highlighter');
269
+ // Highlighter will be absolutely positioned at first gauge, then transformed on scroll.
270
+ this.highlightEl = this._dom.createChildOf(this.stickyHeaderEl, 'div', 'lh-highlighter');
271
+
272
+ // Update sticky header visibility and highlight when page scrolls/resizes.
273
+ const scrollParent = this._getScrollParent(this._dom.rootEl);
274
+ // The 'scroll' handler must be should be on {Element | Document}...
275
+ scrollParent.addEventListener('scroll', () => this._updateStickyHeader());
276
+ // However resizeObserver needs an element, *not* the document.
277
+ const resizeTarget = scrollParent instanceof window.Document
278
+ ? document.documentElement
279
+ : scrollParent;
280
+ new window.ResizeObserver(() => this._updateStickyHeader()).observe(resizeTarget);
281
+ }));
283
282
  }
284
283
 
285
- _updateStickyHeaderOnScroll() {
286
- // Show sticky header when the score scale begins to go underneath the topbar.
284
+ /**
285
+ * Toggle visibility and update highlighter position
286
+ */
287
+ _updateStickyHeader() {
288
+ if (!this.stickyHeaderEl) return;
289
+
290
+ // Show sticky header when the main 5 gauges clear the topbar.
287
291
  const topbarBottom = this.topbarEl.getBoundingClientRect().bottom;
288
- const scoreScaleTop = this.scoreScaleEl.getBoundingClientRect().top;
289
- const showStickyHeader = topbarBottom >= scoreScaleTop;
292
+ const categoriesTop = this.categoriesEl.getBoundingClientRect().top;
293
+ const showStickyHeader = topbarBottom >= categoriesTop;
290
294
 
291
295
  // Highlight mini gauge when section is in view.
292
296
  // In view = the last category that starts above the middle of the window.
293
- const categoryEls = Array.from(this._document.querySelectorAll('.lh-category'));
297
+ const categoryEls = Array.from(this._dom.rootEl.querySelectorAll('.lh-category'));
294
298
  const categoriesAboveTheMiddle =
295
299
  categoryEls.filter(el => el.getBoundingClientRect().top - window.innerHeight / 2 < 0);
296
300
  const highlightIndex =
@@ -9,6 +9,7 @@
9
9
  import fs from 'fs';
10
10
 
11
11
  import jsdom from 'jsdom';
12
+ import {jest} from '@jest/globals';
12
13
 
13
14
  import * as lighthouseRenderer from '../../clients/bundle.js';
14
15
  import {LH_ROOT} from '../../../root.js';
@@ -22,16 +23,23 @@ const sampleResultsStr =
22
23
  describe('lighthouseRenderer bundle', () => {
23
24
  let document;
24
25
  beforeAll(() => {
26
+ global.console.warn = jest.fn();
27
+
25
28
  const {window} = new jsdom.JSDOM();
26
29
  document = window.document;
27
30
 
28
31
  global.window = global.self = window;
32
+ global.window.requestAnimationFrame = fn => fn();
29
33
  // Stub out matchMedia for Node.
30
34
  global.self.matchMedia = function() {
31
35
  return {
32
36
  addListener: function() {},
33
37
  };
34
38
  };
39
+ global.window.ResizeObserver = class ResizeObserver {
40
+ observe() { }
41
+ unobserve() { }
42
+ };
35
43
  });
36
44
 
37
45
  afterAll(() => {
@@ -8,6 +8,7 @@
8
8
  /* eslint-env jest */
9
9
 
10
10
  import jsdom from 'jsdom';
11
+ import {jest} from '@jest/globals';
11
12
 
12
13
  import {Util} from '../../renderer/util.js';
13
14
  import {DOM} from '../../renderer/dom.js';
@@ -23,6 +24,8 @@ describe('ReportRendererAxe', () => {
23
24
  let sampleResults;
24
25
 
25
26
  beforeAll(async () => {
27
+ global.console.warn = jest.fn();
28
+
26
29
  const {window} = new jsdom.JSDOM();
27
30
  const dom = new DOM(window.document);
28
31
  const detailsRenderer = new DetailsRenderer(dom);
@@ -47,10 +50,10 @@ describe('ReportRendererAxe', () => {
47
50
  });
48
51
 
49
52
  it('renders without axe violations', async () => {
50
- const container = renderer._dom._document.createElement('main');
53
+ const container = renderer._dom.document().createElement('main');
51
54
  const output = renderer.renderReport(sampleResults, container);
52
55
 
53
- renderer._dom._document.body.appendChild(container);
56
+ renderer._dom.document().body.appendChild(container);
54
57
 
55
58
  const config = {
56
59
  rules: {