Repository: GoogleChrome/lighthouse
Pull request #8724 (2019-04-29 21:25:37)

core: filter out blob urls more often
I noticed this blob url in our caching audit.
Doesn't make much sense. :)


![image](https://user-images.githubusercontent.com/39191/56928072-9d623180-6a8a-11e9-9b00-f3f4ce1680d9.png)


patrickhulce: (needs sample json update though :))

Patch:
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index 0d4738055..79322f4d0 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -152,11 +152,12 @@ class CacheHeaders extends Audit {
       NetworkRequest.TYPES.Stylesheet,
     ]);
 
-    const resourceUrl = record.url;
+    // It's not a request loaded over the network, caching makes no sense
+    if (URL.NON_NETWORK_PROTOCOLS.includes(record.protocol)) return false;
+
     return (
       CACHEABLE_STATUS_CODES.has(record.statusCode) &&
-      STATIC_RESOURCE_TYPES.has(record.resourceType || 'Other') &&
-      !resourceUrl.includes('data:')
+      STATIC_RESOURCE_TYPES.has(record.resourceType || 'Other')
     );
   }
 
diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js
index f6460c28e..32a219b4a 100644
--- a/lighthouse-core/audits/font-display.js
+++ b/lighthouse-core/audits/font-display.js
@@ -116,6 +116,7 @@ class FontDisplay extends Audit {
       .filter(record => !passingFontURLs.has(record.url))
       // ...and that aren't data URLs, the blocking concern doesn't really apply
       .filter(record => !/^data:/.test(record.url))
+      .filter(record => !/^blob:/.test(record.url))
       .map(record => {
         // In reality the end time should be calculated with paint time included
         // all browsers wait 3000ms to block text so we make sure 3000 is our max wasted time
diff --git a/lighthouse-core/audits/seo/canonical.js b/lighthouse-core/audits/seo/canonical.js
index 3b5d32960..75ad89955 100644
--- a/lighthouse-core/audits/seo/canonical.js
+++ b/lighthouse-core/audits/seo/canonical.js
@@ -35,19 +35,6 @@ const UIStrings = {
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
-/**
- * Returns a primary domain for provided URL (e.g. http://www.example.com -> example.com).
- * Note that it does not take second-level domains into account (.co.uk).
- * @param {URL} url
- * @returns {string}
- */
-function getPrimaryDomain(url) {
-  return url.hostname
-    .split('.')
-    .slice(-2)
-    .join('.');
-}
-
 /**
  * @typedef CanonicalURLData
  * @property {Set<string>} uniqueCanonicalURLs
@@ -173,7 +160,7 @@ class Canonical extends Audit {
 
     // bing and yahoo don't allow canonical URLs pointing to different domains, it's also
     // a common mistake to publish a page with canonical pointing to e.g. a test domain or localhost
-    if (getPrimaryDomain(canonicalURL) !== getPrimaryDomain(baseURL)) {
+    if (!URL.rootDomainsMatch(canonicalURL, baseURL)) {
       return {
         score: 0,
         explanation: str_(UIStrings.explanationDifferentDomain, {url: canonicalURL}),
diff --git a/lighthouse-core/computed/resource-summary.js b/lighthouse-core/computed/resource-summary.js
new file mode 100644
index 000000000..a2c9b6b59
--- /dev/null
+++ b/lighthouse-core/computed/resource-summary.js
@@ -0,0 +1,84 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const makeComputedArtifact = require('./computed-artifact.js');
+const NetworkRecords = require('./network-records.js');
+const MainResource = require('./main-resource.js');
+const URL = require('../lib/url-shim.js');
+
+/** @typedef {{count: number, size: number}} ResourceEntry */
+class ResourceSummary {
+  /**
+   * @param {LH.Artifacts.NetworkRequest} record
+   * @return {LH.Budget.ResourceType}
+   */
+  static determineResourceType(record) {
+    if (!record.resourceType) return 'other';
+    /** @type {Partial<Record<LH.Crdp.Page.ResourceType, LH.Budget.ResourceType>>} */
+    const requestToResourceType = {
+      'Stylesheet': 'stylesheet',
+      'Image': 'image',
+      'Media': 'media',
+      'Font': 'font',
+      'Script': 'script',
+      'Document': 'document',
+    };
+    return requestToResourceType[record.resourceType] || 'other';
+  }
+
+  /**
+   * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
+   * @param {string} mainResourceURL
+   * @return {Record<LH.Budget.ResourceType,ResourceEntry>}
+   */
+  static summarize(networkRecords, mainResourceURL) {
+    /** @type {Record<LH.Budget.ResourceType,ResourceEntry>} */
+    const resourceSummary = {
+      'stylesheet': {count: 0, size: 0},
+      'image': {count: 0, size: 0},
+      'media': {count: 0, size: 0},
+      'font': {count: 0, size: 0},
+      'script': {count: 0, size: 0},
+      'document': {count: 0, size: 0},
+      'other': {count: 0, size: 0},
+      'total': {count: 0, size: 0},
+      'third-party': {count: 0, size: 0},
+    };
+
+    for (const record of networkRecords) {
+      const type = this.determineResourceType(record);
+      resourceSummary[type].count++;
+      resourceSummary[type].size += record.transferSize;
+
+      resourceSummary.total.count++;
+      resourceSummary.total.size += record.transferSize;
+
+      // Ignores subdomains: i.e. blog.example.com & example.com would match
+      if (!URL.rootDomainsMatch(record.url, mainResourceURL)) {
+        resourceSummary['third-party'].count++;
+        resourceSummary['third-party'].size += record.transferSize;
+      }
+    }
+    return resourceSummary;
+  }
+
+  /**
+   * @param {{URL: LH.Artifacts['URL'], devtoolsLog: LH.DevtoolsLog}} data
+   * @param {LH.Audit.Context} context
+   * @return {Promise<Record<LH.Budget.ResourceType,ResourceEntry>>}
+   */
+  static async compute_(data, context) {
+    const [networkRecords, mainResource] = await Promise.all([
+      NetworkRecords.request(data.devtoolsLog, context),
+      MainResource.request(data, context),
+    ]);
+
+    return ResourceSummary.summarize(networkRecords, mainResource.url);
+  }
+}
+
+module.exports = makeComputedArtifact(ResourceSummary);
diff --git a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
index d536ffefb..876b27945 100644
--- a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
+++ b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
@@ -56,6 +56,7 @@ function collectTagsThatBlockFirstPaint() {
               !scriptTag.hasAttribute('async') &&
               !scriptTag.hasAttribute('defer') &&
               !/^data:/.test(scriptTag.src) &&
+              !/^blob:/.test(scriptTag.src) &&
               scriptTag.getAttribute('type') !== 'module'
             );
           } else if (tag.tagName === 'LINK') {
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index 3c37fd85c..16d73ca6a 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -1196,7 +1196,7 @@
     "description": "Label for the TTL column in data tables, entries will be the time to live value of the cache header on a web resource"
   },
   "lighthouse-core/lib/i18n/i18n.js | columnSize": {
-    "message": "Size (KB)",
+    "message": "Size",
     "description": "Label for the size column in data tables, entries will be the size of a web resource in kilobytes"
   },
   "lighthouse-core/lib/i18n/i18n.js | columnTimeSpent": {
@@ -1208,11 +1208,11 @@
     "description": "Label for the URL column in data tables, entries will be the URL of a web resource"
   },
   "lighthouse-core/lib/i18n/i18n.js | columnWastedBytes": {
-    "message": "Potential Savings (KB)",
+    "message": "Potential Savings",
     "description": "Label for the wasted bytes column in data tables, entries will be the number of kilobytes the user could reduce their page by if they implemented the suggestions"
   },
   "lighthouse-core/lib/i18n/i18n.js | columnWastedMs": {
-    "message": "Potential Savings (ms)",
+    "message": "Potential Savings",
     "description": "Label for the wasted bytes column in data tables, entries will be the number of milliseconds the user could reduce page load by if they implemented the suggestions"
   },
   "lighthouse-core/lib/i18n/i18n.js | displayValueByteSavings": {
@@ -1343,6 +1343,10 @@
     "message": "Expand snippet",
     "description": "Label for button that shows all lines of the snippet when clicked"
   },
+  "lighthouse-core/report/html/renderer/util.js | thirdPartyResourcesLabel": {
+    "message": "Show 3rd-party resources",
+    "description": "This label is for a checkbox above a table of items loaded by a web page. The checkbox is used to show or hide third-party (or \"3rd-party\") resources in the table, where \"third-party resources\" refers to items loaded by a web page from URLs that aren't controlled by the owner of the web page."
+  },
   "lighthouse-core/report/html/renderer/util.js | toplevelWarningsMessage": {
     "message": "There were issues affecting this run of Lighthouse:",
     "description": "Label shown preceding any important warnings that may have invalidated the entire report. For example, if the user has Chrome extensions installed, they may add enough performance overhead that Lighthouse's performance metrics are unreliable. If shown, this will be displayed at the top of the report UI."
diff --git a/lighthouse-core/lib/i18n/i18n.js b/lighthouse-core/lib/i18n/i18n.js
index e77bc4e50..9c4ba3934 100644
--- a/lighthouse-core/lib/i18n/i18n.js
+++ b/lighthouse-core/lib/i18n/i18n.js
@@ -47,13 +47,13 @@ const UIStrings = {
   /** Label for the URL column in data tables, entries will be the URL of a web resource */
   columnURL: 'URL',
   /** Label for the size column in data tables, entries will be the size of a web resource in kilobytes */
-  columnSize: 'Size (KB)',
+  columnSize: 'Size',
   /** Label for the TTL column in data tables, entries will be the time to live value of the cache header on a web resource */
   columnCacheTTL: 'Cache TTL',
   /** Label for the wasted bytes column in data tables, entries will be the number of kilobytes the user could reduce their page by if they implemented the suggestions */
-  columnWastedBytes: 'Potential Savings (KB)',
+  columnWastedBytes: 'Potential Savings',
   /** Label for the wasted bytes column in data tables, entries will be the number of milliseconds the user could reduce page load by if they implemented the suggestions */
-  columnWastedMs: 'Potential Savings (ms)',
+  columnWastedMs: 'Potential Savings',
   /** Label for the time spent column in data tables, entries will be the number of milliseconds spent during a particular activity */
   columnTimeSpent: 'Time Spent',
 };
diff --git a/lighthouse-core/lib/url-shim.js b/lighthouse-core/lib/url-shim.js
index 52691ef4b..cb8d23339 100644
--- a/lighthouse-core/lib/url-shim.js
+++ b/lighthouse-core/lib/url-shim.js
@@ -9,22 +9,10 @@
  * URL shim so we keep our code DRY
  */
 
-/* global self */
+/* global URL */
 
 const Util = require('../report/html/renderer/util.js');
 
-// Type cast so tsc sees window.URL and require('url').URL as sufficiently equivalent.
-const URL = /** @type {!Window["URL"]} */ (typeof self !== 'undefined' && self.URL) ||
-    require('url').URL;
-
-// 25 most used tld plus one domains (aka public suffixes) from http archive.
-// @see https://github.com/GoogleChrome/lighthouse/pull/5065#discussion_r191926212
-// The canonical list is https://publicsuffix.org/learn/ but we're only using subset to conserve bytes
-const listOfTlds = [
-  'com', 'co', 'gov', 'edu', 'ac', 'org', 'go', 'gob', 'or', 'net', 'in', 'ne', 'nic', 'gouv',
-  'web', 'spb', 'blog', 'jus', 'kiev', 'mil', 'wi', 'qc', 'ca', 'bel', 'on',
-];
-
 const allowedProtocols = [
   'https:', 'http:', 'chrome:', 'chrome-extension:',
 ];
@@ -99,34 +87,18 @@ class URLShim extends URL {
     }
   }
 
-  /**
-   * Gets the tld of a domain
-   *
-   * @param {string} hostname
-   * @return {string} tld
-   */
-  static getTld(hostname) {
-    const tlds = hostname.split('.').slice(-2);
-
-    if (!listOfTlds.includes(tlds[0])) {
-      return `.${tlds[tlds.length - 1]}`;
-    }
-
-    return `.${tlds.join('.')}`;
-  }
-
   /**
    * Check if rootDomains matches
    *
-   * @param {string} urlA
-   * @param {string} urlB
+   * @param {string|URL} urlA
+   * @param {string|URL} urlB
    */
   static rootDomainsMatch(urlA, urlB) {
     let urlAInfo;
     let urlBInfo;
     try {
-      urlAInfo = new URL(urlA);
-      urlBInfo = new URL(urlB);
+      urlAInfo = Util.createOrReturnURL(urlA);
+      urlBInfo = Util.createOrReturnURL(urlB);
     } catch (err) {
       return false;
     }
@@ -135,14 +107,9 @@ class URLShim extends URL {
       return false;
     }
 
-    const tldA = URLShim.getTld(urlAInfo.hostname);
-    const tldB = URLShim.getTld(urlBInfo.hostname);
-
     // get the string before the tld
-    const urlARootDomain = urlAInfo.hostname.replace(new RegExp(`${tldA}$`), '')
-      .split('.').splice(-1)[0];
-    const urlBRootDomain = urlBInfo.hostname.replace(new RegExp(`${tldB}$`), '')
-      .split('.').splice(-1)[0];
+    const urlARootDomain = Util.getRootDomain(urlAInfo);
+    const urlBRootDomain = Util.getRootDomain(urlBInfo);
 
     return urlARootDomain === urlBRootDomain;
   }
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 2240e62e5..a77e1d545 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -124,7 +124,11 @@ class DetailsRenderer {
       element.appendChild(hostElem);
     }
 
-    if (title) element.title = url;
+    if (title) {
+      element.title = url;
+      // set the url on the element's dataset which we use to check 3rd party origins
+      element.dataset.url = url;
+    }
     return element;
   }
 
@@ -331,7 +335,7 @@ class DetailsRenderer {
 
   /**
    * @param {LH.Audit.Details.List} details
-   * @returns {Element}
+   * @return {Element}
    */
   _renderList(details) {
     const listContainer = this._dom.createElement('div', 'lh-list');
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 0ae9517ee..4144966f5 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -16,15 +16,25 @@
  */
 'use strict';
 
+/* eslint-env browser */
+
 /**
  * @fileoverview Adds export button, print, and other dynamic functionality to
  * the report.
  */
 
-/* globals self URL Blob CustomEvent getFilenamePrefix window */
+/* globals getFilenamePrefix Util */
 
 /** @typedef {import('./dom.js')} DOM */
 
+/**
+ * @param {HTMLTableElement} tableEl
+ * @return {Array<HTMLTableRowElement>}
+ */
+function getTableRows(tableEl) {
+  return Array.from(tableEl.tBodies[0].rows);
+}
+
 class ReportUIFeatures {
   /**
    * @param {DOM} dom
@@ -74,6 +84,7 @@ class ReportUIFeatures {
     this._setupMediaQueryListeners();
     this._setupSmoothScroll();
     this._setupExportButton();
+    this._setupThirdPartyFilter();
     this._setupStickyHeaderElements();
     this._setUpCollapseDetailsAfterPrinting();
     this._resetUIState();
@@ -131,6 +142,92 @@ class ReportUIFeatures {
     dropdown.addEventListener('click', this.onExport);
   }
 
+  _setupThirdPartyFilter() {
+    // Some audits should not display the third party filter option.
+    const thirdPartyFilterAuditExclusions = [
+      // This audit deals explicitly with third party resources.
+      'uses-rel-preconnect',
+    ];
+
+    // Get all tables with a text url column.
+    /** @type {Array<HTMLTableElement>} */
+    const tables = Array.from(this._document.querySelectorAll('.lh-table'));
+    const tablesWithUrls = tables
+      .filter(el => el.querySelector('td.lh-table-column--url'))
+      .filter(el => {
+        const containingAudit = el.closest('.lh-audit');
+        if (!containingAudit) throw new Error('.lh-table not within audit');
+        return !thirdPartyFilterAuditExclusions.includes(containingAudit.id);
+      });
+
+    tablesWithUrls.forEach((tableEl, index) => {
+      const thirdPartyRows = this._getThirdPartyRows(tableEl, this.json.finalUrl);
+      // No 3rd parties, no checkbox!
+      if (!thirdPartyRows.size) return;
+
+      // create input box
+      const filterTemplate = this._dom.cloneTemplate('#tmpl-lh-3p-filter', this._document);
+      const filterInput = this._dom.find('input', filterTemplate);
+      const id = `lh-3p-filter-label--${index}`;
+
+      filterInput.id = id;
+      filterInput.addEventListener('change', e => {
+        // Remove rows from the dom and keep track of them to readd on uncheck.
+        // Why removing instead of hiding? To keep nth-child(even) background-colors working.
+        if (e.target instanceof HTMLInputElement && !e.target.checked) {
+          for (const row of thirdPartyRows.values()) {
+            row.remove();
+          }
+        } else {
+          // Add row elements back to original positions.
+          for (const [position, row] of thirdPartyRows.entries()) {
+            const childrenArr = getTableRows(tableEl);
+            tableEl.tBodies[0].insertBefore(row, childrenArr[position]);
+          }
+        }
+      });
+
+      this._dom.find('label', filterTemplate).setAttribute('for', id);
+      this._dom.find('.lh-3p-filter-count', filterTemplate).textContent =
+          `${thirdPartyRows.size}`;
+      this._dom.find('.lh-3p-ui-string', filterTemplate).textContent =
+          Util.UIStrings.thirdPartyResourcesLabel;
+
+      // Finally, add checkbox to the DOM.
+      if (!tableEl.parentNode) return; // Keep tsc happy.
+      tableEl.parentNode.insertBefore(filterTemplate, tableEl);
+    });
+  }
+
+  /**
+   * From a table with URL entries, finds the rows containing third-party URLs
+   * and returns a Map of those rows, mapping from row index to row Element.
+   * @param {HTMLTableElement} el
+   * @param {string} finalUrl
+   * @return {Map<number, HTMLTableRowElement>}
+   */
+  _getThirdPartyRows(el, finalUrl) {
+    const urlItems = this._dom.findAll('.lh-text__url', el);
+    const finalUrlRootDomain = Util.getRootDomain(finalUrl);
+
+    /** @type {Map<number, HTMLTableRowElement>} */
+    const thirdPartyRows = new Map();
+    for (const urlItem of urlItems) {
+      const datasetUrl = urlItem.dataset.url;
+      if (!datasetUrl) continue;
+      const isThirdParty = Util.getRootDomain(datasetUrl) !== finalUrlRootDomain;
+      if (!isThirdParty) continue;
+
+      const urlRowEl = urlItem.closest('tr');
+      if (urlRowEl) {
+        const rowPosition = getTableRows(el).indexOf(urlRowEl);
+        thirdPartyRows.set(rowPosition, urlRowEl);
+      }
+    }
+
+    return thirdPartyRows;
+  }
+
   _setupStickyHeaderElements() {
     this.topbarEl = this._dom.find('.lh-topbar', this._document);
     this.scoreScaleEl = this._dom.find('.lh-scorescale', this._document);
diff --git a/lighthouse-core/report/html/renderer/snippet-renderer.js b/lighthouse-core/report/html/renderer/snippet-renderer.js
index 1a627b5a0..66fd015f4 100644
--- a/lighthouse-core/report/html/renderer/snippet-renderer.js
+++ b/lighthouse-core/report/html/renderer/snippet-renderer.js
@@ -69,7 +69,7 @@ function getMessagesForLineNumber(messages, lineNumber) {
 
 /**
  * @param {LH.Audit.Details.SnippetValue} details
- * @returns {LH.Audit.Details.SnippetValue['lines']}
+ * @return {LH.Audit.Details.SnippetValue['lines']}
  */
 function getLinesWhenCollapsed(details) {
   const SURROUNDING_LINES_TO_SHOW_WHEN_COLLAPSED = 2;
@@ -230,7 +230,7 @@ class SnippetRenderer {
    * @param {DOM} dom
    * @param {DocumentFragment} tmpl
    * @param {LH.Audit.Details.SnippetValue} details
-   * @returns {DocumentFragment}
+   * @return {DocumentFragment}
    */
   static renderSnippetLines(dom, tmpl, details) {
     const {lineMessages, generalMessages, lineCount, lines} = details;
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index f0dd4e25c..9b4036e95 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -16,7 +16,7 @@
  */
 'use strict';
 
-/* globals self URL */
+/* globals self, URL */
 
 const ELLIPSIS = '\u2026';
 const NBSP = '\xa0';
@@ -29,6 +29,14 @@ const RATINGS = {
   ERROR: {label: 'error'},
 };
 
+// 25 most used tld plus one domains (aka public suffixes) from http archive.
+// @see https://github.com/GoogleChrome/lighthouse/pull/5065#discussion_r191926212
+// The canonical list is https://publicsuffix.org/learn/ but we're only using subset to conserve bytes
+const listOfTlds = [
+  'com', 'co', 'gov', 'edu', 'ac', 'org', 'go', 'gob', 'or', 'net', 'in', 'ne', 'nic', 'gouv',
+  'web', 'spb', 'blog', 'jus', 'kiev', 'mil', 'wi', 'qc', 'ca', 'bel', 'on',
+];
+
 class Util {
   static get PASS_THRESHOLD() {
     return PASS_THRESHOLD;
@@ -336,6 +344,51 @@ class Util {
     };
   }
 
+  /**
+   * @param {string|URL} value
+   * @return {URL}
+   */
+  static createOrReturnURL(value) {
+    if (value instanceof URL) {
+      return value;
+    }
+
+    return new URL(value);
+  }
+
+  /**
+   * Gets the tld of a domain
+   *
+   * @param {string} hostname
+   * @return {string} tld
+   */
+  static getTld(hostname) {
+    const tlds = hostname.split('.').slice(-2);
+
+    if (!listOfTlds.includes(tlds[0])) {
+      return `.${tlds[tlds.length - 1]}`;
+    }
+
+    return `.${tlds.join('.')}`;
+  }
+
+  /**
+   * Returns a primary domain for provided hostname (e.g. www.example.com -> example.com).
+   * @param {string|URL} url hostname or URL object
+   * @returns {string}
+   */
+  static getRootDomain(url) {
+    const hostname = Util.createOrReturnURL(url).hostname;
+    const tld = Util.getTld(hostname);
+
+    // tld is .com or .co.uk which means we means that length is 1 to big
+    // .com => 2 & .co.uk => 3
+    const splitTld = tld.split('.');
+
+    // get TLD + root domain
+    return hostname.split('.').slice(-splitTld.length).join('.');
+  }
+
   /**
    * @param {LH.Config.Settings} settings
    * @return {Array<{name: string, description: string}>}
@@ -524,6 +577,9 @@ Util.UIStrings = {
   lsPerformanceCategoryDescription: '[Lighthouse](https://developers.google.com/web/tools/lighthouse/) analysis of the current page on an emulated mobile network. Values are estimated and may vary.',
   /** Title of the lab data section of the Performance category. Within this section are various speed metrics which quantify the pageload performance into values presented in seconds and milliseconds. "Lab" is an abbreviated form of "laboratory", and refers to the fact that the data is from a controlled test of a website, not measurements from real users visiting that site.  */
   labDataTitle: 'Lab Data',
+
+  /** This label is for a checkbox above a table of items loaded by a web page. The checkbox is used to show or hide third-party (or "3rd-party") resources in the table, where "third-party resources" refers to items loaded by a web page from URLs that aren't controlled by the owner of the web page. */
+  thirdPartyResourcesLabel: 'Show 3rd-party resources',
 };
 
 if (typeof module !== 'undefined' && module.exports) {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 4a96c8651..7306d0621 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -426,7 +426,6 @@ limitations under the License.
     }
   </style>
 
-
   <div class="lh-header-container">
     <div class="lh-scores-wrapper-placeholder"></div>
   </div>
@@ -601,7 +600,7 @@ limitations under the License.
       align-items: center;
       flex-direction: column;
       text-decoration: none;
-      
+
       --circle-border-width: 8;
       --transition-length: 1s;
 
@@ -618,7 +617,7 @@ limitations under the License.
       color: var(--body-text-color);
     }
 
-    /* TODO(#8185) use more BEM (.lh-gauge__label--big) instead of relying on descendent selector */
+    /* TODO(#8185) use more BEM (.lh-gauge__label--big) instead of relying on descendant selector */
     .lh-category .lh-gauge__label {
       --score-title-font-size: var(--score-title-font-size-big);
       --score-title-line-height: var(--score-title-line-height-big);
@@ -848,6 +847,27 @@ limitations under the License.
   </div>
 </template>
 
+<template id="tmpl-lh-3p-filter">
+  <style>
+    .lh-3p-filter {
+      background: var(--lh-table-higlight-bg);
+      color: var(--medium-75-gray);
+      float: right;
+      padding: 6px;
+    }
+    .lh-3p-filter-label, .lh-3p-filter-input {
+      cursor: pointer;
+      vertical-align: middle;
+      user-select: none;
+    }
+  </style>
+  <div class="lh-3p-filter">
+    <label class="lh-3p-filter-label">
+      <input type="checkbox" class="lh-3p-filter-input" checked />
+      <span class="lh-3p-ui-string">Show 3rd party resources</span> (<span class="lh-3p-filter-count"></span>)
+    </label>
+  </div>
+</template>
 
 <!-- Lighthouse snippet component -->
 <template id="tmpl-lh-snippet">
@@ -857,7 +877,7 @@ limitations under the License.
             --snippet-highlight-light: #fbf1f2;
             --snippet-highlight-dark: #ffd6d8;
           }
-          
+
          .lh-snippet__header {
           position: relative;
           overflow: hidden;
@@ -908,7 +928,7 @@ limitations under the License.
           background: white;
           white-space: pre;
           display: flex;
-        }        
+        }
         .lh-snippet__line:not(.lh-snippet__line--message):first-child {
           padding-top: 4px;
         }
@@ -982,7 +1002,7 @@ limitations under the License.
             <div class="lh-snippet__line-icon"></div>
             <code></code>
           </div>
-        </template>   
+        </template>
     </div>
   </template>
-  
+
diff --git a/proto/lighthouse-result.proto b/proto/lighthouse-result.proto
index 5eaf33a87..d3d485782 100644
--- a/proto/lighthouse-result.proto
+++ b/proto/lighthouse-result.proto
@@ -356,6 +356,9 @@ message I18n {
 
     // The label for the button to show only a few lines of a snippet
     string snippet_collapse_button_label = 19;
+
+    // This label is for a filter checkbox above a table of items
+    string third_party_resources_label = 20;
   }
 
   // The message holding all formatted strings
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 84813c3df..5c36f363d 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -2096,12 +2096,12 @@
                     }, 
                     {
                         "key": "totalBytes", 
-                        "label": "Size (KB)", 
+                        "label": "Size", 
                         "valueType": "bytes"
                     }, 
                     {
                         "key": "wastedMs", 
-                        "label": "Potential Savings (ms)", 
+                        "label": "Potential Savings", 
                         "valueType": "timespanMs"
                     }
                 ], 
@@ -2338,7 +2338,7 @@
                     {
                         "itemType": "bytes", 
                         "key": "totalBytes", 
-                        "text": "Size (KB)"
+                        "text": "Size"
                     }
                 ], 
                 "items": [
@@ -2418,12 +2418,12 @@
                     }, 
                     {
                         "key": "totalBytes", 
-                        "label": "Size (KB)", 
+                        "label": "Size", 
                         "valueType": "bytes"
                     }, 
                     {
                         "key": "wastedBytes", 
-                        "label": "Potential Savings (KB)", 
+                        "label": "Potential Savings", 
                         "valueType": "bytes"
                     }
                 ], 
@@ -2586,7 +2586,7 @@
                         "granularity": 1.0, 
                         "itemType": "bytes", 
                         "key": "totalBytes", 
-                        "text": "Size (KB)"
+                        "text": "Size"
                     }
                 ], 
                 "items": [
@@ -2659,13 +2659,6 @@
                         "totalBytes": 144.0, 
                         "url": "http://localhost:10200/dobetterweb/empty_module.js?delay=500", 
                         "wastedBytes": 144.0
-                    }, 
-                    {
-                        "cacheHitProbability": 0.0, 
-                        "cacheLifetimeMs": 0.0, 
-                        "totalBytes": 0.0, 
-                        "url": "blob:http://localhost:10200/ae0eac03-ab9b-4a6a-b299-f5212153e277", 
-                        "wastedBytes": 0.0
                     }
                 ], 
                 "summary": {
@@ -2673,7 +2666,7 @@
                 }, 
                 "type": "table"
             }, 
-            "displayValue": "11 resources found", 
+            "displayValue": "10 resources found", 
             "id": "uses-long-cache-ttl", 
             "numericValue": 103455.0, 
             "score": 0.58, 
@@ -2788,12 +2781,12 @@
                     }, 
                     {
                         "key": "totalBytes", 
-                        "label": "Size (KB)", 
+                        "label": "Size", 
                         "valueType": "bytes"
                     }, 
                     {
                         "key": "wastedBytes", 
-                        "label": "Potential Savings (KB)", 
+                        "label": "Potential Savings", 
                         "valueType": "bytes"
                     }
                 ], 
@@ -2835,12 +2828,12 @@
                     }, 
                     {
                         "key": "totalBytes", 
-                        "label": "Size (KB)", 
+                        "label": "Size", 
                         "valueType": "bytes"
                     }, 
                     {
                         "key": "wastedBytes", 
-                        "label": "Potential Savings (KB)", 
+                        "label": "Potential Savings", 
                         "valueType": "bytes"
                     }
                 ], 
@@ -3650,6 +3643,7 @@
             "passedAuditsGroupTitle": "Passed audits", 
             "snippetCollapseButtonLabel": "Collapse snippet", 
             "snippetExpandButtonLabel": "Expand snippet", 
+            "thirdPartyResourcesLabel": "Show 3rd-party resources", 
             "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:", 
             "varianceDisclaimer": "Values are estimated and may vary.", 
             "warningAuditsGroupTitle": "Passed audits but with warnings", 
diff --git a/types/budget.d.ts b/types/budget.d.ts
index b1da9e8de..fa8632f1b 100644
--- a/types/budget.d.ts
+++ b/types/budget.d.ts
@@ -39,8 +39,8 @@ declare global {
       /** Supported timing metrics. */
       export type TimingMetric = 'first-contentful-paint' | 'first-cpu-idle' | 'interactive' | 'first-meaningful-paint' | 'estimated-input-latency';
 
-      /** Supported resource types. */
-      export type ResourceType = 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'document' | 'other';
+      /** Supported values for the resourceType property of a ResourceBudget. */
+      export type ResourceType = 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'document' | 'other' | 'total' | 'third-party';
     }
   }
 }


Test patch:
diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
index aba0f68d0..4a3787183 100644
--- a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
@@ -24,6 +24,7 @@ function networkRecord(options = {}) {
     statusCode: options.statusCode || 200,
     resourceType: options.resourceType || NetworkRequest.TYPES.Script,
     transferSize: options.transferSize || 10000,
+    protocol: options.protocol || 'http/1.1',
     responseHeaders: headers,
   };
 }
@@ -207,7 +208,8 @@ describe('Cache headers audit', () => {
     const networkRecords = [
       networkRecord({statusCode: 500}),
       networkRecord({url: 'https://example.com/dynamic.js?userId=crazy', transferSize: 10}),
-      networkRecord({url: 'data:image/jpeg;base64,what'}),
+      networkRecord({url: 'data:image/jpeg;base64,what', protocol: 'data'}),
+      networkRecord({url: 'blob:http://example.com/ca6df701-9c67-49fd-a787', protocol: 'blob'}),
       networkRecord({resourceType: NetworkRequest.TYPES.XHR}),
     ];
 
diff --git a/lighthouse-core/test/computed/resource-summary-test.js b/lighthouse-core/test/computed/resource-summary-test.js
new file mode 100644
index 000000000..87e2b98c7
--- /dev/null
+++ b/lighthouse-core/test/computed/resource-summary-test.js
@@ -0,0 +1,97 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const ComputedResourceSummary = require('../../computed/resource-summary.js');
+const assert = require('assert');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
+
+/* eslint-env jest */
+
+function mockArtifacts(networkRecords) {
+  return {
+    devtoolsLog: networkRecordsToDevtoolsLog(networkRecords),
+    URL: {requestedUrl: networkRecords[0].url, finalUrl: networkRecords[0].url},
+  };
+}
+
+describe('Resource summary computed', () => {
+  let artifacts;
+  let context;
+  beforeEach(() => {
+    artifacts = mockArtifacts([
+      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+      {url: 'http://example.com/app.js', resourceType: 'Script', transferSize: 10},
+      {url: 'http://cdn.example.com/script.js', resourceType: 'Script', transferSize: 50},
+      {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70},
+    ]);
+    context = {computedCache: new Map()};
+  });
+
+  it('includes all resource types, regardless of whether page contains them', async () => {
+    const result = await ComputedResourceSummary.request(artifacts, context);
+    assert.equal(Object.keys(result).length, 9);
+  });
+
+  it('sets size and count correctly', async () => {
+    const result = await ComputedResourceSummary.request(artifacts, context);
+    assert.equal(result.script.count, 2);
+    assert.equal(result.script.size, 10 + 50);
+  });
+
+  it('sets "total" resource metrics correctly', async () => {
+    const result = await ComputedResourceSummary.request(artifacts, context);
+    assert.equal(result.total.count, 4);
+    assert.equal(result.total.size, 30 + 10 + 50 + 70);
+  });
+
+  it('sets "other" resource metrics correctly', async () => {
+    // networkRecordsToDevToolsLog errors with an 'other' resource type, so this test does not use it
+    const networkRecords = [
+      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+      {url: 'http://third-party.com/another-file.html', resourceType: 'manifest', transferSize: 50},
+    ];
+
+    const result = ComputedResourceSummary.summarize(networkRecords, networkRecords[0].url);
+    assert.equal(result.other.count, 1);
+    assert.equal(result.other.size, 50);
+  });
+
+  describe('determining third-party resources', () => {
+    it('with a third-party resource', async () => {
+      artifacts = mockArtifacts([
+        {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+        {url: 'http://third-party.com/another-file.html', resourceType: 'Document', transferSize: 50},
+      ]);
+
+      const result = await ComputedResourceSummary.request(artifacts, context);
+      assert.equal(result['third-party'].count, 1);
+      assert.equal(result['third-party'].size, 50);
+    });
+
+    it('with a first-party resource', async () => {
+      artifacts = mockArtifacts([
+        {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+        {url: 'http://example.com/another-file.html', resourceType: 'Document', transferSize: 50},
+      ]);
+
+      const result = await ComputedResourceSummary.request(artifacts, context);
+      assert.equal(result['third-party'].count, 0);
+      assert.equal(result['third-party'].size, 0);
+    });
+
+    it('with a first-party resource loaded from a subdomain', async () => {
+      artifacts = mockArtifacts([
+        {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+        {url: 'http://blog.example.com/file.html', resourceType: 'Document', transferSize: 50},
+      ]);
+
+      const result = await ComputedResourceSummary.request(artifacts, context);
+      assert.equal(result['third-party'].count, 0);
+      assert.equal(result['third-party'].size, 0);
+    });
+  });
+});
diff --git a/lighthouse-core/test/lib/url-shim-test.js b/lighthouse-core/test/lib/url-shim-test.js
index 2247011ea..320841dce 100644
--- a/lighthouse-core/test/lib/url-shim-test.js
+++ b/lighthouse-core/test/lib/url-shim-test.js
@@ -105,15 +105,6 @@ describe('URL Shim', () => {
     assert.equal(URL.getOrigin(urlD), null);
   });
 
-  describe('getTld', () => {
-    it('returns the correct tld', () => {
-      assert.equal(URL.getTld('example.com'), '.com');
-      assert.equal(URL.getTld('example.co.uk'), '.co.uk');
-      assert.equal(URL.getTld('example.com.br'), '.com.br');
-      assert.equal(URL.getTld('example.tokyo.jp'), '.jp');
-    });
-  });
-
   describe('rootDomainsMatch', () => {
     it('matches a subdomain and a root domain', () => {
       const urlA = 'http://example.com/js/test.js';
diff --git a/lighthouse-core/test/report/html/renderer/details-renderer-test.js b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
index edc5e93bb..96b63171a 100644
--- a/lighthouse-core/test/report/html/renderer/details-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
@@ -393,6 +393,7 @@ describe('DetailsRenderer', () => {
 
       assert.equal(urlEl.localName, 'div');
       assert.equal(urlEl.title, urlText);
+      assert.equal(urlEl.dataset.url, urlText);
       assert.ok(urlEl.firstChild.classList.contains('lh-text'));
       assert.equal(urlEl.textContent, displayUrlText);
     });
@@ -417,6 +418,7 @@ describe('DetailsRenderer', () => {
 
       assert.equal(urlEl.localName, 'div');
       assert.equal(urlEl.title, urlText);
+      assert.equal(urlEl.dataset.url, urlText);
       assert.ok(urlEl.firstChild.classList.contains('lh-text'));
       assert.equal(urlEl.textContent, displayUrlText);
     });
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index 4db93baeb..bf47b04d4 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -11,7 +11,6 @@ const assert = require('assert');
 const fs = require('fs');
 const jsdom = require('jsdom');
 const Util = require('../../../../report/html/renderer/util.js');
-const URL = require('../../../../lib/url-shim');
 const DOM = require('../../../../report/html/renderer/dom.js');
 const DetailsRenderer = require('../../../../report/html/renderer/details-renderer.js');
 const ReportUIFeatures = require('../../../../report/html/renderer/report-ui-features.js');
@@ -19,7 +18,7 @@ const CategoryRenderer = require('../../../../report/html/renderer/category-rend
 const CriticalRequestChainRenderer = require(
     '../../../../report/html/renderer/crc-details-renderer.js');
 const ReportRenderer = require('../../../../report/html/renderer/report-renderer.js');
-const sampleResults = require('../../../results/sample_v2.json');
+const sampleResultsOrig = require('../../../results/sample_v2.json');
 
 const TEMPLATE_FILE = fs.readFileSync(__dirname +
     '/../../../../report/html/templates.html', 'utf8');
@@ -29,9 +28,10 @@ const TEMPLATE_FILE_REPORT = fs.readFileSync(__dirname +
 describe('ReportUIFeatures', () => {
   let renderer;
   let reportUIFeatures;
+  let sampleResults;
+  let dom;
 
   beforeAll(() => {
-    global.URL = URL;
     global.Util = Util;
     global.ReportUIFeatures = ReportUIFeatures;
     global.CriticalRequestChainRenderer = CriticalRequestChainRenderer;
@@ -51,8 +51,9 @@ describe('ReportUIFeatures', () => {
       };
     };
 
-    const document = new jsdom.JSDOM(TEMPLATE_FILE);
-    const documentReport = new jsdom.JSDOM(TEMPLATE_FILE_REPORT);
+    const reportWithTemplates = TEMPLATE_FILE_REPORT
+      .replace('%%LIGHTHOUSE_TEMPLATES%%', TEMPLATE_FILE);
+    const document = new jsdom.JSDOM(reportWithTemplates);
     global.self = document.window;
     global.self.matchMedia = function() {
       return {
@@ -60,6 +61,8 @@ describe('ReportUIFeatures', () => {
       };
     };
 
+    global.HTMLInputElement = document.window.HTMLInputElement;
+
     global.window = document.window;
     global.window.getComputedStyle = function() {
       return {
@@ -68,17 +71,16 @@ describe('ReportUIFeatures', () => {
       };
     };
 
-    const dom = new DOM(document.window.document);
-    const dom2 = new DOM(documentReport.window.document);
+    dom = new DOM(document.window.document);
     const detailsRenderer = new DetailsRenderer(dom);
     const categoryRenderer = new CategoryRenderer(dom, detailsRenderer);
     renderer = new ReportRenderer(dom, categoryRenderer);
-    reportUIFeatures = new ReportUIFeatures(dom2);
+    sampleResults = Util.prepareReportResult(sampleResultsOrig);
+    reportUIFeatures = new ReportUIFeatures(dom);
   });
 
   afterAll(() => {
     global.self = undefined;
-    global.URL = undefined;
     global.Util = undefined;
     global.ReportUIFeatures = undefined;
     global.matchMedia = undefined;
@@ -89,17 +91,74 @@ describe('ReportUIFeatures', () => {
     global.PerformanceCategoryRenderer = undefined;
     global.PwaCategoryRenderer = undefined;
     global.window = undefined;
+    global.HTMLInputElement = undefined;
   });
 
-  describe('initFeature', () => {
+  describe('initFeatures', () => {
     it('should init a report', () => {
       // render a report onto the UIFeature dom
-      const container = reportUIFeatures._dom._document.body;
+      const container = reportUIFeatures._dom._document.querySelector('main');
       renderer.renderReport(sampleResults, container);
 
       assert.equal(reportUIFeatures.json, undefined);
       reportUIFeatures.initFeatures(sampleResults);
       assert.ok(reportUIFeatures.json);
     });
+
+    describe('thrid-party filtering', () => {
+      let container;
+
+      beforeAll(() => {
+        const lhr = JSON.parse(JSON.stringify(sampleResults));
+        lhr.requestedUrl = lhr.finalUrl = 'http://www.example.com';
+        const webpAuditItemTemplate = sampleResults.audits['uses-webp-images'].details.items[0];
+        // Interleave first/third party URLs to test restoring order.
+        lhr.audits['uses-webp-images'].details.items = [
+          {
+            ...webpAuditItemTemplate,
+            url: 'http://www.cdn.com/img1.jpg', // Third party, will be filtered.
+          },
+          {
+            ...webpAuditItemTemplate,
+            url: 'http://www.example.com/img2.jpg', // First party, not filtered.
+          },
+          {
+            ...webpAuditItemTemplate,
+            url: 'http://www.notexample.com/img3.jpg', // Third party, will be filtered.
+          },
+        ];
+
+        // render a report onto the UIFeature dom
+        container = dom.find('main', dom._document);
+        renderer.renderReport(lhr, container);
+        reportUIFeatures.initFeatures(lhr);
+      });
+
+      it('filters out third party resources in details tables when checkbox is clicked', () => {
+        const filterCheckbox = dom.find('#uses-webp-images .lh-3p-filter-input', container);
+
+        function getUrlsInTable() {
+          return dom
+            .findAll('#uses-webp-images .lh-details .lh-text__url .lh-text:first-child', container)
+            .map(el => el.textContent);
+        }
+
+        expect(getUrlsInTable()).toEqual(['/img1.jpg', '/img2.jpg', '/img3.jpg']);
+        filterCheckbox.click();
+        expect(getUrlsInTable()).toEqual(['/img2.jpg']);
+        filterCheckbox.click();
+        expect(getUrlsInTable()).toEqual(['/img1.jpg', '/img2.jpg', '/img3.jpg']);
+      });
+
+      it('adds no filter for audits that do not need them', () => {
+        const checkboxClassName = 'lh-3p-filter-input';
+
+        const yesCheckbox = dom.find(`#uses-webp-images .${checkboxClassName}`, container);
+        expect(yesCheckbox).toBeTruthy();
+
+        expect(() => dom.find(`#uses-rel-preconnect .${checkboxClassName}`, container))
+          .toThrowError('query #uses-rel-preconnect .lh-3p-filter-input not found');
+      });
+    });
   });
 });
diff --git a/lighthouse-core/test/report/html/renderer/util-test.js b/lighthouse-core/test/report/html/renderer/util-test.js
index d59a7e7b3..aa5872ce6 100644
--- a/lighthouse-core/test/report/html/renderer/util-test.js
+++ b/lighthouse-core/test/report/html/renderer/util-test.js
@@ -13,6 +13,7 @@ const NBSP = '\xa0';
 
 /* eslint-env jest */
 /* eslint-disable no-console */
+/* global URL */
 
 describe('util helpers', () => {
   let origConsoleWarn;
@@ -182,4 +183,39 @@ describe('util helpers', () => {
       assert.strictEqual(interactiveRef.stackPacks, undefined);
     });
   });
+
+  describe('getTld', () => {
+    it('returns the correct tld', () => {
+      assert.equal(Util.getTld('example.com'), '.com');
+      assert.equal(Util.getTld('example.co.uk'), '.co.uk');
+      assert.equal(Util.getTld('example.com.br'), '.com.br');
+      assert.equal(Util.getTld('example.tokyo.jp'), '.jp');
+    });
+  });
+
+  describe('getRootDomain', () => {
+    it('returns the correct rootDomain from a string', () => {
+      assert.equal(Util.getRootDomain('https://www.example.com/index.html'), 'example.com');
+      assert.equal(Util.getRootDomain('https://example.com'), 'example.com');
+      assert.equal(Util.getRootDomain('https://www.example.co.uk'), 'example.co.uk');
+      assert.equal(Util.getRootDomain('https://example.com.br/app/'), 'example.com.br');
+      assert.equal(Util.getRootDomain('https://example.tokyo.jp'), 'tokyo.jp');
+      assert.equal(Util.getRootDomain('https://sub.example.com'), 'example.com');
+      assert.equal(Util.getRootDomain('https://sub.example.tokyo.jp'), 'tokyo.jp');
+      assert.equal(Util.getRootDomain('http://localhost'), 'localhost');
+      assert.equal(Util.getRootDomain('http://localhost:8080'), 'localhost');
+    });
+
+    it('returns the correct rootDomain from an URL object', () => {
+      assert.equal(Util.getRootDomain(new URL('https://www.example.com/index.html')), 'example.com');
+      assert.equal(Util.getRootDomain(new URL('https://example.com')), 'example.com');
+      assert.equal(Util.getRootDomain(new URL('https://www.example.co.uk')), 'example.co.uk');
+      assert.equal(Util.getRootDomain(new URL('https://example.com.br/app/')), 'example.com.br');
+      assert.equal(Util.getRootDomain(new URL('https://example.tokyo.jp')), 'tokyo.jp');
+      assert.equal(Util.getRootDomain(new URL('https://sub.example.com')), 'example.com');
+      assert.equal(Util.getRootDomain(new URL('https://sub.example.tokyo.jp')), 'tokyo.jp');
+      assert.equal(Util.getRootDomain(new URL('http://localhost')), 'localhost');
+      assert.equal(Util.getRootDomain(new URL('http://localhost:8080')), 'localhost');
+    });
+  });
 });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 0d28987cb..f96b06073 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1910,7 +1910,7 @@
       "score": 0.58,
       "scoreDisplayMode": "numeric",
       "numericValue": 103455,
-      "displayValue": "11 resources found",
+      "displayValue": "10 resources found",
       "details": {
         "type": "table",
         "headings": [
@@ -1928,7 +1928,7 @@
           {
             "key": "totalBytes",
             "itemType": "bytes",
-            "text": "Size (KB)",
+            "text": "Size",
             "displayUnit": "kb",
             "granularity": 1
           }
@@ -2003,13 +2003,6 @@
             "cacheHitProbability": 0,
             "totalBytes": 144,
             "wastedBytes": 144
-          },
-          {
-            "url": "blob:http://localhost:10200/ae0eac03-ab9b-4a6a-b299-f5212153e277",
-            "cacheLifetimeMs": 0,
-            "cacheHitProbability": 0,
-            "totalBytes": 0,
-            "wastedBytes": 0
           }
         ],
         "summary": {
@@ -2036,7 +2029,7 @@
           {
             "key": "totalBytes",
             "itemType": "bytes",
-            "text": "Size (KB)"
+            "text": "Size"
           }
         ],
         "items": [
@@ -2119,12 +2112,12 @@
           {
             "key": "totalBytes",
             "valueType": "bytes",
-            "label": "Size (KB)"
+            "label": "Size"
           },
           {
             "key": "wastedMs",
             "valueType": "timespanMs",
-            "label": "Potential Savings (ms)"
+            "label": "Potential Savings"
           }
         ],
         "items": [
@@ -2193,12 +2186,12 @@
           {
             "key": "totalBytes",
             "valueType": "bytes",
-            "label": "Size (KB)"
+            "label": "Size"
           },
           {
             "key": "wastedBytes",
             "valueType": "bytes",
-            "label": "Potential Savings (KB)"
+            "label": "Potential Savings"
           }
         ],
         "items": [
@@ -2254,12 +2247,12 @@
           {
             "key": "totalBytes",
             "valueType": "bytes",
-            "label": "Size (KB)"
+            "label": "Size"
           },
           {
             "key": "wastedBytes",
             "valueType": "bytes",
-            "label": "Potential Savings (KB)"
+            "label": "Potential Savings"
           }
         ],
         "items": [
@@ -2311,12 +2304,12 @@
           {
             "key": "totalBytes",
             "valueType": "bytes",
-            "label": "Size (KB)"
+            "label": "Size"
           },
           {
             "key": "wastedBytes",
             "valueType": "bytes",
-            "label": "Potential Savings (KB)"
+            "label": "Potential Savings"
           }
         ],
         "items": [
@@ -4631,6 +4624,7 @@
       "passedAuditsGroupTitle": "Passed audits",
       "snippetCollapseButtonLabel": "Collapse snippet",
       "snippetExpandButtonLabel": "Expand snippet",
+      "thirdPartyResourcesLabel": "Show 3rd-party resources",
       "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:",
       "varianceDisclaimer": "Values are estimated and may vary.",
       "warningAuditsGroupTitle": "Passed audits but with warnings",
@@ -5092,7 +5086,7 @@
       "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | displayValue": [
         {
           "values": {
-            "itemCount": 11
+            "itemCount": 10
           },
           "path": "audits[uses-long-cache-ttl].displayValue"
         }
@@ -5142,8 +5136,11 @@
           "path": "audits[render-blocking-resources].displayValue"
         }
       ],
-      "lighthouse-core/lib/i18n/i18n.js | columnWastedMs": [
-        "audits[render-blocking-resources].details.headings[2].label"
+      "lighthouse-core/lib/i18n/i18n.js | columnWastedBytes": [
+        "audits[render-blocking-resources].details.headings[2].label",
+        "audits[unminified-javascript].details.headings[2].label",
+        "audits[uses-webp-images].details.headings[3].label",
+        "audits[uses-text-compression].details.headings[2].label"
       ],
       "lighthouse-core/audits/byte-efficiency/unminified-css.js | title": [
         "audits[unminified-css].title"
@@ -5177,11 +5174,6 @@
           "path": "audits[uses-text-compression].displayValue"
         }
       ],
-      "lighthouse-core/lib/i18n/i18n.js | columnWastedBytes": [
-        "audits[unminified-javascript].details.headings[2].label",
-        "audits[uses-webp-images].details.headings[3].label",
-        "audits[uses-text-compression].details.headings[2].label"
-      ],
       "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | title": [
         "audits[unused-css-rules].title"
       ],