Repository: GoogleChrome/lighthouse
Pull request #9955 (2019-11-10 11:41:43)

report: fix flicker in normal case report
<!-- Thank you for submitting a pull request! -->

**Summary**
<!-- What kind of change does this PR introduce? -->
<!-- Is this a bugfix, feature, refactoring, build related change, etc? -->
Bugfix
<!-- Describe the need for this change -->
change `.lh-sticky-header`'s position `sticky` to `fixed`.

<!-- Link any documentation or information that would help understand this change -->

**Related Issues/PRs**
<!-- Provide any additional information we might need to understand the pull request -->
#9141 

connorjclark: This needs to be sticky for devtools (#9023). Could you add a sticky declaration for `.lh-sticky-header.lh-devtools`, along with a comment? like:

```
/* The report within DevTools is placed in a container with overflow, which changes the placement of this header unless we change `position` to `fixed.` */
```

So we can get this nice fix for the regular report, but further work will be needed to fix this stutter in DevTools.

___

For why this is different in DevTools: the report is within a containing element. Normally, the report is the entire document, so `fixed` works fine in the normal case.

https://stackoverflow.com/a/48355127/2788187
> If it is defined inside some container, it is positioned with respect to that container. If the container has some overflow(scroll), depending on the scroll offset it turns into position:fixed.


yeonjuan: @connorjclark Thanks! i changed it. :)

Patch:
diff --git a/clients/extension/manifest.json b/clients/extension/manifest.json
index ce5b6d2ff..218f3a4c0 100644
--- a/clients/extension/manifest.json
+++ b/clients/extension/manifest.json
@@ -1,6 +1,6 @@
 {
   "name": "__MSG_appName__",
-  "version": "5.6.0",
+  "version": "100.0.0.0",
   "minimum_chrome_version": "72",
   "manifest_version": 2,
   "description": "__MSG_appDescription__",
diff --git a/clients/extension/scripts/popup.js b/clients/extension/scripts/popup.js
index 1c5109678..47c93c482 100644
--- a/clients/extension/scripts/popup.js
+++ b/clients/extension/scripts/popup.js
@@ -88,7 +88,11 @@ function fillDevToolsShortcut() {
   el.textContent = isMac ? '⌘⌥I (Cmd+Opt+I)' : 'F12';
 }
 
-function persistSettings() {
+/**
+ * Create the settings from the state of the options form, save in storage, and return it.
+ * @returns {SettingsController.Settings}
+ */
+function readSettingsFromDomAndPersist() {
   const optionsEl = find('.section--options');
   // Save settings when options page is closed.
   const checkboxes = /** @type {NodeListOf<HTMLInputElement>} */
@@ -96,10 +100,12 @@ function persistSettings() {
   const selectedCategories = Array.from(checkboxes).map(input => input.value);
   const device = /** @type {HTMLInputElement} */ (find('input[name="device"]:checked')).value;
 
-  SettingsController.saveSettings({
+  const settings = {
     selectedCategories,
     device,
-  });
+  };
+  SettingsController.saveSettings(settings);
+  return settings;
 }
 
 /**
@@ -171,7 +177,7 @@ async function initPopup() {
   });
 
   optionsFormEl.addEventListener('change', () => {
-    persistSettings();
+    settings = readSettingsFromDomAndPersist();
   });
 }
 
diff --git a/docs/releasing.md b/docs/releasing.md
index 0044e992a..c1deb4cad 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -137,7 +137,7 @@ npm publish
 # Publish viewer.
 yarn deploy-viewer
 
-# Publish the extension.
+# Publish the extension (if it changed).
 open https://chrome.google.com/webstore/developer/edit/blipmdconlkpinefehnmjammfjpmpbjk
 cd dist/extension-package/
 echo "Upload the package zip to CWS dev dashboard..."
@@ -145,7 +145,7 @@ echo "Upload the package zip to CWS dev dashboard..."
 # Open <https://chrome.google.com/webstore/developer/dashboard>
 # Click _Edit_ on lighthouse
 # _Upload Updated Package_
-# Select `lighthouse-X.X.X.zip`
+# Select `lighthouse-X.X.X.X.zip`
 # _Publish_ at the bottom
 
 # * Tell the world!!! *
diff --git a/lighthouse-core/audits/deprecations.js b/lighthouse-core/audits/deprecations.js
index 381be4c06..9721c71e8 100644
--- a/lighthouse-core/audits/deprecations.js
+++ b/lighthouse-core/audits/deprecations.js
@@ -57,19 +57,33 @@ class Deprecations extends Audit {
     const entries = artifacts.ConsoleMessages;
 
     const deprecations = entries.filter(log => log.entry.source === 'deprecation').map(log => {
+      // HTML deprecations will have no url and no way to attribute to a specific line.
+      /** @type {LH.Audit.Details.SourceLocationValue=} */
+      let source;
+      if (log.entry.url) {
+        // JS deprecations will have a stack trace.
+        // CSS deprecations only expose a line number.
+        const topCallFrame = log.entry.stackTrace && log.entry.stackTrace.callFrames[0];
+        const line = log.entry.lineNumber || 0;
+        const column = topCallFrame ? topCallFrame.columnNumber : 0;
+        source = {
+          type: 'source-location',
+          url: log.entry.url,
+          urlProvider: 'network',
+          line,
+          column,
+        };
+      }
       return {
         value: log.entry.text,
-        url: log.entry.url || '',
-        source: log.entry.source,
-        lineNumber: log.entry.lineNumber,
+        source,
       };
     });
 
     /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
       {key: 'value', itemType: 'text', text: str_(UIStrings.columnDeprecate)},
-      {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)},
-      {key: 'lineNumber', itemType: 'text', text: str_(UIStrings.columnLine)},
+      {key: 'source', itemType: 'source-location', text: str_(i18n.UIStrings.columnURL)},
     ];
     const details = Audit.makeTableDetails(headings, deprecations);
 
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index a6eab51f9..cff60a696 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -7,7 +7,6 @@
 
 /** @typedef {LH.Artifacts.FontSize['analyzedFailingNodesData'][0]} FailingNodeData */
 
-const URL = require('../../lib/url-shim.js');
 const i18n = require('../../lib/i18n/i18n.js');
 const Audit = require('../audit.js');
 const ComputedViewportMeta = require('../../computed/viewport-meta.js');
@@ -119,80 +118,84 @@ function nodeToTableNode(node) {
  * @param {string} baseURL
  * @param {FailingNodeData['cssRule']} styleDeclaration
  * @param {FailingNodeData['node']} node
- * @returns {{source: string, selector: string | {type: 'node', selector: string, snippet: string}}}
+ * @returns {{source: LH.Audit.Details.UrlValue | LH.Audit.Details.SourceLocationValue | LH.Audit.Details.CodeValue, selector: string | LH.Audit.Details.NodeValue}}
  */
 function findStyleRuleSource(baseURL, styleDeclaration, node) {
-  if (
-    !styleDeclaration ||
+  if (!styleDeclaration ||
     styleDeclaration.type === 'Attributes' ||
     styleDeclaration.type === 'Inline'
   ) {
     return {
+      source: {type: 'url', value: baseURL},
       selector: nodeToTableNode(node),
-      source: baseURL,
     };
   }
 
   if (styleDeclaration.parentRule &&
     styleDeclaration.parentRule.origin === 'user-agent') {
     return {
+      source: {type: 'code', value: 'User Agent Stylesheet'},
       selector: styleDeclaration.parentRule.selectors.map(item => item.text).join(', '),
-      source: 'User Agent Stylesheet',
     };
   }
 
-  if (styleDeclaration.type === 'Regular' && styleDeclaration.parentRule) {
+  // Combine all the selectors for the associated style rule
+  // example: .some-selector, .other-selector {...} => `.some-selector, .other-selector`
+  let selector = '';
+  if (styleDeclaration.parentRule) {
     const rule = styleDeclaration.parentRule;
-    const stylesheet = styleDeclaration.stylesheet;
-
-    if (stylesheet) {
-      let source;
-      const selector = rule.selectors.map(item => item.text).join(', ');
-
-      if (stylesheet.sourceURL) {
-        const url = new URL(stylesheet.sourceURL, baseURL);
-        const range = styleDeclaration.range;
-        source = `${url.href}`;
-
-        // !!range == has defined location in a source file (.css or .html)
-        if (range) {
-          let line = range.startLine + 1;
-          let column = range.startColumn;
-
-          // Add the startLine/startColumn of the <style> element to the range, if stylesheet
-          // is inline.
-          // Always use the rule's location if a sourceURL magic comment is
-          // present (`hasSourceURL` is true) - this makes the line/col relative to the start
-          // of the style tag, which makes them relevant when the "file" is open in DevTool's
-          // Sources panel.
-          const addHtmlLocationOffset = stylesheet.isInline && !stylesheet.hasSourceURL;
-          if (addHtmlLocationOffset) {
-            line += stylesheet.startLine;
-            // The column the stylesheet begins on is only relevant if the rule is declared on the same line.
-            if (range.startLine === 0) {
-              column += stylesheet.startColumn;
-            }
-          }
-
-          source += `:${line}:${column}`;
-        }
-      } else {
-        // dynamically injected to page
-        source = 'dynamic';
-      }
+    selector = rule.selectors.map(item => item.text).join(', ');
+  }
 
-      return {
-        selector,
-        source,
-      };
+  if (styleDeclaration.stylesheet && !styleDeclaration.stylesheet.sourceURL) {
+    // Dynamically injected into page.
+    return {
+      source: {type: 'code', value: 'dynamic'},
+      selector,
+    };
+  }
+
+  // !!range == has defined location in a source file (.css or .html)
+  // sourceURL == stylesheet URL || raw value of magic `sourceURL` comment
+  // hasSourceURL == flag that signals sourceURL is the raw value of a magic `sourceURL` comment, *not* a real resource
+  if (styleDeclaration.stylesheet && styleDeclaration.range) {
+    const {range, stylesheet} = styleDeclaration;
+
+    // DevTools protocol does not provide the resource URL if there is a magic `sourceURL` comment.
+    // `sourceURL` will be the raw value of the magic `sourceURL` comment, which likely refers to
+    // a file at build time, not one that is served over the network that we could link to.
+    const urlProvider = stylesheet.hasSourceURL ? 'comment' : 'network';
+
+    let line = range.startLine;
+    let column = range.startColumn;
+
+    // Add the startLine/startColumn of the <style> element to the range, if stylesheet
+    // is inline.
+    // Always use the rule's location if a sourceURL magic comment is
+    // present (`hasSourceURL` is true) - this makes the line/col relative to the start
+    // of the style tag, which makes them relevant when the "file" is open in DevTool's
+    // Sources panel.
+    const addHtmlLocationOffset = stylesheet.isInline && urlProvider !== 'comment';
+    if (addHtmlLocationOffset) {
+      line += stylesheet.startLine;
+      // The column the stylesheet begins on is only relevant if the rule is declared on the same line.
+      if (range.startLine === 0) {
+        column += stylesheet.startColumn;
+      }
     }
+
+    const url = stylesheet.sourceURL;
+    return {
+      source: {type: 'source-location', url, urlProvider, line, column},
+      selector,
+    };
   }
 
   // The responsible style declaration was not captured in the font-size gatherer due to
   // the rate limiting we do in `fetchFailingNodeSourceRules`.
   return {
-    selector: '',
-    source: 'Unknown',
+    selector,
+    source: {type: 'code', value: 'Unknown'},
   };
 }
 
@@ -268,7 +271,7 @@ class FontSize extends Audit {
 
     /** @type {LH.Audit.Details.Table['headings']} */
     const headings = [
-      {key: 'source', itemType: 'url', text: 'Source'},
+      {key: 'source', itemType: 'source-location', text: 'Source'},
       {key: 'selector', itemType: 'code', text: 'Selector'},
       {key: 'coverage', itemType: 'text', text: '% of Page Text'},
       {key: 'fontSize', itemType: 'text', text: 'Font Size'},
@@ -293,7 +296,8 @@ class FontSize extends Audit {
         (failingTextLength - analyzedFailingTextLength) / visitedTextLength * 100;
 
       tableData.push({
-        source: 'Add\'l illegible text',
+        // Overrides default `source-location`
+        source: {type: 'code', value: 'Add\'l illegible text'},
         selector: '',
         coverage: `${percentageOfUnanalyzedFailingText.toFixed(2)}%`,
         fontSize: '< 12px',
@@ -302,7 +306,8 @@ class FontSize extends Audit {
 
     if (percentageOfPassingText > 0) {
       tableData.push({
-        source: 'Legible text',
+        // Overrides default `source-location`
+        source: {type: 'code', value: 'Legible text'},
         selector: '',
         coverage: `${percentageOfPassingText.toFixed(2)}%`,
         fontSize: '≥ 12px',
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 5f5cd7910..e2a506cfe 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -230,6 +230,9 @@ class DetailsRenderer {
         case 'node': {
           return this.renderNode(value);
         }
+        case 'source-location': {
+          return this.renderSourceLocation(value);
+        }
         case 'url': {
           return this.renderTextURL(value.value);
         }
@@ -393,6 +396,36 @@ class DetailsRenderer {
     return element;
   }
 
+  /**
+   * @param {LH.Audit.Details.SourceLocationValue} item
+   * @return {Element|null}
+   * @protected
+   */
+  renderSourceLocation(item) {
+    if (!item.url) {
+      return null;
+    }
+
+    // Lines are shown as one-indexed.
+    const line = item.line + 1;
+    const column = item.column;
+
+    let element;
+    if (item.urlProvider === 'network') {
+      element = this.renderTextURL(item.url);
+      this._dom.find('a', element).textContent += `:${line}:${column}`;
+    } else {
+      element = this._renderText(`${item.url}:${line}:${column} (from sourceURL)`);
+    }
+
+    element.classList.add('lh-source-location');
+    element.setAttribute('data-source-url', item.url);
+    // DevTools expects zero-indexed lines.
+    element.setAttribute('data-source-line', String(item.line));
+    element.setAttribute('data-source-column', String(item.column));
+    return element;
+  }
+
   /**
    * @param {LH.Audit.Details.Filmstrip} details
    * @return {Element}
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index e848b3f7e..1ce201196 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -213,7 +213,8 @@ class ReportUIFeatures {
     /** @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 =>
+        el.querySelector('td.lh-table-column--url, td.lh-table-column--source-location'))
       .filter(el => {
         const containingAudit = el.closest('.lh-audit');
         if (!containingAudit) throw new Error('.lh-table not within audit');
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index e6d40ba60..0ecb30f03 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -1335,6 +1335,7 @@
 
 /* Looks unnecessary, but mostly for keeping the <th>s left-aligned */
 .lh-table-column--text,
+.lh-table-column--source-location,
 .lh-table-column--url,
 /* .lh-table-column--thumbnail, */
 /* .lh-table-column--empty,*/
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 57462c176..6d69febfe 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -170,7 +170,7 @@ limitations under the License.
       --plugin-icon-size: 75%;
       --gauge-wrapper-width: 60px;
       --gauge-percentage-font-size: 13px;
-      position: sticky;
+      position: fixed;
       left: 0;
       right: 0;
       top: var(--topbar-height);
@@ -185,6 +185,11 @@ limitations under the License.
       pointer-events: none;
     }
 
+    .lh-devtools .lh-sticky-header {
+      /* The report within DevTools is placed in a container with overflow, which changes the placement of this header unless we change `position` to `sticky.` */
+      position: sticky;
+    }
+
     .lh-sticky-header--visible {
       display: grid;
       grid-auto-flow: column;
diff --git a/lighthouse-core/scripts/release/prepare-commit.sh b/lighthouse-core/scripts/release/prepare-commit.sh
index 314831f22..52a782994 100755
--- a/lighthouse-core/scripts/release/prepare-commit.sh
+++ b/lighthouse-core/scripts/release/prepare-commit.sh
@@ -40,11 +40,11 @@ git checkout -b "$BRANCH_NAME"
 # Install the dependencies.
 yarn install
 
-# Bump the version in package.json and clients/extension/manifest.json
+# Bump the version in package.json
 NEEDLE="^  \"version\": \"$SEMVER_PATTERN\""
 REPLACEMENT="  \"version\": \"$NEW_VERSION\""
 
-sed -i '' "s/$NEEDLE/$REPLACEMENT/g" package.json clients/extension/manifest.json
+sed -i '' "s/$NEEDLE/$REPLACEMENT/g" package.json
 
 # Update the fixtures with the new version
 yarn update:sample-json
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 3f53b1382..4d1b4bd54 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -456,31 +456,33 @@
                         "text": "Deprecation / Warning"
                     },
                     {
-                        "itemType": "url",
-                        "key": "url",
+                        "itemType": "source-location",
+                        "key": "source",
                         "text": "URL"
-                    },
-                    {
-                        "itemType": "text",
-                        "key": "lineNumber",
-                        "text": "Line"
                     }
                 ],
                 "items": [
                     {
-                        "lineNumber": 322.0,
-                        "source": "deprecation",
-                        "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+                        "source": {
+                            "column": 6.0,
+                            "line": 322.0,
+                            "type": "source-location",
+                            "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+                            "urlProvider": "network"
+                        },
                         "value": "Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the end user's experience. For more help, check https://xhr.spec.whatwg.org/."
                     },
                     {
-                        "lineNumber": 325.0,
-                        "source": "deprecation",
-                        "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+                        "source": {
+                            "column": 9.0,
+                            "line": 325.0,
+                            "type": "source-location",
+                            "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+                            "urlProvider": "network"
+                        },
                         "value": "'window.webkitStorageInfo' is deprecated. Please use 'navigator.webkitTemporaryStorage' or 'navigator.webkitPersistentStorage' instead."
                     },
                     {
-                        "source": "deprecation",
                         "value": "/deep/ combinator is no longer supported in CSS dynamic profile.It is now effectively no-op, acting as if it were a descendant combinator. /deep/ combinator will be removed, and will be invalid at M65. You should remove it. See https://www.chromestatus.com/features/4964279606312960 for more details."
                     }
                 ],
@@ -818,7 +820,7 @@
             "details": {
                 "headings": [
                     {
-                        "itemType": "url",
+                        "itemType": "source-location",
                         "key": "source",
                         "text": "Source"
                     },
@@ -842,7 +844,10 @@
                     {
                         "coverage": "100.00%",
                         "fontSize": "\u2265 12px",
-                        "source": "Legible text"
+                        "source": {
+                            "type": "code",
+                            "value": "Legible text"
+                        }
                     }
                 ],
                 "type": "table"
diff --git a/types/audit-details.d.ts b/types/audit-details.d.ts
index 0a58c1267..6b1820afd 100644
--- a/types/audit-details.d.ts
+++ b/types/audit-details.d.ts
@@ -82,7 +82,7 @@ declare global {
       }
 
       /** Possible types of values found within table items. */
-      type ItemValueTypes = 'bytes' | 'code' | 'link' | 'ms' | 'node' | 'numeric' | 'text' | 'thumbnail' | 'timespanMs' | 'url';
+      type ItemValueTypes = 'bytes' | 'code' | 'link' | 'ms' | 'node' | 'source-location' | 'numeric' | 'text' | 'thumbnail' | 'timespanMs' | 'url';
 
       // TODO(bckenny): unify Table/Opportunity headings and items on next breaking change.
 
@@ -104,7 +104,7 @@ declare global {
 
       export type TableItem = {
         debugData?: DebugData;
-        [p: string]: string | number | boolean | undefined | DebugData | NodeValue | LinkValue | UrlValue | CodeValue;
+        [p: string]: undefined | string | number | boolean | undefined | DebugData | NodeValue | SourceLocationValue | LinkValue | UrlValue | CodeValue;
       }
 
       export interface OpportunityColumnHeading {
@@ -167,6 +167,20 @@ declare global {
         nodeLabel?: string;
       }
 
+      /**
+       * A value used within a details object, intended to be displayed as a URL
+       * encoded with line and column info (url:line:column).
+       */
+      export interface SourceLocationValue {
+        type: 'source-location';
+        /** urls from the network are always valid urls. otherwise, urls come from either a comment or header, and may not be well-formed. */
+        url: string;
+        /** 'network' when the url is the actual, observed resource url. 'comment' when the url comes from a sourceMapURL comment or X-SourceMap header */
+        urlProvider: 'network' | 'comment';
+        line: number;
+        column: number;
+      }
+
       /**
        * A value used within a details object, intended to be displayed as a
        * linkified URL, regardless of the controlling heading's valueType.


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
index 1ecc918b4..1fc36155d 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
@@ -120,32 +120,52 @@ const expectations = [
           details: {
             items: [
               {
-                source: /seo-tester\.html.+:24:12$/,
+                source: {
+                  url: /seo-tester\.html.+$/,
+                  urlProvider: 'network',
+                  line: 23,
+                  column: 12,
+                },
                 selector: '.small',
                 fontSize: '11px',
               },
               {
-                source: /seo-tester\.html.+:28:55$/,
+                source: {
+                  url: /seo-tester\.html.+$/,
+                  urlProvider: 'network',
+                  line: 27,
+                  column: 55,
+                },
                 selector: '.small-2',
                 fontSize: '11px',
               },
               {
-                source: /seo-tester-inline-magic\.css:3:14$/,
+                source: {
+                  url: /seo-tester-inline-magic\.css$/,
+                  urlProvider: 'comment',
+                  line: 2,
+                  column: 14,
+                },
                 selector: '.small-3',
                 fontSize: '6px',
               },
               {
-                source: /seo-tester-styles-magic\.css:3:10$/,
+                source: {
+                  url: /seo-tester-styles-magic\.css$/,
+                  urlProvider: 'comment',
+                  line: 2,
+                  column: 10,
+                },
                 selector: '.small-4',
                 fontSize: '6px',
               },
               {
-                source: 'User Agent Stylesheet',
+                source: {type: 'code', value: 'User Agent Stylesheet'},
                 selector: 'h6',
                 fontSize: '10px',
               },
               {
-                source: /seo-tester\.html.+$/,
+                source: {type: 'url', value: /seo-tester\.html.+$/},
                 selector: {
                   type: 'node',
                   selector: 'body',
@@ -154,7 +174,7 @@ const expectations = [
                 fontSize: '10px',
               },
               {
-                source: /seo-tester\.html.+$/,
+                source: {type: 'url', value: /seo-tester\.html.+$/},
                 selector: {
                   type: 'node',
                   selector: 'font',
@@ -163,7 +183,7 @@ const expectations = [
                 fontSize: '10px',
               },
               {
-                source: /seo-tester\.html.+$/,
+                source: {type: 'url', value: /seo-tester\.html.+$/},
                 selector: {
                   type: 'node',
                   selector: 'body',
@@ -172,7 +192,7 @@ const expectations = [
                 fontSize: '10px',
               },
               {
-                source: 'Legible text',
+                source: {type: 'code', value: 'Legible text'},
                 selector: '',
                 fontSize: '≥ 12px',
               },
diff --git a/lighthouse-core/test/audits/deprecations-test.js b/lighthouse-core/test/audits/deprecations-test.js
index ab51ae8a1..c058482dd 100644
--- a/lighthouse-core/test/audits/deprecations-test.js
+++ b/lighthouse-core/test/audits/deprecations-test.js
@@ -33,8 +33,7 @@ describe('Console deprecations audit', () => {
     assert.equal(auditResult.score, 0);
     expect(auditResult.displayValue).toBeDisplayString('1 warning found');
     assert.equal(auditResult.details.items.length, 1);
-    assert.equal(auditResult.details.items[0].url, '');
-    assert.equal(auditResult.details.items[0].lineNumber, undefined);
+    assert.equal(auditResult.details.items[0].source, null);
   });
 
   it('fails when deprecation messages are found', () => {
@@ -61,7 +60,7 @@ describe('Console deprecations audit', () => {
             source: 'somethingelse',
             lineNumber: 789,
             url: 'http://example3.com',
-            text: 'Not a deprecation message 456',
+            text: 'Not a deprecation message 789',
           },
         },
       ],
@@ -69,7 +68,7 @@ describe('Console deprecations audit', () => {
     assert.equal(auditResult.score, 0);
     expect(auditResult.displayValue).toBeDisplayString('2 warnings found');
     assert.equal(auditResult.details.items.length, 2);
-    assert.equal(auditResult.details.items[0].url, URL);
-    assert.equal(auditResult.details.items[0].lineNumber, 123);
+    assert.equal(auditResult.details.items[0].source.url, URL);
+    assert.equal(auditResult.details.items[0].source.line, 123);
   });
 });
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index 236266153..f0e77bbcc 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -157,7 +157,10 @@ describe('SEO: Font size audit', () => {
     const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 3);
-    assert.equal(auditResult.details.items[1].source, 'Add\'l illegible text');
+    assert.deepEqual(auditResult.details.items[1].source, {
+      type: 'code',
+      value: 'Add\'l illegible text',
+    });
     assert.equal(auditResult.details.items[1].coverage, '40.00%');
     expect(auditResult.displayValue).toBeDisplayString('50% legible text');
   });
@@ -264,7 +267,10 @@ describe('SEO: Font size audit', () => {
       });
 
       expect(auditResult.details.items[0]).toMatchObject({
-        source: URL.finalUrl,
+        source: {
+          type: 'url',
+          value: URL.finalUrl,
+        },
         selector: {
           type: 'node',
           selector: '#my-parent',
@@ -283,7 +289,10 @@ describe('SEO: Font size audit', () => {
       });
 
       expect(auditResult.details.items[0]).toMatchObject({
-        source: URL.finalUrl,
+        source: {
+          type: 'url',
+          value: URL.finalUrl,
+        },
         selector: {
           type: 'node',
           selector: '#my-parent',
@@ -291,5 +300,103 @@ describe('SEO: Font size audit', () => {
         },
       });
     });
+
+    it('to external stylesheet', async () => {
+      const auditResult = await runFontSizeAuditWithSingleFailingStyle({
+        stylesheet: {
+          sourceURL: 'http://www.example.com/styles-1.css',
+        },
+        type: 'Regular',
+        range: {
+          startLine: 50,
+          startColumn: 50,
+        },
+      });
+
+      assert.equal(auditResult.details.items.length, 1);
+      assert.deepEqual(auditResult.details.items[0].source, {
+        type: 'source-location',
+        url: 'http://www.example.com/styles-1.css',
+        urlProvider: 'network',
+        line: 50,
+        column: 50,
+      });
+    });
+
+    it('to inline <style>', async () => {
+      const auditResult = await runFontSizeAuditWithSingleFailingStyle({
+        stylesheet: {
+          sourceURL: 'http://www.example.com',
+          isInline: true,
+          startLine: 5,
+          startColumn: 5,
+        },
+        type: 'Regular',
+        range: {
+          startLine: 10,
+          startColumn: 10,
+        },
+      });
+
+      assert.equal(auditResult.details.items.length, 1);
+      assert.deepEqual(auditResult.details.items[0].source, {
+        type: 'source-location',
+        url: 'http://www.example.com',
+        urlProvider: 'network',
+        line: 15,
+        column: 10,
+      });
+    });
+
+    it('to inline <style> with rule on the same line as <style>', async () => {
+      const auditResult = await runFontSizeAuditWithSingleFailingStyle({
+        stylesheet: {
+          sourceURL: 'http://www.example.com',
+          isInline: true,
+          startLine: 5,
+          startColumn: 5,
+        },
+        type: 'Regular',
+        range: {
+          startLine: 0,
+          startColumn: 10,
+        },
+      });
+
+      assert.equal(auditResult.details.items.length, 1);
+      assert.deepEqual(auditResult.details.items[0].source, {
+        type: 'source-location',
+        url: 'http://www.example.com',
+        urlProvider: 'network',
+        line: 5,
+        column: 15,
+      });
+    });
+
+    it('to inline <style> with magic sourceURL comment', async () => {
+      const auditResult = await runFontSizeAuditWithSingleFailingStyle({
+        stylesheet: {
+          sourceURL: 'something-magical.css',
+          isInline: true,
+          hasSourceURL: true,
+          startLine: 5,
+          startColumn: 5,
+        },
+        type: 'Regular',
+        range: {
+          startLine: 10,
+          startColumn: 10,
+        },
+      });
+
+      assert.equal(auditResult.details.items.length, 1);
+      assert.deepEqual(auditResult.details.items[0].source, {
+        type: 'source-location',
+        url: 'something-magical.css',
+        urlProvider: 'comment',
+        line: 10,
+        column: 10,
+      });
+    });
   });
 });
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 27660ea05..3dd2058b5 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,56 @@ describe('DetailsRenderer', () => {
       assert.equal(nodeEl.getAttribute('data-snippet'), node.snippet);
     });
 
+    it('renders source-location values', () => {
+      const sourceLocation = {
+        type: 'source-location',
+        url: 'https://www.example.com/script.js',
+        urlProvider: 'network',
+        line: 10,
+        column: 5,
+      };
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'source-location', text: 'Heading'}],
+        items: [{content: sourceLocation}],
+      };
+
+      const el = renderer.render(details);
+      const sourceLocationEl = el.querySelector('.lh-source-location');
+      const anchorEl = sourceLocationEl.querySelector('a');
+      assert.strictEqual(sourceLocationEl.localName, 'div');
+      assert.equal(anchorEl.href, 'https://www.example.com/script.js');
+      assert.equal(sourceLocationEl.textContent, '/script.js:11:5(www.example.com)');
+      assert.equal(sourceLocationEl.getAttribute('data-source-url'), sourceLocation.url);
+      assert.equal(sourceLocationEl.getAttribute('data-source-line'), sourceLocation.line);
+      assert.equal(sourceLocationEl.getAttribute('data-source-column'), sourceLocation.column);
+    });
+
+    it('renders source-location values that aren\'t network resources', () => {
+      const sourceLocation = {
+        type: 'source-location',
+        url: 'https://www.example.com/script.js',
+        urlProvider: 'comment',
+        line: 0,
+        column: 0,
+      };
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'source-location', text: 'Heading'}],
+        items: [{content: sourceLocation}],
+      };
+
+      const el = renderer.render(details);
+      const sourceLocationEl = el.querySelector('.lh-source-location');
+      const anchorEl = sourceLocationEl.querySelector('a');
+      assert.ok(!anchorEl);
+      assert.strictEqual(sourceLocationEl.localName, 'div');
+      assert.equal(sourceLocationEl.textContent, 'https://www.example.com/script.js:1:0 (from sourceURL)');
+      assert.equal(sourceLocationEl.getAttribute('data-source-url'), sourceLocation.url);
+      assert.equal(sourceLocationEl.getAttribute('data-source-line'), sourceLocation.line);
+      assert.equal(sourceLocationEl.getAttribute('data-source-column'), sourceLocation.column);
+    });
+
     it('renders text URL values from a string', () => {
       const urlText = 'https://example.com/';
       const displayUrlText = 'https://example.com';
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 5c7c00cb3..c4ec2683b 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -609,33 +609,34 @@
             "text": "Deprecation / Warning"
           },
           {
-            "key": "url",
-            "itemType": "url",
+            "key": "source",
+            "itemType": "source-location",
             "text": "URL"
-          },
-          {
-            "key": "lineNumber",
-            "itemType": "text",
-            "text": "Line"
           }
         ],
         "items": [
           {
             "value": "Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the end user's experience. For more help, check https://xhr.spec.whatwg.org/.",
-            "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
-            "source": "deprecation",
-            "lineNumber": 322
+            "source": {
+              "type": "source-location",
+              "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+              "urlProvider": "network",
+              "line": 322,
+              "column": 6
+            }
           },
           {
             "value": "'window.webkitStorageInfo' is deprecated. Please use 'navigator.webkitTemporaryStorage' or 'navigator.webkitPersistentStorage' instead.",
-            "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
-            "source": "deprecation",
-            "lineNumber": 325
+            "source": {
+              "type": "source-location",
+              "url": "http://localhost:10200/dobetterweb/dbw_tester.html",
+              "urlProvider": "network",
+              "line": 325,
+              "column": 9
+            }
           },
           {
-            "value": "/deep/ combinator is no longer supported in CSS dynamic profile.It is now effectively no-op, acting as if it were a descendant combinator. /deep/ combinator will be removed, and will be invalid at M65. You should remove it. See https://www.chromestatus.com/features/4964279606312960 for more details.",
-            "url": "",
-            "source": "deprecation"
+            "value": "/deep/ combinator is no longer supported in CSS dynamic profile.It is now effectively no-op, acting as if it were a descendant combinator. /deep/ combinator will be removed, and will be invalid at M65. You should remove it. See https://www.chromestatus.com/features/4964279606312960 for more details."
           }
         ]
       }
@@ -3205,7 +3206,7 @@
         "headings": [
           {
             "key": "source",
-            "itemType": "url",
+            "itemType": "source-location",
             "text": "Source"
           },
           {
@@ -3226,7 +3227,10 @@
         ],
         "items": [
           {
-            "source": "Legible text",
+            "source": {
+              "type": "code",
+              "value": "Legible text"
+            },
             "selector": "",
             "coverage": "100.00%",
             "fontSize": "≥ 12px"
@@ -5647,9 +5651,6 @@
       "lighthouse-core/audits/deprecations.js | columnDeprecate": [
         "audits.deprecations.details.headings[0].text"
       ],
-      "lighthouse-core/audits/deprecations.js | columnLine": [
-        "audits.deprecations.details.headings[2].text"
-      ],
       "lighthouse-core/audits/mainthread-work-breakdown.js | title": [
         "audits[mainthread-work-breakdown].title"
       ],