Merge mozilla-central to inbound a=merge on a CLOSED TREE
authorCoroiu Cristina <ccoroiu@mozilla.com>
Fri, 16 Aug 2019 12:57:16 +0300
changeset 488441 83fad6abe38a2584882e29477280b21d8a0c10c1
parent 488385 fcd47d1e1ed53bf0bfe00a811925bd5cd1451609 (current diff)
parent 488440 5d4cbfe103bbc517599231eb33d4f3ebbbcede40 (diff)
child 488442 e3e481bb80ba03abc33aeaa13f218e738a5bdadf
push id113908
push userccoroiu@mozilla.com
push dateFri, 16 Aug 2019 09:57:53 +0000
treeherdermozilla-inbound@83fad6abe38a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge mozilla-central to inbound a=merge on a CLOSED TREE
devtools/client/debugger/src/actions/tests/helpers/threadFront.js
devtools/shared/fronts/inspector/node-picker.js
taskcluster/ci/source-test/shadow-scheduler.yml
taskcluster/taskgraph/optimize/__init__.py
taskcluster/taskgraph/optimize/seta.py
taskcluster/taskgraph/optimize/strategies.py
testing/web-platform/meta/mathml/relations/css-styling/displaystyle-1.html.ini
testing/web-platform/meta/mathml/relations/html5-tree/display-1.html.ini
--- a/browser/components/aboutlogins/content/aboutLogins.css
+++ b/browser/components/aboutlogins/content/aboutLogins.css
@@ -1,15 +1,15 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 body {
   display: grid;
-  grid-template-columns: minmax(320px, max-content) 1fr;
+  grid-template-columns: 320px 1fr;
   grid-template-rows: 75px 1fr;
   grid-template-areas: "header header"
                        "logins login";
   height: 100vh;
 }
 
 header {
   display: flex;
--- a/browser/components/aboutlogins/content/aboutLogins.html
+++ b/browser/components/aboutlogins/content/aboutLogins.html
@@ -99,17 +99,17 @@
       <button class="create-login-button" data-l10n-id="create-login-button"></button>
     </template>
 
     <template id="login-list-item-template">
       <li class="login-list-item" role="option">
         <div class="favicon-wrapper">
           <img class="favicon" src="" alt=""/>
         </div>
-        <div>
+        <div class="labels">
           <span class="title"></span>
           <span class="username"></span>
         </div>
       </li>
     </template>
 
     <template id="login-intro-template">
       <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
--- a/browser/components/aboutlogins/content/components/login-list.css
+++ b/browser/components/aboutlogins/content/components/login-list.css
@@ -102,20 +102,24 @@ ol {
   border-inline-start-color: var(--in-content-border-highlight);
   background-color: var(--in-content-box-background-hover);
 }
 
 .login-list-item.selected .title {
   font-weight: 600;
 }
 
+.labels {
+  flex-grow: 1;
+  overflow: hidden;
+}
+
 .title,
 .username {
   display: block;
-  max-width: 50ch;
   text-overflow: ellipsis;
   overflow: hidden;
 }
 
 .favicon-wrapper {
   height: 16px;
   width: 16px;
   background-image: url("chrome://mozapps/skin/places/defaultFavicon.svg");
--- a/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss
+++ b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss
@@ -33,16 +33,20 @@
 
     &:hover {
       background-color: $grey-90-20;
     }
 
     &:active {
       background-color: $grey-90-30;
     }
+
+    &:focus {
+      box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30;
+    }
   }
 }
 
 [lwt-newtab-brighttext] {
   .secondary {
     background-color: $grey-10-10;
 
     &:hover {
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/user-actions.md
@@ -0,0 +1,141 @@
+# User Actions
+
+A subset of actions are available to messages via fields like `button_action` for snippets, or `primary_action` for CFRs.
+
+## Usage
+
+For snippets, you should add the action type in `button_action` and any additional parameters in `button_action_args. For example:
+
+```json
+{
+  "button_action": "OPEN_ABOUT_PAGE",
+  "button_action_args": "config"
+}
+```
+
+## Available Actions
+
+### `OPEN_APPLICATIONS_MENU`
+
+* args: (none)
+
+Opens the applications menu.
+
+### `OPEN_PRIVATE_BROWSER_WINDOW`
+
+* args: (none)
+
+Opens a new private browsing window.
+
+
+### `OPEN_URL`
+
+* args: `string` (a url)
+
+Opens a given url.
+
+Example:
+
+```json
+{
+  "button_action": "OPEN_URL",
+  "button_action_args": "https://foo.com"
+}
+```
+
+### `OPEN_ABOUT_PAGE`
+
+* args: `string` (a valid about page without the `about:` prefix)
+
+Opens a given about page
+
+Example:
+
+```json
+{
+  "button_action": "OPEN_ABOUT_PAGE",
+  "button_action_args": "config"
+}
+```
+
+### `OPEN_PREFERENCES_PAGE`
+
+* args: `string` (a category accessible via a `#`)
+
+Opens `about:preferences` with an optional category accessible via a `#` in the URL (e.g. `about:preferences#home`).
+
+Example:
+
+```json
+{
+  "button_action": "OPEN_PREFERENCES_PAGE",
+  "button_action_args": "home"
+}
+```
+
+### `SHOW_FIREFOX_ACCOUNTS`
+
+* args: (none)
+
+Opens Firefox accounts sign-up page. Encodes some information that the origin was from snippets by default.
+
+### `PIN_CURRENT_TAB`
+
+* args: (none)
+
+Pins the currently focused tab.
+
+### `ENABLE_FIREFOX_MONITOR`
+
+* args:
+```ts
+{
+  url: string;
+  flowRequestParams: {
+    entrypoint: string;
+    utm_term: string;
+    form_type: string;
+  }
+}
+```
+
+Opens an oauth flow to enable Firefox Monitor at a given `url` and adds Firefox metrics that user given a set of `flowRequestParams`.
+
+### `url`
+
+The URL should start with `https://monitor.firefox.com/oauth/init` and add various metrics tags as search params, including:
+
+* `utm_source`
+* `utm_campaign`
+* `form_type`
+* `entrypoint`
+
+You should verify the values of these search params with whoever is doing the data analysis (e.g. Leif Oines).
+
+### `flowRequestParams`
+
+These params are used by Firefox to add information specific to that individual user to the final oauth URL. You should include:
+
+* `entrypoint`
+* `utm_term`
+* `form_type`
+
+The `entrypoint` and `form_type` values should match the encoded values in your `url`.
+
+You should verify the values with whoever is doing the data analysis (e.g. Leif Oines).
+
+### Example
+
+```json
+{
+  "button_action": "ENABLE_FIREFOX_MONITOR",
+  "button_action_args": {
+     "url": "https://monitor.firefox.com/oauth/init?utm_source=snippets&utm_campaign=monitor-snippet-test&form_type=email&entrypoint=newtab",
+      "flowRequestParams": {
+        "entrypoint": "snippets",
+        "utm_term": "monitor",
+        "form_type": "email"
+      }
+  }
+}
+```
--- a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
@@ -24,16 +24,17 @@
       min-height: 60px;
       border-radius: 4px;
 
       &:hover {
         background-color: var(--newtab-element-hover-color);
 
         .blockButton {
           display: block;
+          opacity: 1;
 
           // larger inset if discovery stream is enabled.
           .ds-outer-wrapper-breakpoint-override & {
             inset-inline-end: -8%;
 
             @media (max-width: 865px) {
               inset-inline-end: 2%;
             }
@@ -100,16 +101,21 @@
     }
   }
 
   .blockButton {
     display: block;
     inset-inline-end: 20px;
     opacity: 1;
     top: 50%;
+
+    &:focus {
+      box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30;
+      border-radius: 2px;
+    }
   }
 
   .title {
     font-size: inherit;
     margin: 0;
   }
 
   .title-inline {
@@ -144,22 +150,27 @@
 
   &.withButton {
     line-height: 20px;
     margin-bottom: 10px;
     min-height: 60px;
     background-color: transparent;
 
     .blockButton {
-      display: none;
+      display: block;
       inset-inline-end: -15%;
-      opacity: 1;
+      opacity: 0;
       margin: auto;
       top: unset;
 
+      &:focus {
+        opacity: 1;
+        box-shadow: none;
+      }
+
       @media (max-width: 1120px) {
         inset-inline-end: 2%;
       }
 
       @media (max-width: 865px) {
         margin-top: 10px;
       }
 
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
@@ -31,16 +31,17 @@ export class CardGrid extends React.Pure
             type={this.props.type}
             context={rec.context}
             sponsor={rec.sponsor}
             dispatch={this.props.dispatch}
             source={rec.domain}
             pocket_id={rec.pocket_id}
             context_type={rec.context_type}
             bookmarkGuid={rec.bookmarkGuid}
+            engagement={rec.engagement}
             cta={rec.cta}
             cta_variant={this.props.cta_variant}
           />
         )
       );
     }
 
     let divisibility = ``;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
@@ -12,62 +12,79 @@ import { DSContextFooter } from "../DSCo
 
 export const DefaultMeta = ({
   source,
   title,
   excerpt,
   context,
   context_type,
   cta,
+  engagement,
 }) => (
   <div className="meta">
     <div className="info-wrap">
       <p className="source clamp">{source}</p>
       <header className="title clamp">{title}</header>
       {excerpt && <p className="excerpt clamp">{excerpt}</p>}
       {cta && (
         <div role="link" className="cta-link icon icon-arrow" tabIndex="0">
           {cta}
         </div>
       )}
     </div>
-    <DSContextFooter context_type={context_type} context={context} />
+    <DSContextFooter
+      context_type={context_type}
+      context={context}
+      engagement={engagement}
+    />
   </div>
 );
 
 export const VariantMeta = ({
   source,
   title,
   excerpt,
   context,
   context_type,
   cta,
+  engagement,
   sponsor,
 }) => (
   <div className="meta">
     <div className="info-wrap">
       <p className="source clamp">
         {sponsor ? sponsor : source}
         {context && ` · Sponsored`}
       </p>
       <header className="title clamp">{title}</header>
       {excerpt && <p className="excerpt clamp">{excerpt}</p>}
     </div>
-    {cta && <button className="button cta-button">{cta}</button>}
+    {context && cta && <button className="button cta-button">{cta}</button>}
     {!context && (
-      <DSContextFooter context_type={context_type} context={context} />
+      <DSContextFooter
+        context_type={context_type}
+        context={context}
+        engagement={engagement}
+      />
     )}
   </div>
 );
 
 export class DSCard extends React.PureComponent {
   constructor(props) {
     super(props);
 
     this.onLinkClick = this.onLinkClick.bind(this);
+    this.setPlaceholderRef = element => {
+      this.placholderElement = element;
+    };
+
+    this.state = {
+      isSeen: false,
+    };
   }
 
   onLinkClick(event) {
     if (this.props.dispatch) {
       this.props.dispatch(
         ac.UserEvent({
           event: "CLICK",
           source: this.props.type.toUpperCase(),
@@ -88,19 +105,55 @@ export class DSCard extends React.PureCo
                 : {}),
             },
           ],
         })
       );
     }
   }
 
+  onSeen(entries) {
+    if (this.state) {
+      const entry = entries.find(e => e.isIntersecting);
+
+      if (entry) {
+        if (this.placholderElement) {
+          this.observer.unobserve(this.placholderElement);
+        }
+
+        // Stop observing since element has been seen
+        this.setState({
+          isSeen: true,
+        });
+      }
+    }
+  }
+
+  componentDidMount() {
+    if (this.placholderElement) {
+      this.observer = new IntersectionObserver(this.onSeen.bind(this));
+      this.observer.observe(this.placholderElement);
+    }
+  }
+
+  componentWillUnmount() {
+    // Remove observer on unmount
+    if (this.observer && this.placholderElement) {
+      this.observer.unobserve(this.placholderElement);
+    }
+  }
+
   render() {
+    if (this.props.placeholder || !this.state.isSeen) {
+      return (
+        <div className="ds-card placeholder" ref={this.setPlaceholderRef} />
+      );
+    }
     return (
-      <div className={`ds-card${this.props.placeholder ? " placeholder" : ""}`}>
+      <div className="ds-card">
         <SafeAnchor
           className="ds-card-link"
           dispatch={this.props.dispatch}
           onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined}
           url={this.props.url}
         >
           <div className="img-wrapper">
             <DSImage
@@ -111,26 +164,28 @@ export class DSCard extends React.PureCo
           </div>
           {this.props.cta_variant && (
             <VariantMeta
               source={this.props.source}
               title={this.props.title}
               excerpt={this.props.excerpt}
               context={this.props.context}
               context_type={this.props.context_type}
+              engagement={this.props.engagement}
               cta={this.props.cta}
               sponsor={this.props.sponsor}
             />
           )}
           {!this.props.cta_variant && (
             <DefaultMeta
               source={this.props.source}
               title={this.props.title}
               excerpt={this.props.excerpt}
               context={this.props.context}
+              engagement={this.props.engagement}
               context_type={this.props.context_type}
               cta={this.props.cta}
             />
           )}
           <ImpressionStats
             campaignId={this.props.campaignId}
             rows={[
               {
@@ -140,27 +195,25 @@ export class DSCard extends React.PureCo
                   ? { shim: this.props.shim.impression }
                   : {}),
               },
             ]}
             dispatch={this.props.dispatch}
             source={this.props.type}
           />
         </SafeAnchor>
-        {!this.props.placeholder && (
-          <DSLinkMenu
-            id={this.props.id}
-            index={this.props.pos}
-            dispatch={this.props.dispatch}
-            url={this.props.url}
-            title={this.props.title}
-            source={this.props.source}
-            type={this.props.type}
-            pocket_id={this.props.pocket_id}
-            shim={this.props.shim}
-            bookmarkGuid={this.props.bookmarkGuid}
-          />
-        )}
+        <DSLinkMenu
+          id={this.props.id}
+          index={this.props.pos}
+          dispatch={this.props.dispatch}
+          url={this.props.url}
+          title={this.props.title}
+          source={this.props.source}
+          type={this.props.type}
+          pocket_id={this.props.pocket_id}
+          shim={this.props.shim}
+          bookmarkGuid={this.props.bookmarkGuid}
+        />
       </div>
     );
   }
 }
 export const PlaceholderDSCard = props => <DSCard placeholder={true} />;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
@@ -8,24 +8,17 @@
   display: flex;
   flex-direction: column;
   position: relative;
 
   &.placeholder {
     background: transparent;
     box-shadow: inset $inner-box-shadow;
     border-radius: 4px;
-
-    .ds-card-link {
-      cursor: default;
-    }
-
-    .img-wrapper {
-      opacity: 0;
-    }
+    min-height: 300px;
   }
 
   .img-wrapper {
     width: 100%;
   }
 
   .img {
     height: 0;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx
@@ -16,29 +16,33 @@ export const StatusMessage = ({ icon, fl
       className={`story-badge-icon icon icon-${icon}`}
     />
     <div className="story-context-label" data-l10n-id={fluentID} />
   </div>
 );
 
 export class DSContextFooter extends React.PureComponent {
   render() {
-    const { context, context_type } = this.props;
+    const { context, context_type, engagement } = this.props;
     const { icon, fluentID } = cardContextTypes[context_type] || {};
 
     return (
       <div className="story-footer">
         {context && <p className="story-sponsored-label clamp">{context}</p>}
         <TransitionGroup component={null}>
-          {!context && context_type && (
+          {!context && (context_type || engagement) && (
             <CSSTransition
               key={fluentID}
               timeout={ANIMATION_DURATION}
               classNames="story-animate"
             >
-              <StatusMessage icon={icon} fluentID={fluentID} />
+              {engagement && !context_type ? (
+                <div className="story-view-count">{engagement}</div>
+              ) : (
+                <StatusMessage icon={icon} fluentID={fluentID} />
+              )}
             </CSSTransition>
           )}
         </TransitionGroup>
       </div>
     );
   }
 }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss
@@ -3,16 +3,17 @@
 
 .story-footer {
   color: var(--newtab-text-secondary-color);
   inset-inline-start: 0;
   margin-top: 12px;
   position: relative;
 
   .story-sponsored-label,
+  .story-view-count,
   .status-message {
     @include dark-theme-only {
       color: $grey-40;
     }
 
     -webkit-line-clamp: 1;
     font-size: 13px;
     line-height: 24px;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
@@ -73,16 +73,17 @@ export class Hero extends React.PureComp
             pos={rec.pos}
             type={this.props.type}
             dispatch={this.props.dispatch}
             context={rec.context}
             context_type={rec.context_type}
             source={rec.domain}
             pocket_id={rec.pocket_id}
             bookmarkGuid={rec.bookmarkGuid}
+            engagement={rec.engagement}
           />
         )
       );
     }
 
     let heroCard = null;
 
     if (!heroRec || heroRec.placeholder) {
@@ -107,16 +108,17 @@ export class Hero extends React.PureComp
               <div className="header-and-excerpt">
                 <p className="source clamp">{heroRec.domain}</p>
                 <header className="clamp">{heroRec.title}</header>
                 <p className="excerpt clamp">{heroRec.excerpt}</p>
               </div>
               <DSContextFooter
                 context={heroRec.context}
                 context_type={heroRec.context_type}
+                engagement={heroRec.engagement}
               />
             </div>
             <ImpressionStats
               campaignId={heroRec.campaign_id}
               rows={[
                 {
                   id: heroRec.id,
                   pos: heroRec.pos,
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
@@ -40,16 +40,17 @@
     .img-wrapper {
       margin: 0 0 12px;
     }
   }
 
   .ds-card.placeholder {
     margin-bottom: 20px;
     padding-bottom: 20px;
+    min-height: 180px;
   }
 
   .img-wrapper {
     margin: 0 0 12px;
   }
 
   .ds-hero-item {
     position: relative;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
@@ -76,16 +76,17 @@ export class ListItem extends React.Pure
                 <div className="ds-list-item-excerpt clamp">
                   {this.props.excerpt}
                 </div>
               )}
             </div>
             <DSContextFooter
               context={this.props.context}
               context_type={this.props.context_type}
+              engagement={this.props.engagement}
             />
           </div>
           <DSImage
             extraClassNames="ds-list-image"
             source={this.props.image_src}
             rawSource={this.props.raw_image_src}
           />
           <ImpressionStats
@@ -154,16 +155,17 @@ export function _List(props) {
             pos={rec.pos}
             title={rec.title}
             context={rec.context}
             context_type={rec.context_type}
             type={props.type}
             url={rec.url}
             pocket_id={rec.pocket_id}
             bookmarkGuid={rec.bookmarkGuid}
+            engagement={rec.engagement}
           />
         )
       );
     }
 
     const listStyles = [
       "ds-list",
       props.fullWidth ? "ds-list-full-width" : "",
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -1986,17 +1986,18 @@ main {
       box-shadow: none;
       border-radius: 0; }
     .ds-hero .ds-card:not(.placeholder) .meta {
       padding: 0; }
     .ds-hero .ds-card:not(.placeholder) .img-wrapper {
       margin: 0 0 12px; }
   .ds-hero .ds-card.placeholder {
     margin-bottom: 20px;
-    padding-bottom: 20px; }
+    padding-bottom: 20px;
+    min-height: 180px; }
   .ds-hero .img-wrapper {
     margin: 0 0 12px; }
   .ds-hero .ds-hero-item {
     position: relative; }
   .ds-hero .wrapper {
     border-top: 1px solid #D7D7DB;
     color: #737373;
     display: block;
@@ -2630,21 +2631,18 @@ main {
 
 .ds-card {
   display: flex;
   flex-direction: column;
   position: relative; }
   .ds-card.placeholder {
     background: transparent;
     box-shadow: inset 0 0 0 1px var(--newtab-inner-box-shadow-color);
-    border-radius: 4px; }
-    .ds-card.placeholder .ds-card-link {
-      cursor: default; }
-    .ds-card.placeholder .img-wrapper {
-      opacity: 0; }
+    border-radius: 4px;
+    min-height: 300px; }
   .ds-card .img-wrapper {
     width: 100%; }
   .ds-card .img {
     height: 0;
     padding-top: 50%; }
     .ds-card .img img {
       border-radius: 4px;
       box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
@@ -2778,22 +2776,24 @@ main {
       color: #F9F9FA; }
 
 .story-footer {
   color: var(--newtab-text-secondary-color);
   inset-inline-start: 0;
   margin-top: 12px;
   position: relative; }
   .story-footer .story-sponsored-label,
+  .story-footer .story-view-count,
   .story-footer .status-message {
     -webkit-line-clamp: 1;
     font-size: 13px;
     line-height: 24px;
     color: #737373; }
     [lwt-newtab-brighttext] .story-footer .story-sponsored-label, [lwt-newtab-brighttext]
+    .story-footer .story-view-count, [lwt-newtab-brighttext]
     .story-footer .status-message {
       color: #B1B1B3; }
   .story-footer .status-message {
     display: flex;
     align-items: center;
     height: 24px; }
     .story-footer .status-message .story-badge-icon {
       fill: #737373;
@@ -2998,16 +2998,18 @@ main {
     .ASRouterButton.primary:active {
       background-color: #002275; }
   .ASRouterButton.secondary {
     background-color: rgba(12, 12, 13, 0.1); }
     .ASRouterButton.secondary:hover {
       background-color: rgba(12, 12, 13, 0.2); }
     .ASRouterButton.secondary:active {
       background-color: rgba(12, 12, 13, 0.3); }
+    .ASRouterButton.secondary:focus {
+      box-shadow: 0 0 0 1px #0A84FF inset, 0 0 0 1px #0A84FF, 0 0 0 4px rgba(10, 132, 255, 0.3); }
 
 [lwt-newtab-brighttext] .secondary {
   background-color: rgba(249, 249, 250, 0.1); }
   [lwt-newtab-brighttext] .secondary:hover {
     background-color: rgba(249, 249, 250, 0.2); }
   [lwt-newtab-brighttext] .secondary:active {
     background-color: rgba(249, 249, 250, 0.3); }
 
@@ -3308,17 +3310,18 @@ body[lwt-newtab-brighttext] .scene2Icon 
         .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .buttonContainer {
           margin: auto; } }
     .below-search-snippet.withButton .snippet-hover-wrapper {
       min-height: 60px;
       border-radius: 4px; }
       .below-search-snippet.withButton .snippet-hover-wrapper:hover {
         background-color: var(--newtab-element-hover-color); }
         .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
-          display: block; }
+          display: block;
+          opacity: 1; }
           .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
             inset-inline-end: -8%; }
             @media (max-width: 865px) {
               .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
                 inset-inline-end: 2%; } }
 
 .SimpleBelowSearchSnippet {
   background-color: transparent;
@@ -3366,16 +3369,19 @@ body[lwt-newtab-brighttext] .scene2Icon 
       padding: 0;
       text-align: inherit;
       margin: auto; }
   .SimpleBelowSearchSnippet .blockButton {
     display: block;
     inset-inline-end: 20px;
     opacity: 1;
     top: 50%; }
+    .SimpleBelowSearchSnippet .blockButton:focus {
+      box-shadow: 0 0 0 1px #0A84FF inset, 0 0 0 1px #0A84FF, 0 0 0 4px rgba(10, 132, 255, 0.3);
+      border-radius: 2px; }
   .SimpleBelowSearchSnippet .title {
     font-size: inherit;
     margin: 0; }
   .SimpleBelowSearchSnippet .title-inline {
     display: inline; }
   .SimpleBelowSearchSnippet .textContainer {
     margin: 10px;
     margin-inline-start: 0; }
@@ -3392,21 +3398,24 @@ body[lwt-newtab-brighttext] .scene2Icon 
       height: 24px;
       width: 24px; }
   .SimpleBelowSearchSnippet.withButton {
     line-height: 20px;
     margin-bottom: 10px;
     min-height: 60px;
     background-color: transparent; }
     .SimpleBelowSearchSnippet.withButton .blockButton {
-      display: none;
+      display: block;
       inset-inline-end: -15%;
-      opacity: 1;
+      opacity: 0;
       margin: auto;
       top: unset; }
+      .SimpleBelowSearchSnippet.withButton .blockButton:focus {
+        opacity: 1;
+        box-shadow: none; }
       @media (max-width: 1120px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           inset-inline-end: 2%; } }
       @media (max-width: 865px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           margin-top: 10px; } }
       .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
         inset-inline-end: -10%;
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -1989,17 +1989,18 @@ main {
       box-shadow: none;
       border-radius: 0; }
     .ds-hero .ds-card:not(.placeholder) .meta {
       padding: 0; }
     .ds-hero .ds-card:not(.placeholder) .img-wrapper {
       margin: 0 0 12px; }
   .ds-hero .ds-card.placeholder {
     margin-bottom: 20px;
-    padding-bottom: 20px; }
+    padding-bottom: 20px;
+    min-height: 180px; }
   .ds-hero .img-wrapper {
     margin: 0 0 12px; }
   .ds-hero .ds-hero-item {
     position: relative; }
   .ds-hero .wrapper {
     border-top: 1px solid #D7D7DB;
     color: #737373;
     display: block;
@@ -2633,21 +2634,18 @@ main {
 
 .ds-card {
   display: flex;
   flex-direction: column;
   position: relative; }
   .ds-card.placeholder {
     background: transparent;
     box-shadow: inset 0 0 0 1px var(--newtab-inner-box-shadow-color);
-    border-radius: 4px; }
-    .ds-card.placeholder .ds-card-link {
-      cursor: default; }
-    .ds-card.placeholder .img-wrapper {
-      opacity: 0; }
+    border-radius: 4px;
+    min-height: 300px; }
   .ds-card .img-wrapper {
     width: 100%; }
   .ds-card .img {
     height: 0;
     padding-top: 50%; }
     .ds-card .img img {
       border-radius: 4px;
       box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
@@ -2781,22 +2779,24 @@ main {
       color: #F9F9FA; }
 
 .story-footer {
   color: var(--newtab-text-secondary-color);
   inset-inline-start: 0;
   margin-top: 12px;
   position: relative; }
   .story-footer .story-sponsored-label,
+  .story-footer .story-view-count,
   .story-footer .status-message {
     -webkit-line-clamp: 1;
     font-size: 13px;
     line-height: 24px;
     color: #737373; }
     [lwt-newtab-brighttext] .story-footer .story-sponsored-label, [lwt-newtab-brighttext]
+    .story-footer .story-view-count, [lwt-newtab-brighttext]
     .story-footer .status-message {
       color: #B1B1B3; }
   .story-footer .status-message {
     display: flex;
     align-items: center;
     height: 24px; }
     .story-footer .status-message .story-badge-icon {
       fill: #737373;
@@ -3001,16 +3001,18 @@ main {
     .ASRouterButton.primary:active {
       background-color: #002275; }
   .ASRouterButton.secondary {
     background-color: rgba(12, 12, 13, 0.1); }
     .ASRouterButton.secondary:hover {
       background-color: rgba(12, 12, 13, 0.2); }
     .ASRouterButton.secondary:active {
       background-color: rgba(12, 12, 13, 0.3); }
+    .ASRouterButton.secondary:focus {
+      box-shadow: 0 0 0 1px #0A84FF inset, 0 0 0 1px #0A84FF, 0 0 0 4px rgba(10, 132, 255, 0.3); }
 
 [lwt-newtab-brighttext] .secondary {
   background-color: rgba(249, 249, 250, 0.1); }
   [lwt-newtab-brighttext] .secondary:hover {
     background-color: rgba(249, 249, 250, 0.2); }
   [lwt-newtab-brighttext] .secondary:active {
     background-color: rgba(249, 249, 250, 0.3); }
 
@@ -3311,17 +3313,18 @@ body[lwt-newtab-brighttext] .scene2Icon 
         .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .buttonContainer {
           margin: auto; } }
     .below-search-snippet.withButton .snippet-hover-wrapper {
       min-height: 60px;
       border-radius: 4px; }
       .below-search-snippet.withButton .snippet-hover-wrapper:hover {
         background-color: var(--newtab-element-hover-color); }
         .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
-          display: block; }
+          display: block;
+          opacity: 1; }
           .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
             inset-inline-end: -8%; }
             @media (max-width: 865px) {
               .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
                 inset-inline-end: 2%; } }
 
 .SimpleBelowSearchSnippet {
   background-color: transparent;
@@ -3369,16 +3372,19 @@ body[lwt-newtab-brighttext] .scene2Icon 
       padding: 0;
       text-align: inherit;
       margin: auto; }
   .SimpleBelowSearchSnippet .blockButton {
     display: block;
     inset-inline-end: 20px;
     opacity: 1;
     top: 50%; }
+    .SimpleBelowSearchSnippet .blockButton:focus {
+      box-shadow: 0 0 0 1px #0A84FF inset, 0 0 0 1px #0A84FF, 0 0 0 4px rgba(10, 132, 255, 0.3);
+      border-radius: 2px; }
   .SimpleBelowSearchSnippet .title {
     font-size: inherit;
     margin: 0; }
   .SimpleBelowSearchSnippet .title-inline {
     display: inline; }
   .SimpleBelowSearchSnippet .textContainer {
     margin: 10px;
     margin-inline-start: 0; }
@@ -3395,21 +3401,24 @@ body[lwt-newtab-brighttext] .scene2Icon 
       height: 24px;
       width: 24px; }
   .SimpleBelowSearchSnippet.withButton {
     line-height: 20px;
     margin-bottom: 10px;
     min-height: 60px;
     background-color: transparent; }
     .SimpleBelowSearchSnippet.withButton .blockButton {
-      display: none;
+      display: block;
       inset-inline-end: -15%;
-      opacity: 1;
+      opacity: 0;
       margin: auto;
       top: unset; }
+      .SimpleBelowSearchSnippet.withButton .blockButton:focus {
+        opacity: 1;
+        box-shadow: none; }
       @media (max-width: 1120px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           inset-inline-end: 2%; } }
       @media (max-width: 865px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           margin-top: 10px; } }
       .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
         inset-inline-end: -10%;
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -1986,17 +1986,18 @@ main {
       box-shadow: none;
       border-radius: 0; }
     .ds-hero .ds-card:not(.placeholder) .meta {
       padding: 0; }
     .ds-hero .ds-card:not(.placeholder) .img-wrapper {
       margin: 0 0 12px; }
   .ds-hero .ds-card.placeholder {
     margin-bottom: 20px;
-    padding-bottom: 20px; }
+    padding-bottom: 20px;
+    min-height: 180px; }
   .ds-hero .img-wrapper {
     margin: 0 0 12px; }
   .ds-hero .ds-hero-item {
     position: relative; }
   .ds-hero .wrapper {
     border-top: 1px solid #D7D7DB;
     color: #737373;
     display: block;
@@ -2630,21 +2631,18 @@ main {
 
 .ds-card {
   display: flex;
   flex-direction: column;
   position: relative; }
   .ds-card.placeholder {
     background: transparent;
     box-shadow: inset 0 0 0 1px var(--newtab-inner-box-shadow-color);
-    border-radius: 4px; }
-    .ds-card.placeholder .ds-card-link {
-      cursor: default; }
-    .ds-card.placeholder .img-wrapper {
-      opacity: 0; }
+    border-radius: 4px;
+    min-height: 300px; }
   .ds-card .img-wrapper {
     width: 100%; }
   .ds-card .img {
     height: 0;
     padding-top: 50%; }
     .ds-card .img img {
       border-radius: 4px;
       box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
@@ -2778,22 +2776,24 @@ main {
       color: #F9F9FA; }
 
 .story-footer {
   color: var(--newtab-text-secondary-color);
   inset-inline-start: 0;
   margin-top: 12px;
   position: relative; }
   .story-footer .story-sponsored-label,
+  .story-footer .story-view-count,
   .story-footer .status-message {
     -webkit-line-clamp: 1;
     font-size: 13px;
     line-height: 24px;
     color: #737373; }
     [lwt-newtab-brighttext] .story-footer .story-sponsored-label, [lwt-newtab-brighttext]
+    .story-footer .story-view-count, [lwt-newtab-brighttext]
     .story-footer .status-message {
       color: #B1B1B3; }
   .story-footer .status-message {
     display: flex;
     align-items: center;
     height: 24px; }
     .story-footer .status-message .story-badge-icon {
       fill: #737373;
@@ -2998,16 +2998,18 @@ main {
     .ASRouterButton.primary:active {
       background-color: #002275; }
   .ASRouterButton.secondary {
     background-color: rgba(12, 12, 13, 0.1); }
     .ASRouterButton.secondary:hover {
       background-color: rgba(12, 12, 13, 0.2); }
     .ASRouterButton.secondary:active {
       background-color: rgba(12, 12, 13, 0.3); }
+    .ASRouterButton.secondary:focus {
+      box-shadow: 0 0 0 1px #0A84FF inset, 0 0 0 1px #0A84FF, 0 0 0 4px rgba(10, 132, 255, 0.3); }
 
 [lwt-newtab-brighttext] .secondary {
   background-color: rgba(249, 249, 250, 0.1); }
   [lwt-newtab-brighttext] .secondary:hover {
     background-color: rgba(249, 249, 250, 0.2); }
   [lwt-newtab-brighttext] .secondary:active {
     background-color: rgba(249, 249, 250, 0.3); }
 
@@ -3308,17 +3310,18 @@ body[lwt-newtab-brighttext] .scene2Icon 
         .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .buttonContainer {
           margin: auto; } }
     .below-search-snippet.withButton .snippet-hover-wrapper {
       min-height: 60px;
       border-radius: 4px; }
       .below-search-snippet.withButton .snippet-hover-wrapper:hover {
         background-color: var(--newtab-element-hover-color); }
         .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
-          display: block; }
+          display: block;
+          opacity: 1; }
           .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
             inset-inline-end: -8%; }
             @media (max-width: 865px) {
               .ds-outer-wrapper-breakpoint-override .below-search-snippet.withButton .snippet-hover-wrapper:hover .blockButton {
                 inset-inline-end: 2%; } }
 
 .SimpleBelowSearchSnippet {
   background-color: transparent;
@@ -3366,16 +3369,19 @@ body[lwt-newtab-brighttext] .scene2Icon 
       padding: 0;
       text-align: inherit;
       margin: auto; }
   .SimpleBelowSearchSnippet .blockButton {
     display: block;
     inset-inline-end: 20px;
     opacity: 1;
     top: 50%; }
+    .SimpleBelowSearchSnippet .blockButton:focus {
+      box-shadow: 0 0 0 1px #0A84FF inset, 0 0 0 1px #0A84FF, 0 0 0 4px rgba(10, 132, 255, 0.3);
+      border-radius: 2px; }
   .SimpleBelowSearchSnippet .title {
     font-size: inherit;
     margin: 0; }
   .SimpleBelowSearchSnippet .title-inline {
     display: inline; }
   .SimpleBelowSearchSnippet .textContainer {
     margin: 10px;
     margin-inline-start: 0; }
@@ -3392,21 +3398,24 @@ body[lwt-newtab-brighttext] .scene2Icon 
       height: 24px;
       width: 24px; }
   .SimpleBelowSearchSnippet.withButton {
     line-height: 20px;
     margin-bottom: 10px;
     min-height: 60px;
     background-color: transparent; }
     .SimpleBelowSearchSnippet.withButton .blockButton {
-      display: none;
+      display: block;
       inset-inline-end: -15%;
-      opacity: 1;
+      opacity: 0;
       margin: auto;
       top: unset; }
+      .SimpleBelowSearchSnippet.withButton .blockButton:focus {
+        opacity: 1;
+        box-shadow: none; }
       @media (max-width: 1120px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           inset-inline-end: 2%; } }
       @media (max-width: 865px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           margin-top: 10px; } }
       .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
         inset-inline-end: -10%;
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -7890,33 +7890,36 @@ const StatusMessage = ({
 }), external_React_default.a.createElement("div", {
   className: "story-context-label",
   "data-l10n-id": fluentID
 }));
 class DSContextFooter_DSContextFooter extends external_React_default.a.PureComponent {
   render() {
     const {
       context,
-      context_type
+      context_type,
+      engagement
     } = this.props;
     const {
       icon,
       fluentID
     } = types["cardContextTypes"][context_type] || {};
     return external_React_default.a.createElement("div", {
       className: "story-footer"
     }, context && external_React_default.a.createElement("p", {
       className: "story-sponsored-label clamp"
     }, context), external_React_default.a.createElement(external_ReactTransitionGroup_["TransitionGroup"], {
       component: null
-    }, !context && context_type && external_React_default.a.createElement(external_ReactTransitionGroup_["CSSTransition"], {
+    }, !context && (context_type || engagement) && external_React_default.a.createElement(external_ReactTransitionGroup_["CSSTransition"], {
       key: fluentID,
       timeout: ANIMATION_DURATION,
       classNames: "story-animate"
-    }, external_React_default.a.createElement(StatusMessage, {
+    }, engagement && !context_type ? external_React_default.a.createElement("div", {
+      className: "story-view-count"
+    }, engagement) : external_React_default.a.createElement(StatusMessage, {
       icon: icon,
       fluentID: fluentID
     }))));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
 /* This Source Code Form is subject to the terms of the Mozilla Public
@@ -7930,63 +7933,75 @@ class DSContextFooter_DSContextFooter ex
 
 
 const DefaultMeta = ({
   source,
   title,
   excerpt,
   context,
   context_type,
-  cta
+  cta,
+  engagement
 }) => external_React_default.a.createElement("div", {
   className: "meta"
 }, external_React_default.a.createElement("div", {
   className: "info-wrap"
 }, external_React_default.a.createElement("p", {
   className: "source clamp"
 }, source), external_React_default.a.createElement("header", {
   className: "title clamp"
 }, title), excerpt && external_React_default.a.createElement("p", {
   className: "excerpt clamp"
 }, excerpt), cta && external_React_default.a.createElement("div", {
   role: "link",
   className: "cta-link icon icon-arrow",
   tabIndex: "0"
 }, cta)), external_React_default.a.createElement(DSContextFooter_DSContextFooter, {
   context_type: context_type,
-  context: context
+  context: context,
+  engagement: engagement
 }));
 const VariantMeta = ({
   source,
   title,
   excerpt,
   context,
   context_type,
   cta,
+  engagement,
   sponsor
 }) => external_React_default.a.createElement("div", {
   className: "meta"
 }, external_React_default.a.createElement("div", {
   className: "info-wrap"
 }, external_React_default.a.createElement("p", {
   className: "source clamp"
 }, sponsor ? sponsor : source, context && ` · Sponsored`), external_React_default.a.createElement("header", {
   className: "title clamp"
 }, title), excerpt && external_React_default.a.createElement("p", {
   className: "excerpt clamp"
-}, excerpt)), cta && external_React_default.a.createElement("button", {
+}, excerpt)), context && cta && external_React_default.a.createElement("button", {
   className: "button cta-button"
 }, cta), !context && external_React_default.a.createElement(DSContextFooter_DSContextFooter, {
   context_type: context_type,
-  context: context
+  context: context,
+  engagement: engagement
 }));
 class DSCard_DSCard extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onLinkClick = this.onLinkClick.bind(this);
+
+    this.setPlaceholderRef = element => {
+      this.placholderElement = element;
+    };
+
+    this.state = {
+      isSeen: false
+    };
   }
 
   onLinkClick(event) {
     if (this.props.dispatch) {
       this.props.dispatch(Actions["actionCreators"].UserEvent({
         event: "CLICK",
         source: this.props.type.toUpperCase(),
         action_position: this.props.pos
@@ -8000,19 +8015,57 @@ class DSCard_DSCard extends external_Rea
           ...(this.props.shim && this.props.shim.click ? {
             shim: this.props.shim.click
           } : {})
         }]
       }));
     }
   }
 
-  render() {
+  onSeen(entries) {
+    if (this.state) {
+      const entry = entries.find(e => e.isIntersecting);
+
+      if (entry) {
+        if (this.placholderElement) {
+          this.observer.unobserve(this.placholderElement);
+        } // Stop observing since element has been seen
+
+
+        this.setState({
+          isSeen: true
+        });
+      }
+    }
+  }
+
+  componentDidMount() {
+    if (this.placholderElement) {
+      this.observer = new IntersectionObserver(this.onSeen.bind(this));
+      this.observer.observe(this.placholderElement);
+    }
+  }
+
+  componentWillUnmount() {
+    // Remove observer on unmount
+    if (this.observer && this.placholderElement) {
+      this.observer.unobserve(this.placholderElement);
+    }
+  }
+
+  render() {
+    if (this.props.placeholder || !this.state.isSeen) {
+      return external_React_default.a.createElement("div", {
+        className: "ds-card placeholder",
+        ref: this.setPlaceholderRef
+      });
+    }
+
     return external_React_default.a.createElement("div", {
-      className: `ds-card${this.props.placeholder ? " placeholder" : ""}`
+      className: "ds-card"
     }, external_React_default.a.createElement(SafeAnchor_SafeAnchor, {
       className: "ds-card-link",
       dispatch: this.props.dispatch,
       onLinkClick: !this.props.placeholder ? this.onLinkClick : undefined,
       url: this.props.url
     }, external_React_default.a.createElement("div", {
       className: "img-wrapper"
     }, external_React_default.a.createElement(DSImage_DSImage, {
@@ -8020,37 +8073,39 @@ class DSCard_DSCard extends external_Rea
       source: this.props.image_src,
       rawSource: this.props.raw_image_src
     })), this.props.cta_variant && external_React_default.a.createElement(VariantMeta, {
       source: this.props.source,
       title: this.props.title,
       excerpt: this.props.excerpt,
       context: this.props.context,
       context_type: this.props.context_type,
+      engagement: this.props.engagement,
       cta: this.props.cta,
       sponsor: this.props.sponsor
     }), !this.props.cta_variant && external_React_default.a.createElement(DefaultMeta, {
       source: this.props.source,
       title: this.props.title,
       excerpt: this.props.excerpt,
       context: this.props.context,
+      engagement: this.props.engagement,
       context_type: this.props.context_type,
       cta: this.props.cta
     }), external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
       campaignId: this.props.campaignId,
       rows: [{
         id: this.props.id,
         pos: this.props.pos,
         ...(this.props.shim && this.props.shim.impression ? {
           shim: this.props.shim.impression
         } : {})
       }],
       dispatch: this.props.dispatch,
       source: this.props.type
-    })), !this.props.placeholder && external_React_default.a.createElement(DSLinkMenu_DSLinkMenu, {
+    })), external_React_default.a.createElement(DSLinkMenu_DSLinkMenu, {
       id: this.props.id,
       index: this.props.pos,
       dispatch: this.props.dispatch,
       url: this.props.url,
       title: this.props.title,
       source: this.props.source,
       type: this.props.type,
       pocket_id: this.props.pocket_id,
@@ -8187,16 +8242,17 @@ class CardGrid_CardGrid extends external
         type: this.props.type,
         context: rec.context,
         sponsor: rec.sponsor,
         dispatch: this.props.dispatch,
         source: rec.domain,
         pocket_id: rec.pocket_id,
         context_type: rec.context_type,
         bookmarkGuid: rec.bookmarkGuid,
+        engagement: rec.engagement,
         cta: rec.cta,
         cta_variant: this.props.cta_variant
       }));
     }
 
     let divisibility = ``;
 
     if (this.props.items % 4 === 0) {
@@ -8331,17 +8387,18 @@ class List_ListItem extends external_Rea
     }, this.props.domain)), external_React_default.a.createElement("div", {
       className: "ds-list-item-body"
     }, external_React_default.a.createElement("div", {
       className: "ds-list-item-title clamp"
     }, this.props.title), this.props.excerpt && external_React_default.a.createElement("div", {
       className: "ds-list-item-excerpt clamp"
     }, this.props.excerpt)), external_React_default.a.createElement(DSContextFooter_DSContextFooter, {
       context: this.props.context,
-      context_type: this.props.context_type
+      context_type: this.props.context_type,
+      engagement: this.props.engagement
     })), external_React_default.a.createElement(DSImage_DSImage, {
       extraClassNames: "ds-list-image",
       source: this.props.image_src,
       rawSource: this.props.raw_image_src
     }), external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
       campaignId: this.props.campaignId,
       rows: [{
         id: this.props.id,
@@ -8395,17 +8452,18 @@ function _List(props) {
         raw_image_src: rec.raw_image_src,
         pos: rec.pos,
         title: rec.title,
         context: rec.context,
         context_type: rec.context_type,
         type: props.type,
         url: rec.url,
         pocket_id: rec.pocket_id,
-        bookmarkGuid: rec.bookmarkGuid
+        bookmarkGuid: rec.bookmarkGuid,
+        engagement: rec.engagement
       }));
     }
 
     const listStyles = ["ds-list", props.fullWidth ? "ds-list-full-width" : "", props.hasBorders ? "ds-list-borders" : "", props.hasImages ? "ds-list-images" : "", props.hasNumbers ? "ds-list-numbers" : ""];
     return external_React_default.a.createElement("ul", {
       className: listStyles.join(" ")
     }, recMarkup);
   };
@@ -8508,17 +8566,18 @@ class Hero_Hero extends external_React_d
         shim: rec.shim,
         pos: rec.pos,
         type: this.props.type,
         dispatch: this.props.dispatch,
         context: rec.context,
         context_type: rec.context_type,
         source: rec.domain,
         pocket_id: rec.pocket_id,
-        bookmarkGuid: rec.bookmarkGuid
+        bookmarkGuid: rec.bookmarkGuid,
+        engagement: rec.engagement
       }));
     }
 
     let heroCard = null;
 
     if (!heroRec || heroRec.placeholder) {
       heroCard = external_React_default.a.createElement(PlaceholderDSCard, null);
     } else {
@@ -8543,17 +8602,18 @@ class Hero_Hero extends external_React_d
       }, external_React_default.a.createElement("p", {
         className: "source clamp"
       }, heroRec.domain), external_React_default.a.createElement("header", {
         className: "clamp"
       }, heroRec.title), external_React_default.a.createElement("p", {
         className: "excerpt clamp"
       }, heroRec.excerpt)), external_React_default.a.createElement(DSContextFooter_DSContextFooter, {
         context: heroRec.context,
-        context_type: heroRec.context_type
+        context_type: heroRec.context_type,
+        engagement: heroRec.engagement
       })), external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
         campaignId: heroRec.campaign_id,
         rows: [{
           id: heroRec.id,
           pos: heroRec.pos,
           ...(heroRec.shim && heroRec.shim.impression ? {
             shim: heroRec.shim.impression
           } : {})
--- a/browser/components/newtab/lib/OnboardingMessageProvider.jsm
+++ b/browser/components/newtab/lib/OnboardingMessageProvider.jsm
@@ -394,20 +394,19 @@ const ONBOARDING_MESSAGES = () => [
     // Never accessed the FxA panel && doesn't use Firefox sync & has FxA enabled
     targeting: `isFxABadgeEnabled && !hasAccessedFxAPanel && !usesFirefoxSync && isFxAEnabled == true`,
     trigger: { id: "toolbarBadgeUpdate" },
   },
   {
     id: "PROTECTIONS_PANEL_1",
     template: "protections_panel",
     content: {
-      title: "Browse without being followed",
-      body:
-        "Keep your data to yourself. Firefox protects you from many of the most common trackers that follow what you do online.",
-      link_text: "Learn more",
+      title: { string_id: "cfr-protections-panel-header" },
+      body: { string_id: "cfr-protections-panel-body" },
+      link_text: { string_id: "cfr-protections-panel-link-text" },
       cta_url: `${Services.urlFormatter.formatURLPref(
         "app.support.baseURL"
       )}etp-promotions?as=u&utm_source=inproduct`,
     },
     trigger: { id: "protectionsPanelOpen" },
   },
 ];
 
--- a/browser/components/newtab/lib/ToolbarBadgeHub.jsm
+++ b/browser/components/newtab/lib/ToolbarBadgeHub.jsm
@@ -43,16 +43,21 @@ ChromeUtils.defineModuleGetter(
   "setInterval",
   "resource://gre/modules/Timer.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "clearInterval",
   "resource://gre/modules/Timer.jsm"
 );
+ChromeUtils.defineModuleGetter(
+  this,
+  "requestIdleCallback",
+  "resource://gre/modules/Timer.jsm"
+);
 
 // Frequency at which to check for new messages
 const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000;
 let notificationsByWindow = new WeakMap();
 
 class _ToolbarBadgeHub {
   constructor() {
     this.id = "toolbar-badge-hub";
@@ -252,27 +257,27 @@ class _ToolbarBadgeHub {
 
       return toolbarbutton;
     }
 
     return null;
   }
 
   registerBadgeToAllWindows(message) {
-    // Impression should be added when the badge becomes visible
-    this._addImpression(message);
-    // Send a telemetry ping when adding the notification badge
-    this.sendUserEventTelemetry("IMPRESSION", message);
-
     if (message.template === "update_action") {
       this.executeAction({ ...message.content.action, message_id: message.id });
       // No badge to set only an action to execute
       return;
     }
 
+    // Impression should be added when the badge becomes visible
+    this._addImpression(message);
+    // Send a telemetry ping when adding the notification badge
+    this.sendUserEventTelemetry("IMPRESSION", message);
+
     EveryWindow.registerCallback(
       this.id,
       win => {
         if (notificationsByWindow.has(win)) {
           // nothing to do
           return;
         }
         const el = this.addToolbarNotification(win, message);
@@ -292,17 +297,17 @@ class _ToolbarBadgeHub {
     // We need to clear any existing notifications and only show
     // the one set by devtools
     if (options.force) {
       this.removeAllNotifications();
     }
 
     if (message.content.delay) {
       this.state.showBadgeTimeoutId = setTimeout(() => {
-        this.registerBadgeToAllWindows(message);
+        requestIdleCallback(() => this.registerBadgeToAllWindows(message));
       }, message.content.delay);
     } else {
       this.registerBadgeToAllWindows(message);
     }
   }
 
   async messageRequest({ triggerId, template }) {
     const message = await this._handleMessageRequest({
--- a/browser/components/newtab/lib/ToolbarPanelHub.jsm
+++ b/browser/components/newtab/lib/ToolbarPanelHub.jsm
@@ -373,16 +373,18 @@ class _ToolbarPanelHub {
 
   /**
    * Inserts a message into the Protections Panel. The message is visible once
    * and afterwards set in a collapsed state. It can be shown again using the
    * info button in the panel header.
    */
   async insertProtectionPanelMessage(event) {
     const win = event.target.ownerGlobal;
+    this.maybeInsertFTL(win);
+
     const doc = event.target.ownerDocument;
     const container = doc.getElementById("messaging-system-message-container");
     const infoButton = doc.getElementById("protections-popup-info-button");
     const panelContainer = doc.getElementById("protections-popup");
     const toggleMessage = () => {
       container.toggleAttribute("disabled");
       infoButton.toggleAttribute("checked");
     };
--- a/browser/components/newtab/locales-src/asrouter.ftl
+++ b/browser/components/newtab/locales-src/asrouter.ftl
@@ -73,16 +73,22 @@ cfr-doorhanger-pintab-animation-resume =
 
 cfr-doorhanger-bookmark-fxa-header = Sync your bookmarks everywhere.
 cfr-doorhanger-bookmark-fxa-body = Great find! Now don’t be left without this bookmark on your mobile devices. Get Started with a { -fxaccount-brand-name }.
 cfr-doorhanger-bookmark-fxa-link-text = Sync bookmarks now…
 cfr-doorhanger-bookmark-fxa-close-btn-tooltip =
   .aria-label = Close button
   .title = Close
 
+## Protections panel
+
+cfr-protections-panel-header = Browse without being followed
+cfr-protections-panel-body = Keep your data to yourself. { -brand-short-name } protects you from many of the most common trackers that follow what you do online.
+cfr-protections-panel-link-text = Learn more
+
 ## What's New toolbar button and panel
 
 cfr-whatsnew-button =
   .label = What’s New
   .tooltiptext = What’s New
 
 cfr-whatsnew-panel-header = What’s New
 
--- a/browser/components/newtab/locales-src/onboarding.ftl
+++ b/browser/components/newtab/locales-src/onboarding.ftl
@@ -6,17 +6,16 @@
 ### Various strings use a non-breaking space to avoid a single dangling /
 ### widowed word, so test on various window sizes if you also want this.
 
 ## These button action text can be split onto multiple lines, so use explicit
 ## newlines in translations to control where the line break appears (e.g., to
 ## avoid breaking quoted text).
 
 onboarding-button-label-learn-more = Learn More
-onboarding-button-label-try-now = Try It Now
 onboarding-button-label-get-started = Get Started
 
 ## Welcome modal dialog strings
 
 onboarding-welcome-header = Welcome to { -brand-short-name }
 onboarding-welcome-body = You’ve got the browser.<br/>Meet the rest of { -brand-product-name }.
 onboarding-welcome-learn-more = Learn more about the benefits.
 
@@ -71,31 +70,16 @@ onboarding-benefit-privacy-title = True 
 onboarding-benefit-privacy-text = Everything we do honors our Personal Data Promise: Take less. Keep it safe. No secrets.
 
 
 ## These strings belong to the individual onboarding messages.
 
 ## Each message has a title and a description of what the browser feature is.
 ## Each message also has an associated button for the user to try the feature.
 ## The string for the button is found above, in the UI strings section
-onboarding-private-browsing-title = Private Browsing
-onboarding-private-browsing-text = Browse by yourself. Private Browsing with Content Blocking blocks online trackers that follow you around the web.
-
-onboarding-screenshots-title = Screenshots
-onboarding-screenshots-text = Take, save and share screenshots - without leaving { -brand-short-name }. Capture a region or an entire page as you browse. Then save to the web for easy access and sharing.
-
-onboarding-addons-title = Add-ons
-onboarding-addons-text = Add even more features that make { -brand-short-name } work harder for you. Compare prices, check the weather or express your personality with a custom theme.
-
-onboarding-ghostery-title = Ghostery
-onboarding-ghostery-text = Browse faster, smarter, or safer with extensions like Ghostery, which lets you block annoying ads.
-
-# Note: "Sync" in this case is a generic verb, as in "to synchronize"
-onboarding-fxa-title = Sync
-onboarding-fxa-text = Sign up for a { -fxaccount-brand-name } and sync your bookmarks, passwords, and open tabs everywhere you use { -brand-short-name }.
 
 onboarding-tracking-protection-title2 = Protection From Tracking
 onboarding-tracking-protection-text2 = { -brand-short-name } helps stop websites from tracking you online, making it harder for ads to follow you around the web.
 onboarding-tracking-protection-button2 = How it Works
 
 onboarding-data-sync-title = Take Your Settings with You
 # "Sync" is short for synchronize.
 onboarding-data-sync-text2 = Sync your bookmarks, passwords, and more everywhere you use { -brand-product-name }.
--- a/browser/components/newtab/test/unit/asrouter/templates/FirstRun.test.jsx
+++ b/browser/components/newtab/test/unit/asrouter/templates/FirstRun.test.jsx
@@ -11,17 +11,17 @@ import React from "react";
 const FAKE_TRIPLETS = [
   {
     id: "CARD_1",
     content: {
       title: { string_id: "onboarding-private-browsing-title" },
       text: { string_id: "onboarding-private-browsing-text" },
       icon: "icon",
       primary_button: {
-        label: { string_id: "onboarding-button-label-try-now" },
+        label: { string_id: "onboarding-button-label-get-started" },
         action: {
           type: "OPEN_URL",
           data: { args: "https://example.com/" },
         },
       },
     },
   },
 ];
--- a/browser/components/newtab/test/unit/asrouter/templates/OnboardingMessage.test.jsx
+++ b/browser/components/newtab/test/unit/asrouter/templates/OnboardingMessage.test.jsx
@@ -17,17 +17,17 @@ const DEFAULT_CONTENT = {
   },
 };
 
 const L10N_CONTENT = {
   title: { string_id: "onboarding-private-browsing-title" },
   text: { string_id: "onboarding-private-browsing-text" },
   icon: "icon",
   primary_button: {
-    label: { string_id: "onboarding-button-label-try-now" },
+    label: { string_id: "onboarding-button-label-get-started" },
     action: { type: "SOME_TYPE" },
   },
 };
 
 describe("OnboardingMessage", () => {
   let globals;
   let sandbox;
   beforeEach(() => {
--- a/browser/components/newtab/test/unit/asrouter/templates/Trailhead.test.jsx
+++ b/browser/components/newtab/test/unit/asrouter/templates/Trailhead.test.jsx
@@ -6,17 +6,17 @@ import { Trailhead } from "content-src/a
 
 export const CARDS = [
   {
     content: {
       title: { string_id: "onboarding-private-browsing-title" },
       text: { string_id: "onboarding-private-browsing-text" },
       icon: "icon",
       primary_button: {
-        label: { string_id: "onboarding-button-label-try-now" },
+        label: { string_id: "onboarding-button-label-get-started" },
         action: {
           type: "OPEN_URL",
           data: { args: "https://example.com/" },
         },
       },
     },
   },
 ];
--- a/browser/components/newtab/test/unit/asrouter/templates/Triplets.test.jsx
+++ b/browser/components/newtab/test/unit/asrouter/templates/Triplets.test.jsx
@@ -6,17 +6,17 @@ import React from "react";
 const CARDS = [
   {
     id: "CARD_1",
     content: {
       title: { string_id: "onboarding-private-browsing-title" },
       text: { string_id: "onboarding-private-browsing-text" },
       icon: "icon",
       primary_button: {
-        label: { string_id: "onboarding-button-label-try-now" },
+        label: { string_id: "onboarding-button-label-get-started" },
         action: {
           type: "OPEN_URL",
           data: { args: "https://example.com/" },
         },
       },
     },
   },
 ];
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx
@@ -15,16 +15,17 @@ import { SafeAnchor } from "content-src/
 import { shallow, mount } from "enzyme";
 
 describe("<DSCard>", () => {
   let wrapper;
   let sandbox;
 
   beforeEach(() => {
     wrapper = shallow(<DSCard />);
+    wrapper.setState({ isSeen: true });
     sandbox = sinon.createSandbox();
   });
 
   afterEach(() => {
     sandbox.restore();
   });
 
   it("should render", () => {
@@ -74,36 +75,39 @@ describe("<DSCard>", () => {
   });
 
   it("should start with no .active class", () => {
     assert.equal(wrapper.find(".active").length, 0);
   });
 
   it("should render badges for pocket, bookmark when not a spoc element ", () => {
     wrapper = mount(<DSCard context_type="bookmark" />);
+    wrapper.setState({ isSeen: true });
     const contextFooter = wrapper.find(DSContextFooter);
 
     assert.lengthOf(contextFooter.find(StatusMessage), 1);
   });
 
   it("should render Sponsored Context for a spoc element", () => {
     const context = "Sponsored by Foo";
     wrapper = mount(<DSCard context_type="bookmark" context={context} />);
+    wrapper.setState({ isSeen: true });
     const contextFooter = wrapper.find(DSContextFooter);
 
     assert.lengthOf(contextFooter.find(StatusMessage), 0);
     assert.equal(contextFooter.find(".story-sponsored-label").text(), context);
   });
 
   describe("onLinkClick", () => {
     let dispatch;
 
     beforeEach(() => {
       dispatch = sandbox.stub();
       wrapper = shallow(<DSCard dispatch={dispatch} />);
+      wrapper.setState({ isSeen: true });
     });
 
     it("should call dispatch with the correct events", () => {
       wrapper.setProps({ id: "fooidx", pos: 1, type: "foo" });
 
       wrapper.instance().onLinkClick();
 
       assert.calledTwice(dispatch);
@@ -155,16 +159,17 @@ describe("<DSCard>", () => {
         })
       );
     });
   });
 
   describe("DSCard with CTA", () => {
     beforeEach(() => {
       wrapper = mount(<DSCard />);
+      wrapper.setState({ isSeen: true });
     });
 
     it("should render Default Meta", () => {
       const default_meta = wrapper.find(DefaultMeta);
       assert.ok(default_meta.exists());
     });
 
     it("should not render cta-link for item with no cta", () => {
@@ -173,19 +178,29 @@ describe("<DSCard>", () => {
     });
 
     it("should render cta-link by default when item has cta", () => {
       wrapper.setProps({ cta: "test" });
       const meta = wrapper.find(DefaultMeta);
       assert.equal(meta.find(".cta-link").text(), "test");
     });
 
-    it("should render cta-button when item has cta and cta button variant is true", () => {
+    it("should not render cta-button for non spoc content", () => {
       wrapper.setProps({ cta: "test", cta_variant: true });
       const meta = wrapper.find(VariantMeta);
+      assert.lengthOf(meta.find(".cta-button"), 0);
+    });
+
+    it("should render cta-button when item has cta and cta button variant is true and is spoc", () => {
+      wrapper.setProps({
+        cta: "test",
+        cta_variant: true,
+        context: "Sponsored by Foo",
+      });
+      const meta = wrapper.find(VariantMeta);
       assert.equal(meta.find(".cta-button").text(), "test");
     });
 
     it("should not render Sponsored by label in footer for spoc item with cta button variant", () => {
       wrapper.setProps({
         cta: "test",
         context: "Sponsored by test",
         cta_variant: true,
@@ -202,41 +217,79 @@ describe("<DSCard>", () => {
         cta_variant: true,
       });
 
       assert.ok(wrapper.find(VariantMeta).exists());
       const meta = wrapper.find(VariantMeta);
       assert.equal(meta.find(".source").text(), "Test · Sponsored");
     });
   });
+  describe("DSCard with Intersection Observer", () => {
+    beforeEach(() => {
+      wrapper = shallow(<DSCard />);
+    });
+
+    it("should render card when seen", () => {
+      let card = wrapper.find("div.ds-card.placeholder");
+      assert.lengthOf(card, 1);
+
+      wrapper.instance().observer = {
+        unobserve: sandbox.stub(),
+      };
+      wrapper.instance().placholderElement = "element";
+
+      wrapper.instance().onSeen([
+        {
+          isIntersecting: true,
+        },
+      ]);
+
+      assert.isTrue(wrapper.instance().state.isSeen);
+      card = wrapper.find("div.ds-card.placeholder");
+      assert.lengthOf(card, 0);
+      assert.lengthOf(wrapper.find(SafeAnchor), 1);
+      assert.calledOnce(wrapper.instance().observer.unobserve);
+      assert.calledWith(wrapper.instance().observer.unobserve, "element");
+    });
+
+    it("should setup proper placholder ref for isSeen", () => {
+      wrapper.instance().setPlaceholderRef("element");
+      assert.equal(wrapper.instance().placholderElement, "element");
+    });
+
+    it("should setup observer on componentDidMount", () => {
+      wrapper = mount(<DSCard />);
+      assert.isTrue(!!wrapper.instance().observer);
+    });
+  });
 });
 
 describe("<PlaceholderDSCard> component", () => {
   it("should have placeholder prop", () => {
     const wrapper = shallow(<PlaceholderDSCard />);
     const card = wrapper.find(DSCard);
     assert.lengthOf(card, 1);
 
     const placeholder = wrapper.find(DSCard).prop("placeholder");
     assert.isTrue(placeholder);
   });
 
   it("should contain placeholder div", () => {
     const wrapper = shallow(<DSCard placeholder={true} />);
+    wrapper.setState({ isSeen: true });
     const card = wrapper.find("div.ds-card.placeholder");
     assert.lengthOf(card, 1);
   });
 
   it("should not be clickable", () => {
     const wrapper = shallow(<DSCard placeholder={true} />);
+    wrapper.setState({ isSeen: true });
     const anchor = wrapper.find("SafeAnchor.ds-card-link");
-    assert.lengthOf(anchor, 1);
-
-    const linkClick = anchor.prop("onLinkClick");
-    assert.isUndefined(linkClick);
+    assert.lengthOf(anchor, 0);
   });
 
   it("should not have context menu", () => {
     const wrapper = shallow(<DSCard placeholder={true} />);
+    wrapper.setState({ isSeen: true });
     const linkMenu = wrapper.find(DSLinkMenu);
     assert.lengthOf(linkMenu, 0);
   });
 });
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx
@@ -7,43 +7,57 @@ import { mount } from "enzyme";
 import { cardContextTypes } from "content-src/components/Card/types.js";
 
 describe("<DSContextFooter>", () => {
   let wrapper;
   let sandbox;
   const bookmarkBadge = "bookmark";
   const removeBookmarkBadge = "removedBookmark";
   const context = "Sponsored by Babel";
+  const engagement = "Popular";
 
   beforeEach(() => {
     wrapper = mount(<DSContextFooter />);
     sandbox = sinon.createSandbox();
   });
 
   afterEach(() => {
     sandbox.restore();
   });
 
   it("should render", () => {
     assert.isTrue(wrapper.exists());
     assert.isOk(wrapper.find(".story-footer"));
   });
+  it("should render an engagement status if no badge and spoc passed", () => {
+    wrapper = mount(<DSContextFooter engagement={engagement} />);
+
+    const engagementLabel = wrapper.find(".story-view-count");
+    assert.equal(engagementLabel.text(), engagement);
+  });
   it("should render a badge if a proper badge prop is passed", () => {
-    wrapper = mount(<DSContextFooter context_type={bookmarkBadge} />);
+    wrapper = mount(
+      <DSContextFooter context_type={bookmarkBadge} engagement={engagement} />
+    );
     const { fluentID } = cardContextTypes[bookmarkBadge];
 
+    assert.lengthOf(wrapper.find(".story-view-count"), 0);
     const statusLabel = wrapper.find(".story-context-label");
-    assert.isOk(statusLabel);
     assert.equal(statusLabel.prop("data-l10n-id"), fluentID);
   });
   it("should only render a sponsored context if pass a sponsored context", async () => {
     wrapper = mount(
-      <DSContextFooter context_type={bookmarkBadge} context={context} />
+      <DSContextFooter
+        context_type={bookmarkBadge}
+        context={context}
+        engagement={engagement}
+      />
     );
 
+    assert.lengthOf(wrapper.find(".story-view-count"), 0);
     assert.lengthOf(wrapper.find(StatusMessage), 0);
     assert.equal(wrapper.find(".story-sponsored-label").text(), context);
   });
   it("should render a new badge if props change from an old badge to a new one", async () => {
     wrapper = mount(<DSContextFooter context_type={bookmarkBadge} />);
 
     const { fluentID: bookmarkFluentID } = cardContextTypes[bookmarkBadge];
     const bookmarkStatusMessage = wrapper.find(
--- a/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
+++ b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
@@ -18,16 +18,17 @@ describe("ToolbarBadgeHub", () => {
   let clearTimeoutStub;
   let setTimeoutStub;
   let setIntervalStub;
   let addObserverStub;
   let removeObserverStub;
   let getStringPrefStub;
   let clearUserPrefStub;
   let setStringPrefStub;
+  let requestIdleCallbackStub;
   beforeEach(async () => {
     globals = new GlobalOverrider();
     sandbox = sinon.createSandbox();
     instance = new _ToolbarBadgeHub();
     fakeAddImpression = sandbox.stub();
     fakeDispatch = sandbox.stub();
     isBrowserPrivateStub = sandbox.stub();
     const panelTestMsgs = await PanelTestProvider.getMessages();
@@ -62,17 +63,19 @@ describe("ToolbarBadgeHub", () => {
         },
       },
     };
     addObserverStub = sandbox.stub();
     removeObserverStub = sandbox.stub();
     getStringPrefStub = sandbox.stub();
     clearUserPrefStub = sandbox.stub();
     setStringPrefStub = sandbox.stub();
+    requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn());
     globals.set({
+      requestIdleCallback: requestIdleCallbackStub,
       EveryWindow: everyWindowStub,
       PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub },
       setTimeout: setTimeoutStub,
       clearTimeout: clearTimeoutStub,
       setInterval: setIntervalStub,
       Services: {
         wm: {
           getMostRecentWindow: () => fakeWindow,
@@ -557,16 +560,18 @@ describe("ToolbarBadgeHub", () => {
 
       cb();
 
       assert.calledOnce(instance.registerBadgeToAllWindows);
       assert.calledWithExactly(
         instance.registerBadgeToAllWindows,
         msg_with_delay
       );
+      // Delayed actions should be executed inside requestIdleCallback
+      assert.calledOnce(requestIdleCallbackStub);
     });
   });
   describe("#sendUserEventTelemetry", () => {
     beforeEach(async () => {
       await instance.init(sandbox.stub().resolves(), {
         dispatch: fakeDispatch,
       });
     });
--- a/browser/locales/en-US/browser/newtab/asrouter.ftl
+++ b/browser/locales/en-US/browser/newtab/asrouter.ftl
@@ -73,16 +73,22 @@ cfr-doorhanger-pintab-animation-resume =
 
 cfr-doorhanger-bookmark-fxa-header = Sync your bookmarks everywhere.
 cfr-doorhanger-bookmark-fxa-body = Great find! Now don’t be left without this bookmark on your mobile devices. Get Started with a { -fxaccount-brand-name }.
 cfr-doorhanger-bookmark-fxa-link-text = Sync bookmarks now…
 cfr-doorhanger-bookmark-fxa-close-btn-tooltip =
   .aria-label = Close button
   .title = Close
 
+## Protections panel
+
+cfr-protections-panel-header = Browse without being followed
+cfr-protections-panel-body = Keep your data to yourself. { -brand-short-name } protects you from many of the most common trackers that follow what you do online.
+cfr-protections-panel-link-text = Learn more
+
 ## What's New toolbar button and panel
 
 cfr-whatsnew-button =
   .label = What’s New
   .tooltiptext = What’s New
 
 cfr-whatsnew-panel-header = What’s New
 
--- a/browser/locales/en-US/browser/newtab/onboarding.ftl
+++ b/browser/locales/en-US/browser/newtab/onboarding.ftl
@@ -6,17 +6,16 @@
 ### Various strings use a non-breaking space to avoid a single dangling /
 ### widowed word, so test on various window sizes if you also want this.
 
 ## These button action text can be split onto multiple lines, so use explicit
 ## newlines in translations to control where the line break appears (e.g., to
 ## avoid breaking quoted text).
 
 onboarding-button-label-learn-more = Learn More
-onboarding-button-label-try-now = Try It Now
 onboarding-button-label-get-started = Get Started
 
 ## Welcome modal dialog strings
 
 onboarding-welcome-header = Welcome to { -brand-short-name }
 onboarding-welcome-body = You’ve got the browser.<br/>Meet the rest of { -brand-product-name }.
 onboarding-welcome-learn-more = Learn more about the benefits.
 
@@ -71,31 +70,16 @@ onboarding-benefit-privacy-title = True 
 onboarding-benefit-privacy-text = Everything we do honors our Personal Data Promise: Take less. Keep it safe. No secrets.
 
 
 ## These strings belong to the individual onboarding messages.
 
 ## Each message has a title and a description of what the browser feature is.
 ## Each message also has an associated button for the user to try the feature.
 ## The string for the button is found above, in the UI strings section
-onboarding-private-browsing-title = Private Browsing
-onboarding-private-browsing-text = Browse by yourself. Private Browsing with Content Blocking blocks online trackers that follow you around the web.
-
-onboarding-screenshots-title = Screenshots
-onboarding-screenshots-text = Take, save and share screenshots - without leaving { -brand-short-name }. Capture a region or an entire page as you browse. Then save to the web for easy access and sharing.
-
-onboarding-addons-title = Add-ons
-onboarding-addons-text = Add even more features that make { -brand-short-name } work harder for you. Compare prices, check the weather or express your personality with a custom theme.
-
-onboarding-ghostery-title = Ghostery
-onboarding-ghostery-text = Browse faster, smarter, or safer with extensions like Ghostery, which lets you block annoying ads.
-
-# Note: "Sync" in this case is a generic verb, as in "to synchronize"
-onboarding-fxa-title = Sync
-onboarding-fxa-text = Sign up for a { -fxaccount-brand-name } and sync your bookmarks, passwords, and open tabs everywhere you use { -brand-short-name }.
 
 onboarding-tracking-protection-title2 = Protection From Tracking
 onboarding-tracking-protection-text2 = { -brand-short-name } helps stop websites from tracking you online, making it harder for ads to follow you around the web.
 onboarding-tracking-protection-button2 = How it Works
 
 onboarding-data-sync-title = Take Your Settings with You
 # "Sync" is short for synchronize.
 onboarding-data-sync-text2 = Sync your bookmarks, passwords, and more everywhere you use { -brand-product-name }.
--- a/devtools/client/accessibility/accessibility.css
+++ b/devtools/client/accessibility/accessibility.css
@@ -658,17 +658,17 @@ body {
 
 .accessible .tree .objectBox-accessible .accessible-name,
 .accessible .tree .objectBox-node .attrName {
   overflow: hidden;
   text-overflow: ellipsis;
 }
 
 .accessible .tree .objectBox-accessible .open-accessibility-inspector,
-.accessible .tree .objectBox-node .open-inspector{
+.accessible .tree .objectBox-node .open-inspector {
   width: 17px;
   cursor: pointer;
   flex-shrink: 0;
 }
 
 .accessible .tree .objectBox-object,
 .accessible .tree .objectBox-string,
 .accessible .tree .objectBox-text,
@@ -800,13 +800,8 @@ body {
 .accessibility-color-contrast .accessibility-color-contrast-label:after {
   content: ":";
 }
 
 .accessibility-color-contrast .accessibility-color-contrast-label,
 .accessibility-color-contrast .accessibility-color-contrast-separator:before {
   margin-inline-end: 3px;
 }
-
-.accessibility-color-contrast .accessibility-color-contrast-separator:before {
-  content: "-";
-  margin-inline-start: 4px;
-}
--- a/devtools/client/debugger/src/actions/ast/setInScopeLines.js
+++ b/devtools/client/debugger/src/actions/ast/setInScopeLines.js
@@ -27,32 +27,31 @@ function getOutOfScopeLines(outOfScopeLo
   return uniq(
     flatMap(outOfScopeLocations, location =>
       range(location.start.line, location.end.line)
     )
   );
 }
 
 async function getInScopeLines(cx, location, { dispatch, getState, parser }) {
-  const { source, content } = getSourceWithContent(
-    getState(),
-    location.sourceId
-  );
+  const source = getSourceWithContent(getState(), location.sourceId);
 
   let locations = null;
   if (location.line && source && !source.isWasm) {
     locations = await parser.findOutOfScopeLocations(
       source.id,
       ((location: any): parser.AstPosition)
     );
   }
 
   const linesOutOfScope = getOutOfScopeLines(locations);
   const sourceNumLines =
-    !content || !isFulfilled(content) ? 0 : getSourceLineCount(content.value);
+    !source.content || !isFulfilled(source.content)
+      ? 0
+      : getSourceLineCount(source.content.value);
 
   const sourceLines = range(1, sourceNumLines + 1);
 
   return !linesOutOfScope
     ? sourceLines
     : without(sourceLines, ...linesOutOfScope);
 }
 
--- a/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js
+++ b/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js
@@ -16,30 +16,30 @@ import {
 } from "../../../utils/test-head";
 
 const { getInScopeLines } = selectors;
 
 const sourceTexts = {
   "scopes.js": readFixture("scopes.js"),
 };
 
-const threadFront = {
+const mockCommandClient = {
   sourceContents: async ({ source }) => ({
     source: sourceTexts[source],
     contentType: "text/javascript",
   }),
   evaluateExpressions: async () => {},
   getFrameScopes: async () => {},
-  getBreakpointPositions: async () => ({}),
-  getBreakableLines: async () => [],
+  getSourceActorBreakpointPositions: async () => ({}),
+  getSourceActorBreakableLines: async () => [],
 };
 
 describe("getInScopeLine", () => {
   it("with selected line", async () => {
-    const store = createStore(threadFront);
+    const store = createStore(mockCommandClient);
     const { dispatch, getState } = store;
     const source = makeMockSource("scopes.js", "scopes.js");
 
     await dispatch(actions.newGeneratedSource(makeSource("scopes.js")));
 
     await dispatch(
       actions.selectLocation(selectors.getContext(getState()), {
         sourceId: "scopes.js",
--- a/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js
+++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js
@@ -9,18 +9,16 @@ import {
   isGeneratedId,
   originalToGeneratedId,
 } from "devtools-source-map";
 import { uniqBy, zip } from "lodash";
 
 import {
   getSource,
   getSourceFromId,
-  hasBreakpointPositions,
-  hasBreakpointPositionsForLine,
   getBreakpointPositionsForSource,
   getSourceActorsForSource,
 } from "../../selectors";
 
 import type {
   MappedLocation,
   Range,
   SourceLocation,
@@ -28,17 +26,19 @@ import type {
   Context,
 } from "../../types";
 
 import { makeBreakpointId } from "../../utils/breakpoint";
 import {
   memoizeableAction,
   type MemoizedAction,
 } from "../../utils/memoizableAction";
+import { fulfilled } from "../../utils/async-value";
 import type { ThunkArgs } from "../../actions/types";
+import { loadSourceActorBreakpointColumns } from "../source-actors";
 
 async function mapLocations(
   generatedLocations: SourceLocation[],
   { sourceMaps }: ThunkArgs
 ) {
   if (generatedLocations.length == 0) {
     return [];
   }
@@ -109,17 +109,17 @@ function groupByLine(results, sourceId, 
 
 async function _setBreakpointPositions(cx, sourceId, line, thunkArgs) {
   const { client, dispatch, getState, sourceMaps } = thunkArgs;
   let generatedSource = getSource(getState(), sourceId);
   if (!generatedSource) {
     return;
   }
 
-  let results = {};
+  const results = {};
   if (isOriginalId(sourceId)) {
     // Explicitly typing ranges is required to work around the following issue
     // https://github.com/facebook/flow/issues/5294
     const ranges: Range[] = await sourceMaps.getGeneratedRangesForOriginal(
       sourceId,
       generatedSource.url,
       true
     );
@@ -135,33 +135,48 @@ async function _setBreakpointPositions(c
       // in this case.
       if (range.end.column === Infinity) {
         range.end = {
           line: range.end.line + 1,
           column: 0,
         };
       }
 
-      const bps = await client.getBreakpointPositions(
-        getSourceActorsForSource(getState(), generatedSource.id),
-        range
+      const actorBps = await Promise.all(
+        getSourceActorsForSource(getState(), generatedSource.id).map(actor =>
+          client.getSourceActorBreakpointPositions(actor, range)
+        )
       );
-      for (const bpLine in bps) {
-        results[bpLine] = (results[bpLine] || []).concat(bps[bpLine]);
+
+      for (const actorPositions of actorBps) {
+        for (const rangeLine of Object.keys(actorPositions)) {
+          let columns = actorPositions[parseInt(rangeLine, 10)];
+          const existing = results[rangeLine];
+          if (existing) {
+            columns = [...new Set([...existing, ...columns])];
+          }
+
+          results[rangeLine] = columns;
+        }
       }
     }
   } else {
     if (typeof line !== "number") {
       throw new Error("Line is required for generated sources");
     }
 
-    results = await client.getBreakpointPositions(
-      getSourceActorsForSource(getState(), generatedSource.id),
-      { start: { line, column: 0 }, end: { line: line + 1, column: 0 } }
+    const actorColumns = await Promise.all(
+      getSourceActorsForSource(getState(), generatedSource.id).map(actor =>
+        dispatch(loadSourceActorBreakpointColumns({ id: actor.id, line }))
+      )
     );
+
+    for (const columns of actorColumns) {
+      results[line] = (results[line] || []).concat(columns);
+    }
   }
 
   let positions = convertToList(results, generatedSource);
   positions = await mapLocations(positions, thunkArgs);
 
   positions = filterBySource(positions, sourceId);
   positions = filterByUniqLocation(positions);
   positions = groupByLine(positions, sourceId, line);
@@ -173,18 +188,16 @@ async function _setBreakpointPositions(c
   }
 
   dispatch({
     type: "ADD_BREAKPOINT_POSITIONS",
     cx,
     source: source,
     positions,
   });
-
-  return positions;
 }
 
 function generatedSourceActorKey(state, sourceId) {
   const generatedSource = getSource(
     state,
     isOriginalId(sourceId) ? originalToGeneratedId(sourceId) : sourceId
   );
   const actors = generatedSource
@@ -194,21 +207,29 @@ function generatedSourceActorKey(state, 
     : [];
   return [sourceId, ...actors].join(":");
 }
 
 export const setBreakpointPositions: MemoizedAction<
   { cx: Context, sourceId: string, line?: number },
   ?BreakpointPositions
 > = memoizeableAction("setBreakpointPositions", {
-  hasValue: ({ sourceId, line }, { getState }) =>
-    isGeneratedId(sourceId) && line
-      ? hasBreakpointPositionsForLine(getState(), sourceId, line)
-      : hasBreakpointPositions(getState(), sourceId),
-  getValue: ({ sourceId, line }, { getState }) =>
-    getBreakpointPositionsForSource(getState(), sourceId),
+  getValue: ({ sourceId, line }, { getState }) => {
+    const positions = getBreakpointPositionsForSource(getState(), sourceId);
+    if (!positions) {
+      return null;
+    }
+
+    if (isGeneratedId(sourceId) && line && !positions[line]) {
+      // We always return the full position dataset, but if a given line is
+      // not available, we treat the whole set as loading.
+      return null;
+    }
+
+    return fulfilled(positions);
+  },
   createKey({ sourceId, line }, { getState }) {
     const key = generatedSourceActorKey(getState(), sourceId);
     return isGeneratedId(sourceId) && line ? `${key}-${line}` : key;
   },
   action: async ({ cx, sourceId, line }, thunkArgs) =>
     _setBreakpointPositions(cx, sourceId, line, thunkArgs),
 });
--- a/devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js
+++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpointPositions.spec.js
@@ -6,25 +6,25 @@
 
 import {
   actions,
   selectors,
   createStore,
   makeSource,
   waitForState,
 } from "../../../utils/test-head";
-import { createSource } from "../../tests/helpers/threadFront";
+import { createSource } from "../../tests/helpers/mockCommandClient";
 
 describe("breakpointPositions", () => {
   it("fetches positions", async () => {
     const fooContent = createSource("foo", "");
 
     const store = createStore({
-      getBreakpointPositions: async () => ({ "9": [1] }),
-      getBreakableLines: async () => [],
+      getSourceActorBreakpointPositions: async () => ({ "9": [1] }),
+      getSourceActorBreakableLines: async () => [],
       sourceContents: async () => fooContent,
     });
 
     const { dispatch, getState, cx } = store;
     const source = await dispatch(
       actions.newGeneratedSource(makeSource("foo"))
     );
     await dispatch(actions.loadSourceById(cx, source.id));
@@ -58,22 +58,22 @@ describe("breakpointPositions", () => {
   });
 
   it("doesn't re-fetch positions", async () => {
     const fooContent = createSource("foo", "");
 
     let resolve = _ => {};
     let count = 0;
     const store = createStore({
-      getBreakpointPositions: () =>
+      getSourceActorBreakpointPositions: () =>
         new Promise(r => {
           count++;
           resolve = r;
         }),
-      getBreakableLines: async () => [],
+      getSourceActorBreakableLines: async () => [],
       sourceContents: async () => fooContent,
     });
 
     const { dispatch, getState, cx } = store;
     const source = await dispatch(
       actions.newGeneratedSource(makeSource("foo"))
     );
     await dispatch(actions.loadSourceById(cx, source.id));
--- a/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js
+++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js
@@ -7,23 +7,23 @@
 import {
   createStore,
   selectors,
   actions,
   makeSource,
   getTelemetryEvents,
 } from "../../../utils/test-head";
 
-import { simpleMockThreadFront } from "../../tests/helpers/threadFront.js";
+import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
 
 function mockClient(positionsResponse = {}) {
   return {
-    ...simpleMockThreadFront,
-    getBreakpointPositions: async () => positionsResponse,
-    getBreakableLines: async () => [],
+    ...mockCommandClient,
+    getSourceActorBreakpointPositions: async () => positionsResponse,
+    getSourceActorBreakableLines: async () => [],
   };
 }
 
 describe("breakpoints", () => {
   it("should add a breakpoint", async () => {
     const { dispatch, getState, cx } = createStore(mockClient({ "2": [1] }));
     const loc1 = {
       sourceId: "a",
--- a/devtools/client/debugger/src/actions/file-search.js
+++ b/devtools/client/debugger/src/actions/file-search.js
@@ -30,35 +30,35 @@ import {
   setActiveSearch,
 } from "./ui";
 import { isFulfilled } from "../utils/async-value";
 type Editor = Object;
 type Match = Object;
 
 export function doSearch(cx: Context, query: string, editor: Editor) {
   return ({ getState, dispatch }: ThunkArgs) => {
-    const selectedSourceWithContent = getSelectedSourceWithContent(getState());
-    if (!selectedSourceWithContent || !selectedSourceWithContent.content) {
+    const selectedSource = getSelectedSourceWithContent(getState());
+    if (!selectedSource || !selectedSource.content) {
       return;
     }
 
     dispatch(setFileSearchQuery(cx, query));
     dispatch(searchContents(cx, query, editor));
   };
 }
 
 export function doSearchForHighlight(
   query: string,
   editor: Editor,
   line: number,
   ch: number
 ) {
   return async ({ getState, dispatch }: ThunkArgs) => {
-    const selectedSourceWithContent = getSelectedSourceWithContent(getState());
-    if (!selectedSourceWithContent || !selectedSourceWithContent.content) {
+    const selectedSource = getSelectedSourceWithContent(getState());
+    if (!selectedSource || !selectedSource.content) {
       return;
     }
     dispatch(searchContentsForHighlight(query, editor, line, ch));
   };
 }
 
 export function setFileSearchQuery(cx: Context, query: string): Action {
   return {
@@ -100,29 +100,28 @@ export function updateSearchResults(
 export function searchContents(
   cx: Context,
   query: string,
   editor: Object,
   focusFirstResult?: boolean = true
 ) {
   return async ({ getState, dispatch }: ThunkArgs) => {
     const modifiers = getFileSearchModifiers(getState());
-    const selectedSourceWithContent = getSelectedSourceWithContent(getState());
+    const selectedSource = getSelectedSourceWithContent(getState());
 
     if (
       !editor ||
-      !selectedSourceWithContent ||
-      !selectedSourceWithContent.content ||
-      !isFulfilled(selectedSourceWithContent.content) ||
+      !selectedSource ||
+      !selectedSource.content ||
+      !isFulfilled(selectedSource.content) ||
       !modifiers
     ) {
       return;
     }
-    const selectedSource = selectedSourceWithContent.source;
-    const selectedContent = selectedSourceWithContent.content.value;
+    const selectedContent = selectedSource.content.value;
 
     const ctx = { ed: editor, cm: editor.codeMirror };
 
     if (!query) {
       clearSearch(ctx.cm, query);
       return;
     }
 
--- a/devtools/client/debugger/src/actions/pause/tests/pause.spec.js
+++ b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js
@@ -17,17 +17,17 @@ import {
 import { makeWhyNormal } from "../../../utils/test-mockup";
 
 import { parserWorker } from "../../../test/tests-setup";
 import { features } from "../../../utils/prefs";
 
 const { isStepping } = selectors;
 
 let stepInResolve = null;
-const mockThreadFront = {
+const mockCommandClient = {
   stepIn: () =>
     new Promise(_resolve => {
       stepInResolve = _resolve;
     }),
   stepOver: () => new Promise(_resolve => _resolve),
   evaluate: async () => {},
   evaluateInFrame: async () => {},
   evaluateExpressions: async () => [],
@@ -66,18 +66,18 @@ const mockThreadFront = {
         case "foo-wasm/originalSource":
           return resolve({
             source: "fn fooBar() {}\nfn barZoo() { fooBar() }",
             contentType: "text/rust",
           });
       }
     });
   },
-  getBreakpointPositions: async () => ({}),
-  getBreakableLines: async () => [],
+  getSourceActorBreakpointPositions: async () => ({}),
+  getSourceActorBreakableLines: async () => [],
   actorID: "threadActorID",
 };
 
 const mockFrameId = "1";
 
 function createPauseInfo(
   frameLocation = { sourceId: "foo1", line: 2 },
   frameOpts = {}
@@ -99,17 +99,17 @@ function createPauseInfo(
     loadedObjects: [],
     why: makeWhyNormal(),
   };
 }
 
 describe("pause", () => {
   describe("stepping", () => {
     it("should set and clear the command", async () => {
-      const { dispatch, getState } = createStore(mockThreadFront);
+      const { dispatch, getState } = createStore(mockCommandClient);
       const mockPauseInfo = createPauseInfo();
 
       await dispatch(actions.newGeneratedSource(makeSource("foo1")));
       await dispatch(actions.paused(mockPauseInfo));
       const cx = selectors.getThreadContext(getState());
       const stepped = dispatch(actions.stepIn(cx));
       expect(isStepping(getState(), "FakeThread")).toBeTruthy();
       if (!stepInResolve) {
@@ -124,43 +124,43 @@ describe("pause", () => {
       const client = { stepIn: jest.fn() };
       const { dispatch, cx } = createStore(client);
 
       dispatch(actions.stepIn(cx));
       expect(client.stepIn.mock.calls).toHaveLength(0);
     });
 
     it("should step when paused", async () => {
-      const { dispatch, getState } = createStore(mockThreadFront);
+      const { dispatch, getState } = createStore(mockCommandClient);
       const mockPauseInfo = createPauseInfo();
 
       await dispatch(actions.newGeneratedSource(makeSource("foo1")));
       await dispatch(actions.paused(mockPauseInfo));
       const cx = selectors.getThreadContext(getState());
       dispatch(actions.stepIn(cx));
       expect(isStepping(getState(), "FakeThread")).toBeTruthy();
     });
 
     it("should step over when paused", async () => {
-      const store = createStore(mockThreadFront);
+      const store = createStore(mockCommandClient);
       const { dispatch, getState } = store;
       const mockPauseInfo = createPauseInfo();
 
       await dispatch(actions.newGeneratedSource(makeSource("foo1")));
       await dispatch(actions.paused(mockPauseInfo));
       const cx = selectors.getThreadContext(getState());
       const getNextStepSpy = jest.spyOn(parserWorker, "getNextStep");
       dispatch(actions.stepOver(cx));
       expect(getNextStepSpy).not.toHaveBeenCalled();
       expect(isStepping(getState(), "FakeThread")).toBeTruthy();
     });
 
     it("should step over when paused before an await", async () => {
       features.asyncStepping = true;
-      const store = createStore(mockThreadFront);
+      const store = createStore(mockCommandClient);
       const { dispatch, getState } = store;
       const mockPauseInfo = createPauseInfo({
         sourceId: "await",
         line: 2,
         column: 0,
       });
 
       await dispatch(actions.newGeneratedSource(makeSource("await")));
@@ -170,18 +170,18 @@ describe("pause", () => {
       const getNextStepSpy = jest.spyOn(parserWorker, "getNextStep");
       dispatch(actions.stepOver(cx));
       expect(getNextStepSpy).toHaveBeenCalled();
       getNextStepSpy.mockRestore();
     });
 
     it("should step over when paused after an await", async () => {
       const store = createStore({
-        ...mockThreadFront,
-        getBreakpointPositions: async () => ({ [2]: [1] }),
+        ...mockCommandClient,
+        getSourceActorBreakpointPositions: async () => ({ [2]: [1] }),
       });
       const { dispatch, getState } = store;
       const mockPauseInfo = createPauseInfo({
         sourceId: "await",
         line: 2,
         column: 6,
       });
 
@@ -197,17 +197,17 @@ describe("pause", () => {
 
     it("getting frame scopes with bindings", async () => {
       const generatedLocation = {
         sourceId: "foo",
         line: 1,
         column: 0,
       };
 
-      const store = createStore(mockThreadFront, {});
+      const store = createStore(mockCommandClient, {});
       const { dispatch, getState } = store;
       const mockPauseInfo = createPauseInfo(generatedLocation, {
         scope: {
           bindings: {
             variables: { b: { value: {} } },
             arguments: [{ a: { value: {} } }],
           },
         },
@@ -274,17 +274,17 @@ describe("pause", () => {
         getOriginalLocations: async items => items,
         getOriginalSourceText: async () => ({
           text: "\n\nfunction fooOriginal() {\n  return -5;\n}",
           contentType: "text/javascript",
         }),
         getGeneratedLocation: async location => location,
       };
 
-      const store = createStore(mockThreadFront, {}, sourceMapsMock);
+      const store = createStore(mockCommandClient, {}, sourceMapsMock);
       const { dispatch, getState } = store;
       const mockPauseInfo = createPauseInfo(generatedLocation);
 
       await dispatch(actions.newGeneratedSource(makeSource("foo")));
       await dispatch(actions.newGeneratedSource(makeSource("foo-original")));
 
       await dispatch(actions.paused(mockPauseInfo));
       expect(selectors.getFrames(getState(), "FakeThread")).toEqual([
@@ -335,17 +335,17 @@ describe("pause", () => {
         getOriginalLocations: async items => items,
         getOriginalSourceText: async () => ({
           text: "fn fooBar() {}\nfn barZoo() { fooBar() }",
           contentType: "text/rust",
         }),
         getGeneratedRangesForOriginal: async () => [],
       };
 
-      const store = createStore(mockThreadFront, {}, sourceMapsMock);
+      const store = createStore(mockCommandClient, {}, sourceMapsMock);
       const { dispatch, getState } = store;
       const mockPauseInfo = createPauseInfo(generatedLocation);
 
       const source = await dispatch(
         actions.newGeneratedSource(
           makeSource("foo-wasm", { introductionType: "wasm" })
         )
       );
@@ -378,41 +378,41 @@ describe("pause", () => {
           thread: "FakeThread",
         },
       ]);
     });
   });
 
   describe("resumed", () => {
     it("should not evaluate expression while stepping", async () => {
-      const client = { ...mockThreadFront, evaluateExpressions: jest.fn() };
+      const client = { ...mockCommandClient, evaluateExpressions: jest.fn() };
       const { dispatch, getState } = createStore(client);
       const mockPauseInfo = createPauseInfo();
 
       await dispatch(actions.newGeneratedSource(makeSource("foo1")));
       await dispatch(actions.paused(mockPauseInfo));
 
       const cx = selectors.getThreadContext(getState());
       dispatch(actions.stepIn(cx));
-      await dispatch(actions.resumed(mockThreadFront.actorID));
+      await dispatch(actions.resumed(mockCommandClient.actorID));
       expect(client.evaluateExpressions.mock.calls).toHaveLength(1);
     });
 
     it("resuming - will re-evaluate watch expressions", async () => {
-      const client = { ...mockThreadFront, evaluateExpressions: jest.fn() };
+      const client = { ...mockCommandClient, evaluateExpressions: jest.fn() };
       const store = createStore(client);
       const { dispatch, getState, cx } = store;
       const mockPauseInfo = createPauseInfo();
 
       await dispatch(actions.newGeneratedSource(makeSource("foo1")));
       await dispatch(actions.newGeneratedSource(makeSource("foo")));
       await dispatch(actions.addExpression(cx, "foo"));
       await waitForState(store, state => selectors.getExpression(state, "foo"));
 
       client.evaluateExpressions.mockReturnValue(Promise.resolve(["YAY"]));
       await dispatch(actions.paused(mockPauseInfo));
 
-      await dispatch(actions.resumed(mockThreadFront.actorID));
+      await dispatch(actions.resumed(mockCommandClient.actorID));
       const expression = selectors.getExpression(getState(), "foo");
       expect(expression && expression.value).toEqual("YAY");
     });
   });
 });
--- a/devtools/client/debugger/src/actions/source-actors.js
+++ b/devtools/client/debugger/src/actions/source-actors.js
@@ -1,16 +1,27 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import type { ThunkArgs } from "./types";
-import type { SourceActor } from "../reducers/source-actors";
+import {
+  getSourceActor,
+  getSourceActorBreakableLines,
+  getSourceActorBreakpointColumns,
+  type SourceActorId,
+  type SourceActor,
+} from "../reducers/source-actors";
+import {
+  memoizeableAction,
+  type MemoizedAction,
+} from "../utils/memoizableAction";
+import { PROMISE } from "./utils/middleware/promise";
 
 export function insertSourceActor(item: SourceActor) {
   return insertSourceActors([item]);
 }
 export function insertSourceActors(items: Array<SourceActor>) {
   return function({ dispatch }: ThunkArgs) {
     dispatch({
       type: "INSERT_SOURCE_ACTORS",
@@ -25,8 +36,53 @@ export function removeSourceActor(item: 
 export function removeSourceActors(items: Array<SourceActor>) {
   return function({ dispatch }: ThunkArgs) {
     dispatch({
       type: "REMOVE_SOURCE_ACTORS",
       items,
     });
   };
 }
+
+export const loadSourceActorBreakpointColumns: MemoizedAction<
+  { id: SourceActorId, line: number },
+  Array<number>
+> = memoizeableAction("loadSourceActorBreakpointColumns", {
+  createKey: ({ id, line }) => `${id}:${line}`,
+  getValue: ({ id, line }, { getState }) =>
+    getSourceActorBreakpointColumns(getState(), id, line),
+  action: async ({ id, line }, { dispatch, getState, client }) => {
+    await dispatch({
+      type: "SET_SOURCE_ACTOR_BREAKPOINT_COLUMNS",
+      sourceId: id,
+      line,
+      [PROMISE]: (async () => {
+        const positions = await client.getSourceActorBreakpointPositions(
+          getSourceActor(getState(), id),
+          {
+            start: { line, column: 0 },
+            end: { line: line + 1, column: 0 },
+          }
+        );
+
+        return positions[line] || [];
+      })(),
+    });
+  },
+});
+
+export const loadSourceActorBreakableLines: MemoizedAction<
+  { id: SourceActorId },
+  Array<number>
+> = memoizeableAction("loadSourceActorBreakableLines", {
+  createKey: args => args.id,
+  getValue: ({ id }, { getState }) =>
+    getSourceActorBreakableLines(getState(), id),
+  action: async ({ id }, { dispatch, getState, client }) => {
+    await dispatch({
+      type: "SET_SOURCE_ACTOR_BREAKABLE_LINES",
+      sourceId: id,
+      [PROMISE]: client.getSourceActorBreakableLines(
+        getSourceActor(getState(), id)
+      ),
+    });
+  },
+});
--- a/devtools/client/debugger/src/actions/sources/breakableLines.js
+++ b/devtools/client/debugger/src/actions/sources/breakableLines.js
@@ -5,16 +5,17 @@
 // @flow
 
 import { isOriginalId } from "devtools-source-map";
 import { getSourceActorsForSource, getBreakableLines } from "../../selectors";
 import { setBreakpointPositions } from "../breakpoints/breakpointPositions";
 import { union } from "lodash";
 import type { Context } from "../../types";
 import type { ThunkArgs } from "../../actions/types";
+import { loadSourceActorBreakableLines } from "../source-actors";
 
 function calculateBreakableLines(positions) {
   const lines = [];
   for (const line in positions) {
     if (positions[line].length > 0) {
       lines.push(Number(line));
     }
   }
@@ -25,27 +26,31 @@ function calculateBreakableLines(positio
 export function setBreakableLines(cx: Context, sourceId: string) {
   return async ({ getState, dispatch, client }: ThunkArgs) => {
     let breakableLines;
     if (isOriginalId(sourceId)) {
       const positions = await dispatch(
         setBreakpointPositions({ cx, sourceId })
       );
       breakableLines = calculateBreakableLines(positions);
+
+      const existingBreakableLines = getBreakableLines(getState(), sourceId);
+      if (existingBreakableLines) {
+        breakableLines = union(existingBreakableLines, breakableLines);
+      }
+
+      dispatch({
+        type: "SET_ORIGINAL_BREAKABLE_LINES",
+        cx,
+        sourceId,
+        breakableLines,
+      });
     } else {
-      breakableLines = await client.getBreakableLines(
-        getSourceActorsForSource(getState(), sourceId)
+      const actors = getSourceActorsForSource(getState(), sourceId);
+
+      await Promise.all(
+        actors.map(actor =>
+          dispatch(loadSourceActorBreakableLines({ id: actor.id }))
+        )
       );
     }
-
-    const existingBreakableLines = getBreakableLines(getState(), sourceId);
-    if (existingBreakableLines) {
-      breakableLines = union(existingBreakableLines, breakableLines);
-    }
-
-    dispatch({
-      type: "SET_BREAKABLE_LINES",
-      cx,
-      sourceId,
-      breakableLines,
-    });
   };
 }
--- a/devtools/client/debugger/src/actions/sources/loadSourceText.js
+++ b/devtools/client/debugger/src/actions/sources/loadSourceText.js
@@ -14,17 +14,17 @@ import {
   getSourcesEpoch,
   getBreakpointsForSource,
   getSourceActorsForSource,
 } from "../../selectors";
 import { addBreakpoint } from "../breakpoints";
 
 import { prettyPrintSource } from "./prettyPrint";
 import { setBreakableLines } from "./breakableLines";
-import { isFulfilled } from "../../utils/async-value";
+import { isFulfilled, fulfilled } from "../../utils/async-value";
 
 import { isOriginal, isPretty } from "../../utils/source";
 import {
   memoizeableAction,
   type MemoizedAction,
 } from "../../utils/memoizableAction";
 
 import { Telemetry } from "devtools-modules";
@@ -119,38 +119,44 @@ async function loadSourceTextPromise(
 
     await dispatch(setBreakableLines(cx, source.id));
     // Update the text in any breakpoints for this source by re-adding them.
     const breakpoints = getBreakpointsForSource(getState(), source.id);
     for (const { location, options, disabled } of breakpoints) {
       await dispatch(addBreakpoint(cx, location, options, disabled));
     }
   }
-
-  return newSource;
 }
 
 export function loadSourceById(cx: Context, sourceId: string) {
   return ({ getState, dispatch }: ThunkArgs) => {
     const source = getSourceFromId(getState(), sourceId);
     return dispatch(loadSourceText({ cx, source }));
   };
 }
 
 export const loadSourceText: MemoizedAction<
   { cx: Context, source: Source },
   ?Source
 > = memoizeableAction("loadSourceText", {
-  exitEarly: ({ source }) => !source,
-  hasValue: ({ source }, { getState }) => {
-    return !!(
-      getSource(getState(), source.id) &&
-      getSourceWithContent(getState(), source.id).content
-    );
+  getValue: ({ source }, { getState }) => {
+    source = source ? getSource(getState(), source.id) : null;
+    if (!source) {
+      return null;
+    }
+
+    const { content } = getSourceWithContent(getState(), source.id);
+    if (!content || content.state === "pending") {
+      return content;
+    }
+
+    // This currently swallows source-load-failure since we return fulfilled
+    // here when content.state === "rejected". In an ideal world we should
+    // propagate that error upward.
+    return fulfilled(source);
   },
-  getValue: ({ source }, { getState }) => getSource(getState(), source.id),
   createKey: ({ source }, { getState }) => {
     const epoch = getSourcesEpoch(getState());
     return `${epoch}:${source.id}`;
   },
   action: ({ cx, source }, thunkArgs) =>
     loadSourceTextPromise(cx, source, thunkArgs),
 });
--- a/devtools/client/debugger/src/actions/sources/prettyPrint.js
+++ b/devtools/client/debugger/src/actions/sources/prettyPrint.js
@@ -61,17 +61,17 @@ export async function prettyPrintSource(
 }
 
 export function createPrettySource(cx: Context, sourceId: string) {
   return async ({ dispatch, getState, sourceMaps }: ThunkArgs) => {
     const source = getSourceFromId(getState(), sourceId);
     const url = getPrettySourceURL(source.url);
     const id = generatedToOriginalId(sourceId, url);
 
-    const prettySource: Source = {
+    const prettySource = {
       id,
       url,
       relativeUrl: url,
       isBlackBoxed: false,
       isPrettyPrinted: true,
       isWasm: false,
       introductionUrl: null,
       introductionType: undefined,
--- a/devtools/client/debugger/src/actions/sources/symbols.js
+++ b/devtools/client/debugger/src/actions/sources/symbols.js
@@ -1,24 +1,25 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
-import { hasSymbols, getSymbols } from "../../selectors";
+import { getSymbols } from "../../selectors";
 
 import { PROMISE } from "../utils/middleware/promise";
 import { updateTab } from "../tabs";
 import { loadSourceText } from "./loadSourceText";
 
 import {
   memoizeableAction,
   type MemoizedAction,
 } from "../../utils/memoizableAction";
+import { fulfilled } from "../../utils/async-value";
 
 import type { Source, Context } from "../../types";
 import type { Symbols } from "../../reducers/types";
 
 async function doSetSymbols(cx, source, { dispatch, getState, parser }) {
   const sourceId = source.id;
 
   await dispatch(loadSourceText({ cx, source }));
@@ -29,24 +30,31 @@ async function doSetSymbols(cx, source, 
     sourceId,
     [PROMISE]: parser.getSymbols(sourceId),
   });
 
   const symbols = getSymbols(getState(), source);
   if (symbols && symbols.framework) {
     dispatch(updateTab(source, symbols.framework));
   }
-
-  return symbols;
 }
 
 type Args = { cx: Context, source: Source };
 
 export const setSymbols: MemoizedAction<Args, ?Symbols> = memoizeableAction(
   "setSymbols",
   {
-    exitEarly: ({ source }) => source.isWasm,
-    hasValue: ({ source }, { getState }) => hasSymbols(getState(), source),
-    getValue: ({ source }, { getState }) => getSymbols(getState(), source),
+    getValue: ({ source }, { getState }) => {
+      if (source.isWasm) {
+        return fulfilled(null);
+      }
+
+      const symbols = getSymbols(getState(), source);
+      if (!symbols || symbols.loading) {
+        return null;
+      }
+
+      return fulfilled(symbols);
+    },
     createKey: ({ source }) => source.id,
     action: ({ cx, source }, thunkArgs) => doSetSymbols(cx, source, thunkArgs),
   }
 );
--- a/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js
+++ b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js
@@ -10,17 +10,17 @@ import {
   createStore,
   makeSource,
 } from "../../../utils/test-head";
 
 describe("blackbox", () => {
   it("should blackbox a source", async () => {
     const store = createStore({
       blackBox: async () => true,
-      getBreakableLines: async () => [],
+      getSourceActorBreakableLines: async () => [],
     });
     const { dispatch, getState, cx } = store;
 
     const foo1Source = await dispatch(
       actions.newGeneratedSource(makeSource("foo1"))
     );
     await dispatch(actions.toggleBlackBox(cx, foo1Source));
 
--- a/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js
+++ b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js
@@ -9,24 +9,24 @@ import {
   selectors,
   watchForState,
   createStore,
   makeOriginalSource,
   makeSource,
 } from "../../../utils/test-head";
 import {
   createSource,
-  sourceThreadFront,
-} from "../../tests/helpers/threadFront.js";
+  mockCommandClient,
+} from "../../tests/helpers/mockCommandClient";
 import { getBreakpointsList } from "../../../selectors";
 import { isFulfilled, isRejected } from "../../../utils/async-value";
 
 describe("loadSourceText", () => {
   it("should load source text", async () => {
-    const store = createStore(sourceThreadFront);
+    const store = createStore(mockCommandClient);
     const { dispatch, getState, cx } = store;
 
     const foo1Source = await dispatch(
       actions.newGeneratedSource(makeSource("foo1"))
     );
     await dispatch(actions.loadSourceText({ cx, source: foo1Source }));
 
     const foo1Content = selectors.getSourceContent(getState(), foo1Source.id);
@@ -54,20 +54,20 @@ describe("loadSourceText", () => {
   });
 
   it("should update breakpoint text when a source loads", async () => {
     const fooOrigContent = createSource("fooOrig", "var fooOrig = 42;");
     const fooGenContent = createSource("fooGen", "var fooGen = 42;");
 
     const store = createStore(
       {
-        ...sourceThreadFront,
+        ...mockCommandClient,
         sourceContents: async () => fooGenContent,
-        getBreakpointPositions: async () => ({ "1": [0] }),
-        getBreakableLines: async () => [],
+        getSourceActorBreakpointPositions: async () => ({ "1": [0] }),
+        getSourceActorBreakableLines: async () => [],
       },
       {},
       {
         getGeneratedRangesForOriginal: async () => [
           { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } },
         ],
         getOriginalLocations: async (sourceId, items) =>
           items.map(item => ({
@@ -151,18 +151,18 @@ describe("loadSourceText", () => {
     let resolve;
     let count = 0;
     const { dispatch, getState, cx } = createStore({
       sourceContents: () =>
         new Promise(r => {
           count++;
           resolve = r;
         }),
-      getBreakpointPositions: async () => ({}),
-      getBreakableLines: async () => [],
+      getSourceActorBreakpointPositions: async () => ({}),
+      getSourceActorBreakableLines: async () => [],
     });
     const id = "foo";
 
     await dispatch(actions.newGeneratedSource(makeSource(id)));
 
     let source = selectors.getSourceFromId(getState(), id);
     dispatch(actions.loadSourceText({ cx, source }));
 
@@ -189,18 +189,18 @@ describe("loadSourceText", () => {
     let resolve;
     let count = 0;
     const { dispatch, getState, cx } = createStore({
       sourceContents: () =>
         new Promise(r => {
           count++;
           resolve = r;
         }),
-      getBreakpointPositions: async () => ({}),
-      getBreakableLines: async () => [],
+      getSourceActorBreakpointPositions: async () => ({}),
+      getSourceActorBreakableLines: async () => [],
     });
     const id = "foo";
 
     await dispatch(actions.newGeneratedSource(makeSource(id)));
     let source = selectors.getSourceFromId(getState(), id);
     const loading = dispatch(actions.loadSourceText({ cx, source }));
 
     if (!resolve) {
@@ -218,49 +218,49 @@ describe("loadSourceText", () => {
       content &&
         isFulfilled(content) &&
         content.value.type === "text" &&
         content.value.value
     ).toEqual("yay");
   });
 
   it("should cache subsequent source text loads", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const source = await dispatch(
       actions.newGeneratedSource(makeSource("foo1"))
     );
     await dispatch(actions.loadSourceText({ cx, source }));
     const prevSource = selectors.getSourceFromId(getState(), "foo1");
 
     await dispatch(actions.loadSourceText({ cx, source: prevSource }));
     const curSource = selectors.getSource(getState(), "foo1");
 
     expect(prevSource === curSource).toBeTruthy();
   });
 
   it("should indicate a loading source", async () => {
-    const store = createStore(sourceThreadFront);
+    const store = createStore(mockCommandClient);
     const { dispatch, cx } = store;
 
     const source = await dispatch(
       actions.newGeneratedSource(makeSource("foo2"))
     );
 
     const wasLoading = watchForState(store, state => {
       return !selectors.getSourceContent(state, "foo2");
     });
 
     await dispatch(actions.loadSourceText({ cx, source }));
 
     expect(wasLoading()).toBe(true);
   });
 
   it("should indicate an errored source text", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const source = await dispatch(
       actions.newGeneratedSource(makeSource("bad-id"))
     );
     await dispatch(actions.loadSourceText({ cx, source }));
     const badSource = selectors.getSource(getState(), "bad-id");
 
     const content = badSource
--- a/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js
+++ b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js
@@ -15,58 +15,57 @@ import {
 const {
   getSource,
   getSourceCount,
   getSelectedSource,
   getSourceByURL,
 } = selectors;
 import sourceQueue from "../../../utils/source-queue";
 
-// eslint-disable-next-line max-len
-import { sourceThreadFront as threadFront } from "../../tests/helpers/threadFront.js";
+import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
 
 describe("sources - new sources", () => {
   it("should add sources to state", async () => {
-    const { dispatch, getState } = createStore(threadFront);
+    const { dispatch, getState } = createStore(mockCommandClient);
     await dispatch(actions.newGeneratedSource(makeSource("base.js")));
     await dispatch(actions.newGeneratedSource(makeSource("jquery.js")));
 
     expect(getSourceCount(getState())).toEqual(2);
     const base = getSource(getState(), "base.js");
     const jquery = getSource(getState(), "jquery.js");
     expect(base && base.id).toEqual("base.js");
     expect(jquery && jquery.id).toEqual("jquery.js");
   });
 
   it("should not add multiple identical sources", async () => {
-    const { dispatch, getState } = createStore(threadFront);
+    const { dispatch, getState } = createStore(mockCommandClient);
 
     await dispatch(actions.newGeneratedSource(makeSource("base.js")));
     await dispatch(actions.newGeneratedSource(makeSource("base.js")));
 
     expect(getSourceCount(getState())).toEqual(1);
   });
 
   it("should automatically select a pending source", async () => {
-    const { dispatch, getState, cx } = createStore(threadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
     const baseSourceURL = makeSourceURL("base.js");
     await dispatch(actions.selectSourceURL(cx, baseSourceURL));
 
     expect(getSelectedSource(getState())).toBe(undefined);
     const baseSource = await dispatch(
       actions.newGeneratedSource(makeSource("base.js"))
     );
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.url).toBe(baseSource.url);
   });
 
   it("should add original sources", async () => {
     const { dispatch, getState } = createStore(
-      threadFront,
+      mockCommandClient,
       {},
       {
         getOriginalURLs: async () => ["magic.js"],
         getOriginalLocations: async items => items,
       }
     );
 
     await dispatch(
@@ -77,32 +76,32 @@ describe("sources - new sources", () => 
     const magic = getSourceByURL(getState(), "magic.js");
     expect(magic && magic.url).toEqual("magic.js");
   });
 
   // eslint-disable-next-line
   it("should not attempt to fetch original sources if it's missing a source map url", async () => {
     const getOriginalURLs = jest.fn();
     const { dispatch } = createStore(
-      threadFront,
+      mockCommandClient,
       {},
       {
         getOriginalURLs,
         getOriginalLocations: async items => items,
       }
     );
 
     await dispatch(actions.newGeneratedSource(makeSource("base.js")));
     expect(getOriginalURLs).not.toHaveBeenCalled();
   });
 
   // eslint-disable-next-line
   it("should process new sources immediately, without waiting for source maps to be fetched first", async () => {
     const { dispatch, getState } = createStore(
-      threadFront,
+      mockCommandClient,
       {},
       {
         getOriginalURLs: async () => new Promise(_ => {}),
         getOriginalLocations: async items => items,
       }
     );
     await dispatch(
       actions.newGeneratedSource(
@@ -112,17 +111,17 @@ describe("sources - new sources", () => 
     expect(getSourceCount(getState())).toEqual(1);
     const base = getSource(getState(), "base.js");
     expect(base && base.id).toEqual("base.js");
   });
 
   // eslint-disable-next-line
   it("shouldn't let one slow loading source map delay all the other source maps", async () => {
     const dbg = createStore(
-      threadFront,
+      mockCommandClient,
       {},
       {
         getOriginalURLs: async source => {
           if (source.id == "foo.js") {
             // simulate a hang loading foo.js.map
             return new Promise(_ => {});
           }
 
@@ -148,17 +147,17 @@ describe("sources - new sources", () => 
     const bazzCljs = getSourceByURL(getState(), "bazz.cljs");
     expect(bazzCljs && bazzCljs.url).toEqual("bazz.cljs");
   });
 
   describe("sources - sources with querystrings", () => {
     it(`should find two sources when same source with
       querystring`, async () => {
       const { getSourcesUrlsInSources } = selectors;
-      const { dispatch, getState } = createStore(threadFront);
+      const { dispatch, getState } = createStore(mockCommandClient);
       await dispatch(actions.newGeneratedSource(makeSource("base.js?v=1")));
       await dispatch(actions.newGeneratedSource(makeSource("base.js?v=2")));
       await dispatch(actions.newGeneratedSource(makeSource("diff.js?v=1")));
 
       const base1 = "http://localhost:8000/examples/base.js?v=1";
       const diff1 = "http://localhost:8000/examples/diff.js?v=1";
       const diff2 = "http://localhost:8000/examples/diff.js?v=1";
 
--- a/devtools/client/debugger/src/actions/sources/tests/prettyPrint.spec.js
+++ b/devtools/client/debugger/src/actions/sources/tests/prettyPrint.spec.js
@@ -6,22 +6,22 @@
 
 import {
   actions,
   selectors,
   createStore,
   makeSource,
 } from "../../../utils/test-head";
 import { createPrettySource } from "../prettyPrint";
-import { sourceThreadFront } from "../../tests/helpers/threadFront.js";
+import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
 import { isFulfilled } from "../../../utils/async-value";
 
 describe("sources - pretty print", () => {
   it("returns a pretty source for a minified file", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const url = "base.js";
     const source = await dispatch(actions.newGeneratedSource(makeSource(url)));
     await dispatch(actions.loadSourceText({ cx, source }));
 
     await dispatch(createPrettySource(cx, source.id));
 
     const prettyURL = `${source.url}:formatted`;
@@ -37,29 +37,29 @@ describe("sources - pretty print", () =>
         isFulfilled(content) &&
         content.value.type === "text" &&
         content.value.contentType
     ).toEqual("text/javascript");
     expect(content).toMatchSnapshot();
   });
 
   it("should create a source when first toggling pretty print", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const source = await dispatch(
       actions.newGeneratedSource(makeSource("foobar.js"))
     );
     await dispatch(actions.loadSourceText({ cx, source }));
 
     await dispatch(actions.togglePrettyPrint(cx, source.id));
     expect(selectors.getSourceCount(getState())).toEqual(2);
   });
 
   it("should not make a second source when toggling pretty print", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const source = await dispatch(
       actions.newGeneratedSource(makeSource("foobar.js"))
     );
     await dispatch(actions.loadSourceText({ cx, source }));
 
     await dispatch(actions.togglePrettyPrint(cx, source.id));
     expect(selectors.getSourceCount(getState())).toEqual(2);
--- a/devtools/client/debugger/src/actions/sources/tests/querystrings.spec.js
+++ b/devtools/client/debugger/src/actions/sources/tests/querystrings.spec.js
@@ -8,21 +8,21 @@ import {
   actions,
   selectors,
   createStore,
   makeSource,
 } from "../../../utils/test-head";
 const { getSourcesUrlsInSources } = selectors;
 
 // eslint-disable-next-line max-len
-import { sourceThreadFront as threadFront } from "../../tests/helpers/threadFront.js";
+import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
 
 describe("sources - sources with querystrings", () => {
   it("should find two sources when same source with querystring", async () => {
-    const { dispatch, getState } = createStore(threadFront);
+    const { dispatch, getState } = createStore(mockCommandClient);
     await dispatch(actions.newGeneratedSource(makeSource("base.js?v=1")));
     await dispatch(actions.newGeneratedSource(makeSource("base.js?v=2")));
     await dispatch(actions.newGeneratedSource(makeSource("diff.js?v=1")));
 
     expect(
       getSourcesUrlsInSources(
         getState(),
         "http://localhost:8000/examples/base.js?v=1"
--- a/devtools/client/debugger/src/actions/sources/tests/select.spec.js
+++ b/devtools/client/debugger/src/actions/sources/tests/select.spec.js
@@ -18,29 +18,29 @@ import {
 const {
   getSource,
   getSourceCount,
   getSelectedSource,
   getSourceTabs,
   getSelectedLocation,
 } = selectors;
 
-import { sourceThreadFront } from "../../tests/helpers/threadFront.js";
+import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
 
 process.on("unhandledRejection", (reason, p) => {});
 
 function initialLocation(sourceId) {
   return { sourceId, line: 1 };
 }
 
 describe("sources", () => {
   it("should select a source", async () => {
     // Note that we pass an empty client in because the action checks
     // if it exists.
-    const store = createStore(sourceThreadFront);
+    const store = createStore(mockCommandClient);
     const { dispatch, getState } = store;
 
     const frame = makeFrame({ id: "1", sourceId: "foo1" });
 
     await dispatch(actions.newGeneratedSource(makeSource("foo1")));
     await dispatch(
       actions.paused({
         thread: "FakeThread",
@@ -64,17 +64,17 @@ describe("sources", () => {
     const source = getSource(getState(), selectedSource.id);
     if (!source) {
       throw new Error("bad source");
     }
     expect(source.id).toEqual("foo1");
   });
 
   it("should select next tab on tab closed if no previous tab", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const fooSource = await dispatch(
       actions.newGeneratedSource(makeSource("foo.js"))
     );
     await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
     await dispatch(actions.newGeneratedSource(makeSource("baz.js")));
 
     // 3rd tab
@@ -93,27 +93,27 @@ describe("sources", () => {
     await dispatch(actions.closeTab(cx, fooSource));
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.id).toBe("bar.js");
     expect(getSourceTabs(getState())).toHaveLength(2);
   });
 
   it("should open a tab for the source", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
     await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
     dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
 
     const tabs = getSourceTabs(getState());
     expect(tabs).toHaveLength(1);
     expect(tabs[0].url).toEqual("http://localhost:8000/examples/foo.js");
   });
 
   it("should select previous tab on tab closed", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
     await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
     await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
 
     const bazSource = await dispatch(
       actions.newGeneratedSource(makeSource("baz.js"))
     );
 
     await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
@@ -122,17 +122,17 @@ describe("sources", () => {
     await dispatch(actions.closeTab(cx, bazSource));
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.id).toBe("bar.js");
     expect(getSourceTabs(getState())).toHaveLength(2);
   });
 
   it("should keep the selected source when other tab closed", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
     await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
     const bazSource = await dispatch(
       actions.newGeneratedSource(makeSource("baz.js"))
     );
 
     // 3rd tab
@@ -149,32 +149,32 @@ describe("sources", () => {
     await dispatch(actions.closeTab(cx, bazSource));
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.id).toBe("foo.js");
     expect(getSourceTabs(getState())).toHaveLength(2);
   });
 
   it("should not select new sources that lack a URL", async () => {
-    const { dispatch, getState } = createStore(sourceThreadFront);
+    const { dispatch, getState } = createStore(mockCommandClient);
 
     await dispatch(
       actions.newGeneratedSource({
         ...makeSource("foo"),
         url: "",
       })
     );
 
     expect(getSourceCount(getState())).toEqual(1);
     const selectedLocation = getSelectedLocation(getState());
     expect(selectedLocation).toEqual(undefined);
   });
 
   it("sets and clears selected location correctly", async () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
     const source = await dispatch(
       actions.newGeneratedSource(makeSource("testSource"))
     );
     const location = ({ test: "testLocation" }: any);
 
     // set value
     dispatch(actions.setSelectedLocation(cx, source, location));
     expect(getSelectedLocation(getState())).toEqual({
@@ -183,17 +183,17 @@ describe("sources", () => {
     });
 
     // clear value
     dispatch(actions.clearSelectedLocation(cx));
     expect(getSelectedLocation(getState())).toEqual(null);
   });
 
   it("sets and clears pending selected location correctly", () => {
-    const { dispatch, getState, cx } = createStore(sourceThreadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
     const url = "testURL";
     const options = { location: { line: "testLine" } };
 
     // set value
     dispatch(actions.setPendingSelectedLocation(cx, url, options));
     const setResult = getState().sources.pendingSelectedLocation;
     expect(setResult).toEqual({
       url,
@@ -202,34 +202,34 @@ describe("sources", () => {
 
     // clear value
     dispatch(actions.clearSelectedLocation(cx));
     const clearResult = getState().sources.pendingSelectedLocation;
     expect(clearResult).toEqual({ url: "" });
   });
 
   it("should keep the generated the viewing context", async () => {
-    const store = createStore(sourceThreadFront);
+    const store = createStore(mockCommandClient);
     const { dispatch, getState, cx } = store;
     const baseSource = await dispatch(
       actions.newGeneratedSource(makeSource("base.js"))
     );
 
     await dispatch(
       actions.selectLocation(cx, { sourceId: baseSource.id, line: 1 })
     );
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.id).toBe(baseSource.id);
     await waitForState(store, state => getSymbols(state, baseSource));
   });
 
   it("should keep the original the viewing context", async () => {
     const { dispatch, getState, cx } = createStore(
-      sourceThreadFront,
+      mockCommandClient,
       {},
       {
         getOriginalLocation: async location => ({ ...location, line: 12 }),
         getOriginalLocations: async items => items,
         getGeneratedLocation: async location => ({ ...location, line: 12 }),
         getOriginalSourceText: async () => ({ text: "" }),
         getGeneratedRangesForOriginal: async () => [],
       }
@@ -253,17 +253,17 @@ describe("sources", () => {
     );
 
     const selected = getSelectedLocation(getState());
     expect(selected && selected.line).toBe(12);
   });
 
   it("should change the original the viewing context", async () => {
     const { dispatch, getState, cx } = createStore(
-      sourceThreadFront,
+      mockCommandClient,
       {},
       {
         getOriginalLocation: async location => ({ ...location, line: 12 }),
         getOriginalLocations: async items => items,
         getGeneratedRangesForOriginal: async () => [],
         getOriginalSourceText: async () => ({ text: "" }),
       }
     );
@@ -285,17 +285,17 @@ describe("sources", () => {
     );
 
     const selected = getSelectedLocation(getState());
     expect(selected && selected.line).toBe(1);
   });
 
   describe("selectSourceURL", () => {
     it("should automatically select a pending source", async () => {
-      const { dispatch, getState, cx } = createStore(sourceThreadFront);
+      const { dispatch, getState, cx } = createStore(mockCommandClient);
       const baseSourceURL = makeSourceURL("base.js");
       await dispatch(actions.selectSourceURL(cx, baseSourceURL));
 
       expect(getSelectedSource(getState())).toBe(undefined);
       const baseSource = await dispatch(
         actions.newGeneratedSource(makeSource("base.js"))
       );
 
--- a/devtools/client/debugger/src/actions/tests/ast.spec.js
+++ b/devtools/client/debugger/src/actions/tests/ast.spec.js
@@ -12,27 +12,27 @@ import {
   makeSource,
   makeOriginalSource,
   waitForState,
 } from "../../utils/test-head";
 
 import readFixture from "./helpers/readFixture";
 const { getSymbols, isSymbolsLoading, getFramework } = selectors;
 
-const threadFront = {
+const mockCommandClient = {
   sourceContents: async ({ source }) => ({
     source: sourceTexts[source],
     contentType: "text/javascript",
   }),
   getFrameScopes: async () => {},
   evaluate: async expression => ({ result: evaluationResult[expression] }),
   evaluateExpressions: async expressions =>
     expressions.map(expression => ({ result: evaluationResult[expression] })),
-  getBreakpointPositions: async () => ({}),
-  getBreakableLines: async () => [],
+  getSourceActorBreakpointPositions: async () => ({}),
+  getSourceActorBreakableLines: async () => [],
 };
 
 const sourceMaps = {
   getOriginalSourceText: async ({ id }) => ({
     id,
     text: sourceTexts[id],
     contentType: "text/javascript",
   }),
@@ -51,17 +51,17 @@ const evaluationResult = {
   "this.bazz": { actor: "bazz", preview: {} },
   this: { actor: "this", preview: {} },
 };
 
 describe("ast", () => {
   describe("setSymbols", () => {
     describe("when the source is loaded", () => {
       it("should be able to set symbols", async () => {
-        const store = createStore(threadFront);
+        const store = createStore(mockCommandClient);
         const { dispatch, getState, cx } = store;
         const base = await dispatch(
           actions.newGeneratedSource(makeSource("base.js"))
         );
         await dispatch(actions.loadSourceText({ cx, source: base }));
 
         const loadedSource = selectors.getSourceFromId(getState(), base.id);
         await dispatch(actions.setSymbols({ cx, source: loadedSource }));
@@ -69,37 +69,37 @@ describe("ast", () => {
 
         const baseSymbols = getSymbols(getState(), base);
         expect(baseSymbols).toMatchSnapshot();
       });
     });
 
     describe("when the source is not loaded", () => {
       it("should return null", async () => {
-        const { getState, dispatch } = createStore(threadFront);
+        const { getState, dispatch } = createStore(mockCommandClient);
         const base = await dispatch(
           actions.newGeneratedSource(makeSource("base.js"))
         );
 
         const baseSymbols = getSymbols(getState(), base);
         expect(baseSymbols).toEqual(null);
       });
     });
 
     describe("when there is no source", () => {
       it("should return null", async () => {
-        const { getState } = createStore(threadFront);
+        const { getState } = createStore(mockCommandClient);
         const baseSymbols = getSymbols(getState());
         expect(baseSymbols).toEqual(null);
       });
     });
 
     describe("frameworks", () => {
       it("should detect react components", async () => {
-        const store = createStore(threadFront, {}, sourceMaps);
+        const store = createStore(mockCommandClient, {}, sourceMaps);
         const { cx, dispatch, getState } = store;
 
         const genSource = await dispatch(
           actions.newGeneratedSource(makeSource("reactComponent.js"))
         );
 
         const source = await dispatch(
           actions.newOriginalSource(makeOriginalSource(genSource))
@@ -108,17 +108,17 @@ describe("ast", () => {
         await dispatch(actions.loadSourceText({ cx, source }));
         const loadedSource = selectors.getSourceFromId(getState(), source.id);
         await dispatch(actions.setSymbols({ cx, source: loadedSource }));
 
         expect(getFramework(getState(), source)).toBe("React");
       });
 
       it("should not give false positive on non react components", async () => {
-        const store = createStore(threadFront);
+        const store = createStore(mockCommandClient);
         const { cx, dispatch, getState } = store;
         const base = await dispatch(
           actions.newGeneratedSource(makeSource("base.js"))
         );
         await dispatch(actions.loadSourceText({ cx, source: base }));
         await dispatch(actions.setSymbols({ cx, source: base }));
 
         expect(getFramework(getState(), base)).toBe(undefined);
--- a/devtools/client/debugger/src/actions/tests/expressions.spec.js
+++ b/devtools/client/debugger/src/actions/tests/expressions.spec.js
@@ -32,18 +32,18 @@ const mockThreadFront = {
             } else {
               resolve("boo");
             }
           })
       )
     ),
   getFrameScopes: async () => {},
   sourceContents: () => ({ source: "", contentType: "text/javascript" }),
-  getBreakpointPositions: async () => ({}),
-  getBreakableLines: async () => [],
+  getSourceActorBreakpointPositions: async () => ({}),
+  getSourceActorBreakableLines: async () => [],
   autocomplete: () => {
     return new Promise(resolve => {
       resolve({
         from: "foo",
         matches: ["toLocaleString", "toSource", "toString", "toolbar", "top"],
         matchProp: "to",
       });
     });
rename from devtools/client/debugger/src/actions/tests/helpers/threadFront.js
rename to devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js
--- a/devtools/client/debugger/src/actions/tests/helpers/threadFront.js
+++ b/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js
@@ -1,19 +1,15 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
-import type {
-  SourceActor,
-  SourceActorLocation,
-  BreakpointOptions,
-} from "../../../types";
+import type { SourceActor } from "../../../types";
 
 export function createSource(name: string, code?: string) {
   name = name.replace(/\..*$/, "");
   return {
     source: code || `function ${name}() {\n  return ${name} \n}`,
     contentType: "text/javascript",
   };
 }
@@ -31,53 +27,28 @@ const sources = [
   "barfoo.js",
   "foo.js",
   "bar.js",
   "base.js",
   "bazz.js",
   "jquery.js",
 ];
 
-export const simpleMockThreadFront = {
-  getBreakpointByLocation: (jest.fn(): any),
-  setBreakpoint: (location: SourceActorLocation, _condition: string) =>
-    Promise.resolve({ id: "hi", actualLocation: location }),
-
-  removeBreakpoint: (_id: string) => Promise.resolve(),
-
-  setBreakpointOptions: (
-    _id: string,
-    _location: SourceActorLocation,
-    _options: BreakpointOptions,
-    _noSliding: boolean
-  ) => Promise.resolve({ sourceId: "a", line: 5 }),
-  sourceContents: ({
-    source,
-  }: SourceActor): Promise<{| source: any, contentType: ?string |}> =>
-    new Promise((resolve, reject) => {
-      if (sources.includes(source)) {
-        resolve(createSource(source));
-      }
-
-      reject(`unknown source: ${source}`);
-    }),
-};
-
-// sources and tabs
-export const sourceThreadFront = {
+export const mockCommandClient = {
   sourceContents: function({
     source,
   }: SourceActor): Promise<{| source: any, contentType: ?string |}> {
     return new Promise((resolve, reject) => {
       if (sources.includes(source)) {
         resolve(createSource(source));
       }
 
       reject(`unknown source: ${source}`);
     });
   },
   setBreakpoint: async () => {},
+  removeBreakpoint: (_id: string) => Promise.resolve(),
   threadFront: async () => {},
   getFrameScopes: async () => {},
   evaluateExpressions: async () => {},
-  getBreakpointPositions: async () => ({}),
-  getBreakableLines: async () => [],
+  getSourceActorBreakpointPositions: async () => ({}),
+  getSourceActorBreakableLines: async () => [],
 };
--- a/devtools/client/debugger/src/actions/tests/navigation.spec.js
+++ b/devtools/client/debugger/src/actions/tests/navigation.spec.js
@@ -22,18 +22,18 @@ const {
   getFileSearchResults,
 } = selectors;
 
 const threadFront = {
   sourceContents: async () => ({
     source: "function foo1() {\n  const foo = 5; return foo;\n}",
     contentType: "text/javascript",
   }),
-  getBreakpointPositions: async () => ({}),
-  getBreakableLines: async () => [],
+  getSourceActorBreakpointPositions: async () => ({}),
+  getSourceActorBreakableLines: async () => [],
   detachWorkers: () => {},
 };
 
 describe("navigation", () => {
   it("connect sets the debuggeeUrl", async () => {
     const { dispatch, getState } = createStore({
       fetchWorkers: () => Promise.resolve([]),
       getMainThread: () => "FakeThread",
--- a/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js
+++ b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js
@@ -5,17 +5,17 @@
 // @flow
 
 // TODO: we would like to mock this in the local tests
 import {
   generateBreakpoint,
   mockPendingBreakpoint,
 } from "./helpers/breakpoints.js";
 
-import { simpleMockThreadFront } from "./helpers/threadFront.js";
+import { mockCommandClient } from "./helpers/mockCommandClient";
 
 import { asyncStore } from "../../utils/prefs";
 
 function loadInitialState(opts = {}) {
   const mockedPendingBreakpoint = mockPendingBreakpoint({ ...opts, column: 2 });
   const id = makePendingLocationId(mockedPendingBreakpoint.location);
   asyncStore.pendingBreakpoints = { [id]: mockedPendingBreakpoint };
 
@@ -42,20 +42,20 @@ import {
   waitForState,
 } from "../../utils/test-head";
 
 import sourceMaps from "devtools-source-map";
 
 import { makePendingLocationId } from "../../utils/breakpoint";
 function mockClient(bpPos = {}) {
   return {
-    ...simpleMockThreadFront,
+    ...mockCommandClient,
 
-    getBreakpointPositions: async () => bpPos,
-    getBreakableLines: async () => [],
+    getSourceActorBreakpointPositions: async () => bpPos,
+    getSourceActorBreakableLines: async () => [],
   };
 }
 
 function mockSourceMaps() {
   return {
     ...sourceMaps,
     getOriginalSourceText: async source => ({
       id: source.id,
--- a/devtools/client/debugger/src/actions/tests/preview.spec.js
+++ b/devtools/client/debugger/src/actions/tests/preview.spec.js
@@ -26,18 +26,18 @@ function waitForPreview(store, expressio
 function mockThreadFront(overrides) {
   return {
     evaluateInFrame: async () => ({ result: {} }),
     getFrameScopes: async () => {},
     sourceContents: async () => ({
       source: "",
       contentType: "text/javascript",
     }),
-    getBreakpointPositions: async () => ({}),
-    getBreakableLines: async () => [],
+    getSourceActorBreakpointPositions: async () => ({}),
+    getSourceActorBreakableLines: async () => [],
     evaluateExpressions: async () => [],
     loadObjectProperties: async () => ({}),
     ...overrides,
   };
 }
 
 function dispatchSetPreview(dispatch, context, expression, target) {
   return dispatch(
--- a/devtools/client/debugger/src/actions/tests/project-text-search.spec.js
+++ b/devtools/client/debugger/src/actions/tests/project-text-search.spec.js
@@ -34,18 +34,18 @@ const sources = {
   "bar:formatted": {
     source: "function bla(x, y) {\n const bar = 4; return 2;\n}",
     contentType: "text/javascript",
   },
 };
 
 const threadFront = {
   sourceContents: async ({ source }) => sources[source],
-  getBreakpointPositions: async () => ({}),
-  getBreakableLines: async () => [],
+  getSourceActorBreakpointPositions: async () => ({}),
+  getSourceActorBreakableLines: async () => [],
 };
 
 describe("project text search", () => {
   it("should add a project text search query", () => {
     const { dispatch, getState, cx } = createStore();
     const mockQuery = "foo";
 
     dispatch(actions.addSearchQuery(cx, mockQuery));
--- a/devtools/client/debugger/src/actions/tests/setProjectDirectoryRoot.spec.js
+++ b/devtools/client/debugger/src/actions/tests/setProjectDirectoryRoot.spec.js
@@ -38,17 +38,17 @@ describe("setProjectDirectoryRoot", () =
     dispatch(actions.setProjectDirectoryRoot(cx, "/example.com/foo"));
     dispatch(actions.clearProjectDirectoryRoot(cx));
     dispatch(actions.setProjectDirectoryRoot(cx, "/example.com/bar"));
     expect(getProjectDirectoryRoot(getState())).toBe("/example.com/bar");
   });
 
   it("should filter sources", async () => {
     const store = createStore({
-      getBreakableLines: async () => [],
+      getSourceActorBreakableLines: async () => [],
     });
     const { dispatch, getState, cx } = store;
     await dispatch(actions.newGeneratedSource(makeSource("js/scopes.js")));
     await dispatch(actions.newGeneratedSource(makeSource("lib/vendor.js")));
 
     dispatch(actions.setProjectDirectoryRoot(cx, "localhost:8000/examples/js"));
 
     const filteredSourcesByThread = getDisplayedSources(getState());
@@ -60,24 +60,24 @@ describe("setProjectDirectoryRoot", () =
       "http://localhost:8000/examples/js/scopes.js"
     );
 
     expect(filteredSources.relativeUrl).toEqual("scopes.js");
   });
 
   it("should update the child directory ", () => {
     const { dispatch, getState, cx } = createStore({
-      getBreakableLines: async () => [],
+      getSourceActorBreakableLines: async () => [],
     });
     dispatch(actions.setProjectDirectoryRoot(cx, "example.com"));
     dispatch(actions.setProjectDirectoryRoot(cx, "example.com/foo/bar"));
     expect(getProjectDirectoryRoot(getState())).toBe("example.com/foo/bar");
   });
 
   it("should update the child directory when domain name is Webpack://", () => {
     const { dispatch, getState, cx } = createStore({
-      getBreakableLines: async () => [],
+      getSourceActorBreakableLines: async () => [],
     });
     dispatch(actions.setProjectDirectoryRoot(cx, "webpack://"));
     dispatch(actions.setProjectDirectoryRoot(cx, "webpack:///app"));
     expect(getProjectDirectoryRoot(getState())).toBe("webpack:///app");
   });
 });
--- a/devtools/client/debugger/src/actions/tests/tabs.spec.js
+++ b/devtools/client/debugger/src/actions/tests/tabs.spec.js
@@ -7,79 +7,79 @@
 import {
   actions,
   selectors,
   createStore,
   makeSource,
 } from "../../utils/test-head";
 const { getSelectedSource, getSourceTabs } = selectors;
 
-import { sourceThreadFront as threadFront } from "./helpers/threadFront.js";
+import { mockCommandClient } from "./helpers/mockCommandClient";
 
 describe("closing tabs", () => {
   it("closing a tab", async () => {
-    const { dispatch, getState, cx } = createStore(threadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const fooSource = await dispatch(
       actions.newGeneratedSource(makeSource("foo.js"))
     );
     await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 }));
     dispatch(actions.closeTab(cx, fooSource));
 
     expect(getSelectedSource(getState())).toBe(undefined);
     expect(getSourceTabs(getState())).toHaveLength(0);
   });
 
   it("closing the inactive tab", async () => {
-    const { dispatch, getState, cx } = createStore(threadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const fooSource = await dispatch(
       actions.newGeneratedSource(makeSource("foo.js"))
     );
     await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
     await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 }));
     await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", line: 1 }));
     dispatch(actions.closeTab(cx, fooSource));
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.id).toBe("bar.js");
     expect(getSourceTabs(getState())).toHaveLength(1);
   });
 
   it("closing the only tab", async () => {
-    const { dispatch, getState, cx } = createStore(threadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     const fooSource = await dispatch(
       actions.newGeneratedSource(makeSource("foo.js"))
     );
     await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 }));
     dispatch(actions.closeTab(cx, fooSource));
 
     expect(getSelectedSource(getState())).toBe(undefined);
     expect(getSourceTabs(getState())).toHaveLength(0);
   });
 
   it("closing the active tab", async () => {
-    const { dispatch, getState, cx } = createStore(threadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
     const barSource = await dispatch(
       actions.newGeneratedSource(makeSource("bar.js"))
     );
     await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 }));
     await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", line: 1 }));
     await dispatch(actions.closeTab(cx, barSource));
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.id).toBe("foo.js");
     expect(getSourceTabs(getState())).toHaveLength(1);
   });
 
   it("closing many inactive tabs", async () => {
-    const { dispatch, getState, cx } = createStore(threadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
     await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
     await dispatch(actions.newGeneratedSource(makeSource("bazz.js")));
     await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 }));
     await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", line: 1 }));
     await dispatch(
       actions.selectLocation(cx, { sourceId: "bazz.js", line: 1 })
@@ -92,17 +92,17 @@ describe("closing tabs", () => {
     dispatch(actions.closeTabs(cx, tabs));
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.id).toBe("bazz.js");
     expect(getSourceTabs(getState())).toHaveLength(1);
   });
 
   it("closing many tabs including the active tab", async () => {
-    const { dispatch, getState, cx } = createStore(threadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
     await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
     await dispatch(actions.newGeneratedSource(makeSource("bazz.js")));
     await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 }));
     await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", line: 1 }));
     await dispatch(
       actions.selectLocation(cx, { sourceId: "bazz.js", line: 1 })
@@ -114,17 +114,17 @@ describe("closing tabs", () => {
     await dispatch(actions.closeTabs(cx, tabs));
 
     const selected = getSelectedSource(getState());
     expect(selected && selected.id).toBe("foo.js");
     expect(getSourceTabs(getState())).toHaveLength(1);
   });
 
   it("closing all the tabs", async () => {
-    const { dispatch, getState, cx } = createStore(threadFront);
+    const { dispatch, getState, cx } = createStore(mockCommandClient);
 
     await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
     await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
     await dispatch(actions.selectLocation(cx, { sourceId: "foo.js", line: 1 }));
     await dispatch(actions.selectLocation(cx, { sourceId: "bar.js", line: 1 }));
     await dispatch(
       actions.closeTabs(cx, [
         "http://localhost:8000/examples/foo.js",
--- a/devtools/client/debugger/src/actions/types/SourceAction.js
+++ b/devtools/client/debugger/src/actions/types/SourceAction.js
@@ -1,16 +1,17 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import type { SourceId, Source, SourceLocation, Context } from "../../types";
 import type { PromiseAction } from "../utils/middleware/promise";
+import type { SourceBase } from "../../reducers/sources";
 
 export type LoadSourceAction = PromiseAction<
   {|
     +type: "LOAD_SOURCE_TEXT",
     +cx: Context,
     +sourceId: string,
     +epoch: number,
   |},
@@ -19,22 +20,22 @@ export type LoadSourceAction = PromiseAc
     contentType: string | void,
   }
 >;
 export type SourceAction =
   | LoadSourceAction
   | {|
       +type: "ADD_SOURCE",
       +cx: Context,
-      +source: Source,
+      +source: SourceBase,
     |}
   | {|
       +type: "ADD_SOURCES",
       +cx: Context,
-      +sources: Array<Source>,
+      +sources: Array<SourceBase>,
     |}
   | {|
       +type: "CLEAR_SOURCE_MAP_URL",
       +cx: Context,
       +sourceId: SourceId,
     |}
   | {|
       +type: "SET_SELECTED_LOCATION",
@@ -71,13 +72,13 @@ export type SourceAction =
       +tabs: any,
     |}
   | {|
       +type: "CLOSE_TABS",
       +sources: Array<Source>,
       +tabs: any,
     |}
   | {|
-      type: "SET_BREAKABLE_LINES",
+      type: "SET_ORIGINAL_BREAKABLE_LINES",
       +cx: Context,
       breakableLines: number[],
       sourceId: string,
     |};
--- a/devtools/client/debugger/src/actions/types/SourceActorAction.js
+++ b/devtools/client/debugger/src/actions/types/SourceActorAction.js
@@ -1,20 +1,43 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
-import type { SourceActor } from "../../reducers/source-actors.js";
+import { type PromiseAction } from "../utils/middleware/promise";
+import type {
+  SourceActorId,
+  SourceActor,
+} from "../../reducers/source-actors.js";
 
 export type SourceActorsInsertAction = {|
   type: "INSERT_SOURCE_ACTORS",
   items: Array<SourceActor>,
 |};
 export type SourceActorsRemoveAction = {|
   type: "REMOVE_SOURCE_ACTORS",
   items: Array<SourceActor>,
 |};
 
+export type SourceActorBreakpointColumnsAction = PromiseAction<
+  {|
+    type: "SET_SOURCE_ACTOR_BREAKPOINT_COLUMNS",
+    sourceId: SourceActorId,
+    line: number,
+  |},
+  Array<number>
+>;
+
+export type SourceActorBreakableLinesAction = PromiseAction<
+  {|
+    type: "SET_SOURCE_ACTOR_BREAKABLE_LINES",
+    sourceId: SourceActorId,
+  |},
+  Array<number>
+>;
+
 export type SourceActorAction =
   | SourceActorsInsertAction
-  | SourceActorsRemoveAction;
+  | SourceActorsRemoveAction
+  | SourceActorBreakpointColumnsAction
+  | SourceActorBreakableLinesAction;
--- a/devtools/client/debugger/src/actions/utils/middleware/promise.js
+++ b/devtools/client/debugger/src/actions/utils/middleware/promise.js
@@ -1,17 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import { fromPairs, toPairs } from "lodash";
 import { executeSoon } from "../../../utils/DevToolsUtils";
-
 import type { ThunkArgs } from "../../types";
 
 type BasePromiseAction = {|
   +"@@dispatch/promise": Promise<mixed>,
 |};
 
 export type StartPromiseAction = {|
   ...BasePromiseAction,
@@ -25,17 +24,35 @@ export type DonePromiseAction = {|
 |};
 
 export type ErrorPromiseAction = {|
   ...BasePromiseAction,
   +status: "error",
   +error: any,
 |};
 
-export type PromiseAction<Action, Value = any> =
+import {
+  pending,
+  rejected,
+  fulfilled,
+  type AsyncValue,
+} from "../../../utils/async-value";
+export function asyncActionAsValue<T>(
+  action: PromiseAction<mixed, T>
+): AsyncValue<T> {
+  if (action.status === "start") {
+    return pending();
+  }
+  if (action.status === "error") {
+    return rejected(action.error);
+  }
+  return fulfilled(action.value);
+}
+
+export type PromiseAction<+Action, Value = any> =
   // | {| ...Action, "@@dispatch/promise": Promise<Object> |}
   | {|
       ...BasePromiseAction,
       ...Action,
       +status: "start",
       value: void,
     |}
   | {|
--- a/devtools/client/debugger/src/client/firefox/commands.js
+++ b/devtools/client/debugger/src/client/firefox/commands.js
@@ -433,65 +433,48 @@ async function fetchWorkers(): Promise<W
   targets = newTargets;
   return Object.keys(targets).map(id => createTarget(id, targets[id]));
 }
 
 function getMainThread() {
   return currentThreadFront.actor;
 }
 
-async function getBreakpointPositions(
-  actors: Array<SourceActor>,
-  range: ?Range
-): Promise<{ [string]: number[] }> {
-  const sourcePositions = {};
-
-  for (const { thread, actor } of actors) {
-    const sourceThreadFront = lookupThreadFront(thread);
-    const sourceFront = sourceThreadFront.source({ actor });
-    const positions = await sourceFront.getBreakpointPositionsCompressed(range);
-
-    for (const line of Object.keys(positions)) {
-      let columns = positions[line];
-      const existing = sourcePositions[line];
-      if (existing) {
-        columns = [...new Set([...existing, ...columns])];
-      }
-
-      sourcePositions[line] = columns;
-    }
-  }
-  return sourcePositions;
+async function getSourceActorBreakpointPositions(
+  { thread, actor }: SourceActor,
+  range: Range
+): Promise<{ [number]: number[] }> {
+  const sourceThreadFront = lookupThreadFront(thread);
+  const sourceFront = sourceThreadFront.source({ actor });
+  return sourceFront.getBreakpointPositionsCompressed(range);
 }
 
-async function getBreakableLines(actors: Array<SourceActor>) {
-  let lines = [];
-  for (const { thread, actor } of actors) {
-    const sourceThreadFront = lookupThreadFront(thread);
-    const sourceFront = sourceThreadFront.source({ actor });
-    let actorLines = [];
-    try {
-      actorLines = await sourceFront.getBreakableLines();
-    } catch (e) {
-      // Handle backward compatibility
-      if (
-        e.message &&
-        e.message.match(/does not recognize the packet type getBreakableLines/)
-      ) {
-        const pos = await sourceFront.getBreakpointPositionsCompressed();
-        actorLines = Object.keys(pos).map(line => Number(line));
-      } else if (!e.message || !e.message.match(/Connection closed/)) {
-        throw e;
-      }
+async function getSourceActorBreakableLines({
+  thread,
+  actor,
+}: SourceActor): Promise<Array<number>> {
+  const sourceThreadFront = lookupThreadFront(thread);
+  const sourceFront = sourceThreadFront.source({ actor });
+  let actorLines = [];
+  try {
+    actorLines = await sourceFront.getBreakableLines();
+  } catch (e) {
+    // Handle backward compatibility
+    if (
+      e.message &&
+      e.message.match(/does not recognize the packet type getBreakableLines/)
+    ) {
+      const pos = await sourceFront.getBreakpointPositionsCompressed();
+      actorLines = Object.keys(pos).map(line => Number(line));
+    } else if (!e.message || !e.message.match(/Connection closed/)) {
+      throw e;
     }
-
-    lines = [...new Set([...lines, ...actorLines])];
   }
 
-  return lines;
+  return actorLines;
 }
 
 const clientCommands = {
   autocomplete,
   blackBox,
   createObjectClient,
   loadObjectProperties,
   releaseActor,
@@ -501,18 +484,18 @@ const clientCommands = {
   stepIn,
   stepOut,
   stepOver,
   rewind,
   reverseStepOver,
   breakOnNext,
   sourceContents,
   getSourceForActor,
-  getBreakpointPositions,
-  getBreakableLines,
+  getSourceActorBreakpointPositions,
+  getSourceActorBreakableLines,
   hasBreakpoint,
   setBreakpoint,
   setXHRBreakpoint,
   removeXHRBreakpoint,
   removeBreakpoint,
   evaluate,
   evaluateInFrame,
   evaluateExpressions,
--- a/devtools/client/debugger/src/components/Editor/EditorMenu.js
+++ b/devtools/client/debugger/src/components/Editor/EditorMenu.js
@@ -25,53 +25,53 @@ import type SourceEditor from "../../uti
 type Props = {
   cx: ThreadContext,
   contextMenu: ?MouseEvent,
   editorActions: EditorItemActions,
   clearContextMenu: () => void,
   editor: SourceEditor,
   hasPrettySource: boolean,
   isPaused: boolean,
-  selectedSourceWithContent: SourceWithContent,
+  selectedSource: SourceWithContent,
 };
 
 class EditorMenu extends Component<Props> {
   props: Props;
 
   componentWillUpdate(nextProps: Props) {
     this.props.clearContextMenu();
     if (nextProps.contextMenu) {
       this.showMenu(nextProps);
     }
   }
 
   showMenu(props) {
     const {
       cx,
       editor,
-      selectedSourceWithContent,
+      selectedSource,
       editorActions,
       hasPrettySource,
       isPaused,
       contextMenu: event,
     } = props;
 
     const location = getSourceLocationFromMouseEvent(
       editor,
-      selectedSourceWithContent.source,
+      selectedSource,
       // Use a coercion, as contextMenu is optional
       (event: any)
     );
 
     showMenu(
       event,
       editorMenuItems({
         cx,
         editorActions,
-        selectedSourceWithContent,
+        selectedSource,
         hasPrettySource,
         location,
         isPaused,
         selectionText: editor.codeMirror.getSelection().trim(),
         isTextSelected: editor.codeMirror.somethingSelected(),
       })
     );
   }
@@ -79,20 +79,17 @@ class EditorMenu extends Component<Props
   render() {
     return null;
   }
 }
 
 const mapStateToProps = (state, props) => ({
   cx: getThreadContext(state),
   isPaused: getIsPaused(state, getCurrentThread(state)),
-  hasPrettySource: !!getPrettySource(
-    state,
-    props.selectedSourceWithContent.source.id
-  ),
+  hasPrettySource: !!getPrettySource(state, props.selectedSource.id),
 });
 
 const mapDispatchToProps = dispatch => ({
   editorActions: editorItemActions(dispatch),
 });
 
 export default connect(
   mapStateToProps,
--- a/devtools/client/debugger/src/components/Editor/Footer.js
+++ b/devtools/client/debugger/src/components/Editor/Footer.js
@@ -33,17 +33,17 @@ import "./Footer.css";
 
 type CursorPosition = {
   line: number,
   column: number,
 };
 
 type Props = {
   cx: Context,
-  selectedSourceWithContent: ?SourceWithContent,
+  selectedSource: ?SourceWithContent,
   mappedSource: Source,
   endPanelCollapsed: boolean,
   horizontal: boolean,
   togglePrettyPrint: typeof actions.togglePrettyPrint,
   toggleBlackBox: typeof actions.toggleBlackBox,
   jumpToMappedLocation: typeof actions.jumpToMappedLocation,
   togglePaneCollapse: typeof actions.togglePaneCollapse,
 };
@@ -79,93 +79,86 @@ class SourceFooter extends PureComponent
     if (toggle === true) {
       eventDoc.CodeMirror.on("cursorActivity", this.onCursorChange);
     } else {
       eventDoc.CodeMirror.off("cursorActivity", this.onCursorChange);
     }
   }
 
   prettyPrintButton() {
-    const { cx, selectedSourceWithContent, togglePrettyPrint } = this.props;
+    const { cx, selectedSource, togglePrettyPrint } = this.props;
 
-    if (!selectedSourceWithContent) {
+    if (!selectedSource) {
       return;
     }
 
-    if (
-      !selectedSourceWithContent.content &&
-      selectedSourceWithContent.source.isPrettyPrinted
-    ) {
+    if (!selectedSource.content && selectedSource.isPrettyPrinted) {
       return (
         <div className="loader" key="pretty-loader">
           <AccessibleImage className="loader" />
         </div>
       );
     }
 
     const sourceContent =
-      selectedSourceWithContent.content &&
-      isFulfilled(selectedSourceWithContent.content)
-        ? selectedSourceWithContent.content.value
+      selectedSource.content && isFulfilled(selectedSource.content)
+        ? selectedSource.content.value
         : null;
     if (
       !shouldShowPrettyPrint(
-        selectedSourceWithContent.source,
+        selectedSource,
         sourceContent || { type: "text", value: "", contentType: undefined }
       )
     ) {
       return;
     }
 
     const tooltip = L10N.getStr("sourceTabs.prettyPrint");
-    const sourceLoaded = !!selectedSourceWithContent.content;
+    const sourceLoaded = !!selectedSource.content;
 
     const type = "prettyPrint";
     return (
       <button
-        onClick={() =>
-          togglePrettyPrint(cx, selectedSourceWithContent.source.id)
-        }
+        onClick={() => togglePrettyPrint(cx, selectedSource.id)}
         className={classnames("action", type, {
           active: sourceLoaded,
-          pretty: isPretty(selectedSourceWithContent.source),
+          pretty: isPretty(selectedSource),
         })}
         key={type}
         title={tooltip}
         aria-label={tooltip}
       >
         <AccessibleImage className={type} />
       </button>
     );
   }
 
   blackBoxButton() {
-    const { cx, selectedSourceWithContent, toggleBlackBox } = this.props;
-    const sourceLoaded =
-      selectedSourceWithContent && selectedSourceWithContent.content;
+    const { cx, selectedSource, toggleBlackBox } = this.props;
+    const sourceLoaded = selectedSource && selectedSource.content;
 
-    if (!selectedSourceWithContent) {
+    if (!selectedSource) {
       return;
     }
 
-    if (!shouldBlackbox(selectedSourceWithContent.source)) {
+    if (!shouldBlackbox(selectedSource)) {
       return;
     }
 
-    const blackboxed = selectedSourceWithContent.source.isBlackBoxed;
+    const blackboxed = selectedSource.isBlackBoxed;
 
     const tooltip = blackboxed
       ? L10N.getStr("sourceFooter.unblackbox")
       : L10N.getStr("sourceFooter.blackbox");
 
     const type = "black-box";
 
     return (
       <button
-        onClick={() => toggleBlackBox(cx, selectedSourceWithContent.source)}
+        onClick={() => toggleBlackBox(cx, selectedSource)}
         className={classnames("action", type, {
           active: sourceLoaded,
           blackboxed: blackboxed,
         })}
         key={type}
         title={tooltip}
         aria-label={tooltip}
       >
@@ -198,35 +191,31 @@ class SourceFooter extends PureComponent
     return commands.length ? <div className="commands">{commands}</div> : null;
   }
 
   renderSourceSummary() {
     const {
       cx,
       mappedSource,
       jumpToMappedLocation,
-      selectedSourceWithContent,
+      selectedSource,
     } = this.props;
 
-    if (
-      !mappedSource ||
-      !selectedSourceWithContent ||
-      !isOriginal(selectedSourceWithContent.source)
-    ) {
+    if (!mappedSource || !selectedSource || !isOriginal(selectedSource)) {
       return null;
     }
 
     const filename = getFilename(mappedSource);
     const tooltip = L10N.getFormatStr(
       "sourceFooter.mappedSourceTooltip",
       filename
     );
     const title = L10N.getFormatStr("sourceFooter.mappedSource", filename);
     const mappedSourceLocation = {
-      sourceId: selectedSourceWithContent.source.id,
+      sourceId: selectedSource.id,
       line: 1,
       column: 1,
     };
     return (
       <button
         className="mapped-source"
         onClick={() => jumpToMappedLocation(cx, mappedSourceLocation)}
         title={tooltip}
@@ -237,17 +226,17 @@ class SourceFooter extends PureComponent
   }
 
   onCursorChange = event => {
     const { line, ch } = event.doc.getCursor();
     this.setState({ cursorPosition: { line, column: ch } });
   };
 
   renderCursorPosition() {
-    if (!this.props.selectedSourceWithContent) {
+    if (!this.props.selectedSource) {
       return null;
     }
 
     const { line, column } = this.state.cursorPosition;
 
     const text = L10N.getFormatStr(
       "sourceFooter.currentCursorPosition",
       line + 1,
@@ -275,28 +264,25 @@ class SourceFooter extends PureComponent
           {this.renderToggleButton()}
         </div>
       </div>
     );
   }
 }
 
 const mapStateToProps = state => {
-  const selectedSourceWithContent = getSelectedSourceWithContent(state);
+  const selectedSource = getSelectedSourceWithContent(state);
 
   return {
     cx: getContext(state),
-    selectedSourceWithContent,
-    mappedSource: getGeneratedSource(
-      state,
-      selectedSourceWithContent && selectedSourceWithContent.source
-    ),
+    selectedSource,
+    mappedSource: getGeneratedSource(state, selectedSource),
     prettySource: getPrettySource(
       state,
-      selectedSourceWithContent ? selectedSourceWithContent.source.id : null
+      selectedSource ? selectedSource.id : null
     ),
     endPanelCollapsed: getPaneCollapse(state, "end"),
   };
 };
 
 export default connect(
   mapStateToProps,
   {
--- a/devtools/client/debugger/src/components/Editor/HighlightLine.js
+++ b/devtools/client/debugger/src/components/Editor/HighlightLine.js
@@ -23,115 +23,103 @@ import type {
   SourceDocuments,
 } from "../../types";
 import type { Command } from "../../reducers/types";
 
 type Props = {
   pauseCommand: Command,
   selectedFrame: Frame,
   selectedLocation: SourceLocation,
-  selectedSourceWithContent: ?SourceWithContent,
+  selectedSource: ?SourceWithContent,
 };
 
 function isDebugLine(selectedFrame: Frame, selectedLocation: SourceLocation) {
   if (!selectedFrame) {
     return;
   }
 
   return (
     selectedFrame.location.sourceId == selectedLocation.sourceId &&
     selectedFrame.location.line == selectedLocation.line
   );
 }
 
-function isDocumentReady(
-  selectedSourceWithContent: ?SourceWithContent,
-  selectedLocation
-) {
+function isDocumentReady(selectedSource: ?SourceWithContent, selectedLocation) {
   return (
     selectedLocation &&
-    selectedSourceWithContent &&
-    selectedSourceWithContent.content &&
+    selectedSource &&
+    selectedSource.content &&
     hasDocument(selectedLocation.sourceId)
   );
 }
 
 export class HighlightLine extends Component<Props> {
   isStepping: boolean = false;
   previousEditorLine: ?number = null;
 
   shouldComponentUpdate(nextProps: Props) {
-    const { selectedLocation, selectedSourceWithContent } = nextProps;
-    return this.shouldSetHighlightLine(
-      selectedLocation,
-      selectedSourceWithContent
-    );
+    const { selectedLocation, selectedSource } = nextProps;
+    return this.shouldSetHighlightLine(selectedLocation, selectedSource);
   }
 
   componentDidUpdate(prevProps: Props) {
     this.completeHighlightLine(prevProps);
   }
 
   componentDidMount() {
     this.completeHighlightLine(null);
   }
 
   shouldSetHighlightLine(
     selectedLocation: SourceLocation,
-    selectedSourceWithContent: ?SourceWithContent
+    selectedSource: ?SourceWithContent
   ) {
     const { sourceId, line } = selectedLocation;
     const editorLine = toEditorLine(sourceId, line);
 
-    if (!isDocumentReady(selectedSourceWithContent, selectedLocation)) {
+    if (!isDocumentReady(selectedSource, selectedLocation)) {
       return false;
     }
 
     if (this.isStepping && editorLine === this.previousEditorLine) {
       return false;
     }
 
     return true;
   }
 
   completeHighlightLine(prevProps: Props | null) {
     const {
       pauseCommand,
       selectedLocation,
       selectedFrame,
-      selectedSourceWithContent,
+      selectedSource,
     } = this.props;
     if (pauseCommand) {
       this.isStepping = true;
     }
 
     startOperation();
     if (prevProps) {
       this.clearHighlightLine(
         prevProps.selectedLocation,
-        prevProps.selectedSourceWithContent
+        prevProps.selectedSource
       );
     }
-    this.setHighlightLine(
-      selectedLocation,
-      selectedFrame,
-      selectedSourceWithContent
-    );
+    this.setHighlightLine(selectedLocation, selectedFrame, selectedSource);
     endOperation();
   }
 
   setHighlightLine(
     selectedLocation: SourceLocation,
     selectedFrame: Frame,
-    selectedSourceWithContent: ?SourceWithContent
+    selectedSource: ?SourceWithContent
   ) {
     const { sourceId, line } = selectedLocation;
-    if (
-      !this.shouldSetHighlightLine(selectedLocation, selectedSourceWithContent)
-    ) {
+    if (!this.shouldSetHighlightLine(selectedLocation, selectedSource)) {
       return;
     }
 
     this.isStepping = false;
     const editorLine = toEditorLine(sourceId, line);
     this.previousEditorLine = editorLine;
 
     if (!line || isDebugLine(selectedFrame, selectedLocation)) {
@@ -161,19 +149,19 @@ export class HighlightLine extends Compo
     setTimeout(
       () => doc && doc.removeLineClass(editorLine, "line", "highlight-line"),
       duration
     );
   }
 
   clearHighlightLine(
     selectedLocation: SourceLocation,
-    selectedSourceWithContent: ?SourceWithContent
+    selectedSource: ?SourceWithContent
   ) {
-    if (!isDocumentReady(selectedSourceWithContent, selectedLocation)) {
+    if (!isDocumentReady(selectedSource, selectedLocation)) {
       return;
     }
 
     const { line, sourceId } = selectedLocation;
     const editorLine = toEditorLine(sourceId, line);
     const doc = getDocument(sourceId);
     doc.removeLineClass(editorLine, "line", "highlight-line");
   }
@@ -182,10 +170,10 @@ export class HighlightLine extends Compo
     return null;
   }
 }
 
 export default connect(state => ({
   pauseCommand: getPauseCommand(state, getCurrentThread(state)),
   selectedFrame: getVisibleSelectedFrame(state),
   selectedLocation: getSelectedLocation(state),
-  selectedSourceWithContent: getSelectedSourceWithContent(state),
+  selectedSource: getSelectedSourceWithContent(state),
 }))(HighlightLine);
--- a/devtools/client/debugger/src/components/Editor/index.js
+++ b/devtools/client/debugger/src/components/Editor/index.js
@@ -90,17 +90,17 @@ import type {
 
 const cssVars = {
   searchbarHeight: "var(--editor-searchbar-height)",
 };
 
 export type Props = {
   cx: ThreadContext,
   selectedLocation: ?SourceLocation,
-  selectedSourceWithContent: ?SourceWithContent,
+  selectedSource: ?SourceWithContent,
   searchOn: boolean,
   startPanelSize: number,
   endPanelSize: number,
   conditionalPanelLocation: SourceLocation,
   symbols: SymbolDeclarations,
   isPaused: boolean,
   skipPausing: boolean,
 
@@ -134,30 +134,27 @@ class Editor extends PureComponent<Props
       editor: (null: any),
       contextMenu: null,
     };
   }
 
   componentWillReceiveProps(nextProps: Props) {
     let editor = this.state.editor;
 
-    if (!this.state.editor && nextProps.selectedSourceWithContent) {
+    if (!this.state.editor && nextProps.selectedSource) {
       editor = this.setupEditor();
     }
 
     startOperation();
     this.setText(nextProps, editor);
     this.setSize(nextProps, editor);
     this.scrollToLocation(nextProps, editor);
     endOperation();
 
-    if (
-      this.props.selectedSourceWithContent !=
-      nextProps.selectedSourceWithContent
-    ) {
+    if (this.props.selectedSource != nextProps.selectedSource) {
       this.props.updateViewport();
       resizeBreakpointGutter(editor.codeMirror);
       resizeToggleButton(editor.codeMirror);
     }
   }
 
   setupEditor() {
     const editor = getEditor();
@@ -225,21 +222,21 @@ class Editor extends PureComponent<Props
       L10N.getStr("toggleCondPanel.logPoint.key"),
       this.onToggleConditionalPanel
     );
     shortcuts.on(L10N.getStr("sourceTabs.closeTab.key"), this.onClosePress);
     shortcuts.on("Esc", this.onEscape);
   }
 
   onClosePress = (key, e: KeyboardEvent) => {
-    const { cx, selectedSourceWithContent } = this.props;
-    if (selectedSourceWithContent) {
+    const { cx, selectedSource } = this.props;
+    if (selectedSource) {
       e.preventDefault();
       e.stopPropagation();
-      this.props.closeTab(cx, selectedSourceWithContent.source);
+      this.props.closeTab(cx, selectedSource);
     }
   };
 
   componentWillUnmount() {
     if (this.state.editor) {
       this.state.editor.destroy();
       this.state.editor.codeMirror.off("scroll", this.onEditorScroll);
       this.setState({ editor: (null: any) });
@@ -249,23 +246,23 @@ class Editor extends PureComponent<Props
     shortcuts.off(L10N.getStr("sourceTabs.closeTab.key"));
     shortcuts.off(L10N.getStr("toggleBreakpoint.key"));
     shortcuts.off(L10N.getStr("toggleCondPanel.breakpoint.key"));
     shortcuts.off(L10N.getStr("toggleCondPanel.logPoint.key"));
   }
 
   getCurrentLine() {
     const { codeMirror } = this.state.editor;
-    const { selectedSourceWithContent } = this.props;
-    if (!selectedSourceWithContent) {
+    const { selectedSource } = this.props;
+    if (!selectedSource) {
       return;
     }
 
     const line = getCursorLine(codeMirror);
-    return toSourceLine(selectedSourceWithContent.source.id, line);
+    return toSourceLine(selectedSource.id, line);
   }
 
   onToggleBreakpoint = (key, e: KeyboardEvent) => {
     e.preventDefault();
     e.stopPropagation();
 
     const line = this.getCurrentLine();
     if (typeof line !== "number") {
@@ -326,35 +323,35 @@ class Editor extends PureComponent<Props
   };
 
   openMenu(event: MouseEvent) {
     event.stopPropagation();
     event.preventDefault();
 
     const {
       cx,
-      selectedSourceWithContent,
+      selectedSource,
       breakpointActions,
       editorActions,
       isPaused,
       conditionalPanelLocation,
       closeConditionalPanel,
     } = this.props;
     const { editor } = this.state;
-    if (!selectedSourceWithContent || !editor) {
+    if (!selectedSource || !editor) {
       return;
     }
 
     // only allow one conditionalPanel location.
     if (conditionalPanelLocation) {
       closeConditionalPanel();
     }
 
     const target: Element = (event.target: any);
-    const { id: sourceId } = selectedSourceWithContent.source;
+    const { id: sourceId } = selectedSource;
     const line = lineAtHeight(editor, sourceId, event);
 
     if (typeof line != "number") {
       return;
     }
 
     const location = { line, column: undefined, sourceId };
 
@@ -380,136 +377,126 @@ class Editor extends PureComponent<Props
   onGutterClick = (
     cm: Object,
     line: number,
     gutter: string,
     ev: MouseEvent
   ) => {
     const {
       cx,
-      selectedSourceWithContent,
+      selectedSource,
       conditionalPanelLocation,
       closeConditionalPanel,
       addBreakpointAtLine,
       continueToHere,
       toggleBlackBox,
     } = this.props;
 
     // ignore right clicks in the gutter
-    if (
-      (ev.ctrlKey && ev.button === 0) ||
-      ev.button === 2 ||
-      !selectedSourceWithContent
-    ) {
+    if ((ev.ctrlKey && ev.button === 0) || ev.button === 2 || !selectedSource) {
       return;
     }
 
     // if user clicks gutter to set breakpoint on blackboxed source, un-blackbox the source.
-    if (
-      selectedSourceWithContent &&
-      selectedSourceWithContent.source.isBlackBoxed
-    ) {
-      toggleBlackBox(cx, selectedSourceWithContent.source);
+    if (selectedSource && selectedSource.isBlackBoxed) {
+      toggleBlackBox(cx, selectedSource);
     }
 
     if (conditionalPanelLocation) {
       return closeConditionalPanel();
     }
 
     if (gutter === "CodeMirror-foldgutter") {
       return;
     }
 
-    const sourceLine = toSourceLine(selectedSourceWithContent.source.id, line);
+    const sourceLine = toSourceLine(selectedSource.id, line);
     if (typeof sourceLine !== "number") {
       return;
     }
 
     if (ev.metaKey) {
       return continueToHere(cx, sourceLine);
     }
 
     return addBreakpointAtLine(cx, sourceLine, ev.altKey, ev.shiftKey);
   };
 
   onGutterContextMenu = (event: MouseEvent) => {
     return this.openMenu(event);
   };
 
   onClick(e: MouseEvent) {
-    const { cx, selectedSourceWithContent, jumpToMappedLocation } = this.props;
+    const { cx, selectedSource, jumpToMappedLocation } = this.props;
 
-    if (selectedSourceWithContent && e.metaKey && e.altKey) {
+    if (selectedSource && e.metaKey && e.altKey) {
       const sourceLocation = getSourceLocationFromMouseEvent(
         this.state.editor,
-        selectedSourceWithContent.source,
+        selectedSource,
         e
       );
       jumpToMappedLocation(cx, sourceLocation);
     }
   }
 
   toggleConditionalPanel = (line, log: boolean = false) => {
     const {
       conditionalPanelLocation,
       closeConditionalPanel,
       openConditionalPanel,
-      selectedSourceWithContent,
+      selectedSource,
     } = this.props;
 
     if (conditionalPanelLocation) {
       return closeConditionalPanel();
     }
 
-    if (!selectedSourceWithContent) {
+    if (!selectedSource) {
       return;
     }
 
     return openConditionalPanel(
       {
         line: line,
-        sourceId: selectedSourceWithContent.source.id,
-        sourceUrl: selectedSourceWithContent.source.url,
+        sourceId: selectedSource.id,
+        sourceUrl: selectedSource.url,
       },
       log
     );
   };
 
   shouldScrollToLocation(nextProps, editor) {
-    const { selectedLocation, selectedSourceWithContent } = this.props;
+    const { selectedLocation, selectedSource } = this.props;
     if (
       !editor ||
-      !nextProps.selectedSourceWithContent ||
+      !nextProps.selectedSource ||
       !nextProps.selectedLocation ||
       !nextProps.selectedLocation.line ||
-      !nextProps.selectedSourceWithContent.content
+      !nextProps.selectedSource.content
     ) {
       return false;
     }
 
     const isFirstLoad =
-      (!selectedSourceWithContent || !selectedSourceWithContent.content) &&
-      nextProps.selectedSourceWithContent.content;
+      (!selectedSource || !selectedSource.content) &&
+      nextProps.selectedSource.content;
     const locationChanged = selectedLocation !== nextProps.selectedLocation;
     const symbolsChanged = nextProps.symbols != this.props.symbols;
 
     return isFirstLoad || locationChanged || symbolsChanged;
   }
 
   scrollToLocation(nextProps, editor) {
-    const { selectedLocation, selectedSourceWithContent } = nextProps;
+    const { selectedLocation, selectedSource } = nextProps;
 
     if (selectedLocation && this.shouldScrollToLocation(nextProps, editor)) {
       let { line, column } = toEditorPosition(selectedLocation);
 
-      if (
-        selectedSourceWithContent &&
-        hasDocument(selectedSourceWithContent.source.id)
-      ) {
-        const doc = getDocument(selectedSourceWithContent.source.id);
+      if (selectedSource && hasDocument(selectedSource.id)) {
+        const doc = getDocument(selectedSource.id);
         const lineText: ?string = doc.getLine(line);
         column = Math.max(column, getIndentation(lineText));
       }
 
       scrollToColumn(editor.codeMirror, line, column);
     }
   }
 
@@ -522,44 +509,44 @@ class Editor extends PureComponent<Props
       nextProps.startPanelSize !== this.props.startPanelSize ||
       nextProps.endPanelSize !== this.props.endPanelSize
     ) {
       editor.codeMirror.setSize();
     }
   }
 
   setText(props, editor) {
-    const { selectedSourceWithContent, symbols } = props;
+    const { selectedSource, symbols } = props;
 
     if (!editor) {
       return;
     }
 
     // check if we previously had a selected source
-    if (!selectedSourceWithContent) {
+    if (!selectedSource) {
       return this.clearEditor();
     }
 
-    if (!selectedSourceWithContent.content) {
+    if (!selectedSource.content) {
       return showLoading(editor);
     }
 
-    if (selectedSourceWithContent.content.state === "rejected") {
-      let { value } = selectedSourceWithContent.content;
+    if (selectedSource.content.state === "rejected") {
+      let { value } = selectedSource.content;
       if (typeof value !== "string") {
         value = "Unexpected source error";
       }
 
       return this.showErrorMessage(value);
     }
 
     return showSourceText(
       editor,
-      selectedSourceWithContent.source,
-      selectedSourceWithContent.content.value,
+      selectedSource,
+      selectedSource.content.value,
       symbols
     );
   }
 
   clearEditor() {
     const { editor } = this.state;
     if (!editor) {
       return;
@@ -589,78 +576,69 @@ class Editor extends PureComponent<Props
     return {
       height: "100%",
     };
   }
 
   renderItems() {
     const {
       cx,
-      selectedSourceWithContent,
+      selectedSource,
       conditionalPanelLocation,
       isPaused,
     } = this.props;
     const { editor, contextMenu } = this.state;
 
-    if (
-      !selectedSourceWithContent ||
-      !editor ||
-      !getDocument(selectedSourceWithContent.source.id)
-    ) {
+    if (!selectedSource || !editor || !getDocument(selectedSource.id)) {
       return null;
     }
 
     return (
       <div>
         <DebugLine editor={editor} />
         <HighlightLine />
         <EmptyLines editor={editor} />
         <Breakpoints editor={editor} cx={cx} />
         <Preview editor={editor} editorRef={this.$editorWrapper} />
         <HighlightLines editor={editor} />
         {
           <EditorMenu
             editor={editor}
             contextMenu={contextMenu}
             clearContextMenu={this.clearContextMenu}
-            selectedSourceWithContent={selectedSourceWithContent}
+            selectedSource={selectedSource}
           />
         }
         {conditionalPanelLocation ? <ConditionalPanel editor={editor} /> : null}
         {features.columnBreakpoints ? (
           <ColumnBreakpoints editor={editor} />
         ) : null}
         {isPaused && features.inlinePreview ? (
-          <InlinePreviews
-            editor={editor}
-            selectedSource={selectedSourceWithContent.source}
-          />
+          <InlinePreviews editor={editor} selectedSource={selectedSource} />
         ) : null}
       </div>
     );
   }
 
   renderSearchBar() {
     const { editor } = this.state;
 
-    if (!this.props.selectedSourceWithContent) {
+    if (!this.props.selectedSource) {
       return null;
     }
 
     return <SearchBar editor={editor} />;
   }
 
   render() {
-    const { selectedSourceWithContent, skipPausing } = this.props;
+    const { selectedSource, skipPausing } = this.props;
     return (
       <div
         className={classnames("editor-wrapper", {
-          blackboxed:
-            selectedSourceWithContent &&
-            selectedSourceWithContent.source.isBlackBoxed,
+          blackboxed: selectedSource && selectedSource.isBlackBoxed,
           "skip-pausing": skipPausing,
         })}
         ref={c => (this.$editorWrapper = c)}
       >
         <div
           className="editor-mount devtools-monospace"
           style={this.getInlineEditorStyles()}
         />
@@ -671,28 +649,25 @@ class Editor extends PureComponent<Props
   }
 }
 
 Editor.contextTypes = {
   shortcuts: PropTypes.object,
 };
 
 const mapStateToProps = state => {
-  const selectedSourceWithContent = getSelectedSourceWithContent(state);
+  const selectedSource = getSelectedSourceWithContent(state);
 
   return {
     cx: getThreadContext(state),
     selectedLocation: getSelectedLocation(state),
-    selectedSourceWithContent,
+    selectedSource,
     searchOn: getActiveSearch(state) === "file",
     conditionalPanelLocation: getConditionalPanelLocation(state),
-    symbols: getSymbols(
-      state,
-      selectedSourceWithContent ? selectedSourceWithContent.source : null
-    ),
+    symbols: getSymbols(state, selectedSource),
     isPaused: getIsPaused(state, getCurrentThread(state)),
     skipPausing: getSkipPausing(state),
   };
 };
 
 const mapDispatchToProps = dispatch => ({
   ...bindActionCreators(
     {
--- a/devtools/client/debugger/src/components/Editor/menus/editor.js
+++ b/devtools/client/debugger/src/components/Editor/menus/editor.js
@@ -163,56 +163,58 @@ const downloadFileItem = (
   label: L10N.getStr("downloadFile.label"),
   accesskey: L10N.getStr("downloadFile.accesskey"),
   click: () => downloadFile(selectedContent, getFilename(selectedSource)),
 });
 
 export function editorMenuItems({
   cx,
   editorActions,
-  selectedSourceWithContent,
+  selectedSource,
   location,
   selectionText,
   hasPrettySource,
   isTextSelected,
   isPaused,
 }: {
   cx: ThreadContext,
   editorActions: EditorItemActions,
-  selectedSourceWithContent: SourceWithContent,
+  selectedSource: SourceWithContent,
   location: SourceLocation,
   selectionText: string,
   hasPrettySource: boolean,
   isTextSelected: boolean,
   isPaused: boolean,
 }) {
   const items = [];
-  const { source: selectedSource, content } = selectedSourceWithContent;
+
+  const content =
+    selectedSource.content && isFulfilled(selectedSource.content)
+      ? selectedSource.content.value
+      : null;
 
   items.push(
     jumpToMappedLocationItem(
       cx,
       selectedSource,
       location,
       hasPrettySource,
       editorActions
     ),
     continueToHereItem(cx, location, isPaused, editorActions),
     { type: "separator" },
-    ...(content && isFulfilled(content)
-      ? [copyToClipboardItem(content.value, editorActions)]
-      : []),
+    ...(content ? [copyToClipboardItem(content, editorActions)] : []),
     ...(!selectedSource.isWasm
       ? [
           copySourceItem(selectedSource, selectionText, editorActions),
           copySourceUri2Item(selectedSource, editorActions),
         ]
       : []),
-    ...(content && isFulfilled(content)
-      ? [downloadFileItem(selectedSource, content.value, editorActions)]
+    ...(content
+      ? [downloadFileItem(selectedSource, content, editorActions)]
       : []),
     { type: "separator" },
     showSourceMenuItem(cx, selectedSource, editorActions),
     blackBoxMenuItem(cx, selectedSource, editorActions)
   );
 
   if (isTextSelected) {
     items.push(
--- a/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js
+++ b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js
@@ -29,17 +29,17 @@ function createMockDocument(clear) {
 function generateDefaults(editor, overrides) {
   return {
     editor,
     pauseInfo: {
       why: { type: "breakpoint" },
     },
     frame: null,
     source: ({
-      source: createSourceObject("foo"),
+      ...createSourceObject("foo"),
       content: null,
     }: SourceWithContent),
     ...overrides,
   };
 }
 
 function createFrame(line) {
   return {
@@ -52,31 +52,31 @@ function createFrame(line) {
 }
 
 function render(overrides = {}) {
   const clear = jest.fn();
   const editor = { codeMirror: {} };
   const props = generateDefaults(editor, overrides);
 
   const doc = createMockDocument(clear);
-  setDocument(props.source.source.id, doc);
+  setDocument(props.source.id, doc);
 
   // $FlowIgnore
   const component = shallow(<DebugLine.WrappedComponent {...props} />, {
     lifecycleExperimental: true,
   });
   return { component, props, clear, editor, doc };
 }
 
 describe("DebugLine Component", () => {
   describe("pausing at the first location", () => {
     it("should show a new debug line", async () => {
       const { component, props, doc } = render({
         source: {
-          source: createSourceObject("foo"),
+          ...createSourceObject("foo"),
           content: asyncValue.fulfilled({
             type: "text",
             value: "",
             contentType: undefined,
           }),
         },
       });
       const line = 2;
@@ -89,17 +89,17 @@ describe("DebugLine Component", () => {
         [toEditorLine("foo", line), "line", "new-debug-line"],
       ]);
     });
 
     describe("pausing at a new location", () => {
       it("should replace the first debug line", async () => {
         const { props, component, clear, doc } = render({
           source: {
-            source: createSourceObject("foo"),
+            ...createSourceObject("foo"),
             content: asyncValue.fulfilled({
               type: "text",
               value: "",
               contentType: undefined,
             }),
           },
         });
 
--- a/devtools/client/debugger/src/components/Editor/tests/Editor.spec.js
+++ b/devtools/client/debugger/src/components/Editor/tests/Editor.spec.js
@@ -2,17 +2,17 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import React from "react";
 import { shallow } from "enzyme";
 import Editor from "../index";
-import type { Source, SourceWithContent } from "../../../types";
+import type { Source, SourceWithContent, SourceBase } from "../../../types";
 import { getDocument } from "../../../utils/editor/source-documents";
 import * as asyncValue from "../../../utils/async-value";
 
 function generateDefaults(overrides) {
   return {
     toggleBreakpoint: jest.fn(),
     updateViewport: jest.fn(),
     toggleDisabledBreakpoint: jest.fn(),
@@ -69,17 +69,17 @@ function createMockSourceWithContent(
   const {
     loadedState = "loaded",
     text = "the text",
     contentType = undefined,
     error = undefined,
     ...otherOverrides
   } = overrides;
 
-  const source: Source = ({
+  const source: SourceBase = ({
     id: "foo",
     url: "foo",
     ...otherOverrides,
   }: any);
   let content = null;
   if (loadedState === "loaded") {
     if (typeof text !== "string") {
       throw new Error("Cannot create a non-text source");
@@ -90,17 +90,17 @@ function createMockSourceWithContent(
       : asyncValue.fulfilled({
           type: "text",
           value: text,
           contentType: contentType || undefined,
         });
   }
 
   return {
-    source,
+    ...source,
     content,
   };
 }
 
 function render(overrides = {}) {
   const props = generateDefaults(overrides);
   const mockEditor = createMockEditor();
 
@@ -123,17 +123,17 @@ describe("Editor", () => {
     });
   });
 
   describe("When loading initial source", () => {
     it("should show a loading message", async () => {
       const { component, mockEditor } = render();
       await component.setState({ editor: mockEditor });
       component.setProps({
-        selectedSourceWithContent: {
+        selectedSource: {
           source: { loadedState: "loading" },
           content: null,
         },
       });
 
       expect(mockEditor.replaceDocument.mock.calls[0][0].getValue()).toBe(
         "Loading…"
       );
@@ -143,17 +143,17 @@ describe("Editor", () => {
 
   describe("When loaded", () => {
     it("should show text", async () => {
       const { component, mockEditor, props } = render({});
 
       await component.setState({ editor: mockEditor });
       await component.setProps({
         ...props,
-        selectedSourceWithContent: createMockSourceWithContent({
+        selectedSource: createMockSourceWithContent({
           loadedState: "loaded",
         }),
         selectedLocation: { sourceId: "foo", line: 3, column: 1 },
       });
 
       expect(mockEditor.setText.mock.calls).toEqual([["the text"]]);
       expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([[1, 2]]);
     });
@@ -161,17 +161,17 @@ describe("Editor", () => {
 
   describe("When error", () => {
     it("should show error text", async () => {
       const { component, mockEditor, props } = render({});
 
       await component.setState({ editor: mockEditor });
       await component.setProps({
         ...props,
-        selectedSourceWithContent: createMockSourceWithContent({
+        selectedSource: createMockSourceWithContent({
           loadedState: "loaded",
           text: undefined,
           error: "error text",
         }),
         selectedLocation: { sourceId: "bad-foo", line: 3, column: 1 },
       });
 
       expect(mockEditor.setText.mock.calls).toEqual([
@@ -180,17 +180,17 @@ describe("Editor", () => {
     });
 
     it("should show wasm error", async () => {
       const { component, mockEditor, props } = render({});
 
       await component.setState({ editor: mockEditor });
       await component.setProps({
         ...props,
-        selectedSourceWithContent: createMockSourceWithContent({
+        selectedSource: createMockSourceWithContent({
           loadedState: "loaded",
           isWasm: true,
           text: undefined,
           error: "blah WebAssembly binary source is not available blah",
         }),
         selectedLocation: { sourceId: "bad-foo", line: 3, column: 1 },
       });
 
@@ -202,26 +202,26 @@ describe("Editor", () => {
 
   describe("When navigating to a loading source", () => {
     it("should show loading message and not scroll", async () => {
       const { component, mockEditor, props } = render({});
 
       await component.setState({ editor: mockEditor });
       await component.setProps({
         ...props,
-        selectedSourceWithContent: createMockSourceWithContent({
+        selectedSource: createMockSourceWithContent({
           loadedState: "loaded",
         }),
         selectedLocation: { sourceId: "foo", line: 3, column: 1 },
       });
 
       // navigate to a new source that is still loading
       await component.setProps({
         ...props,
-        selectedSourceWithContent: createMockSourceWithContent({
+        selectedSource: createMockSourceWithContent({
           id: "bar",
           loadedState: "loading",
         }),
         selectedLocation: { sourceId: "bar", line: 1, column: 1 },
       });
 
       expect(mockEditor.replaceDocument.mock.calls[1][0].getValue()).toBe(
         "Loading…"
@@ -232,66 +232,64 @@ describe("Editor", () => {
       expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([[1, 2]]);
     });
 
     it("should set the mode when symbols load", async () => {
       const { component, mockEditor, props } = render({});
 
       await component.setState({ editor: mockEditor });
 
-      const selectedSourceWithContent = createMockSourceWithContent({
+      const selectedSource = createMockSourceWithContent({
         loadedState: "loaded",
         contentType: "javascript",
       });
 
-      await component.setProps({ ...props, selectedSourceWithContent });
+      await component.setProps({ ...props, selectedSource });
 
       const symbols = { hasJsx: true };
       await component.setProps({
         ...props,
-        selectedSourceWithContent,
+        selectedSource,
         symbols,
       });
 
       expect(mockEditor.setMode.mock.calls).toEqual([
         [{ name: "javascript" }],
         [{ name: "jsx" }],
       ]);
     });
 
     it("should not re-set the mode when the location changes", async () => {
       const { component, mockEditor, props } = render({});
 
       await component.setState({ editor: mockEditor });
 
-      const selectedSourceWithContent = createMockSourceWithContent({
+      const selectedSource = createMockSourceWithContent({
         loadedState: "loaded",
         contentType: "javascript",
       });
 
-      await component.setProps({ ...props, selectedSourceWithContent });
+      await component.setProps({ ...props, selectedSource });
 
       // symbols are parsed
       const symbols = { hasJsx: true };
       await component.setProps({
         ...props,
-        selectedSourceWithContent,
+        selectedSource,
         symbols,
       });
 
       // selectedLocation changes e.g. pausing/stepping
-      mockEditor.codeMirror.doc = getDocument(
-        selectedSourceWithContent.source.id
-      );
+      mockEditor.codeMirror.doc = getDocument(selectedSource.id);
       mockEditor.codeMirror.getOption = () => ({ name: "jsx" });
       const selectedLocation = { sourceId: "foo", line: 4, column: 1 };
 
       await component.setProps({
         ...props,
-        selectedSourceWithContent,
+        selectedSource,
         symbols,
         selectedLocation,
       });
 
       expect(mockEditor.setMode.mock.calls).toEqual([
         [{ name: "javascript" }],
         [{ name: "jsx" }],
       ]);
@@ -300,26 +298,26 @@ describe("Editor", () => {
 
   describe("When navigating to a loaded source", () => {
     it("should show text and then scroll", async () => {
       const { component, mockEditor, props } = render({});
 
       await component.setState({ editor: mockEditor });
       await component.setProps({
         ...props,
-        selectedSourceWithContent: createMockSourceWithContent({
+        selectedSource: createMockSourceWithContent({
           loadedState: "loading",
         }),
         selectedLocation: { sourceId: "foo", line: 1, column: 1 },
       });
 
       // navigate to a new source that is still loading
       await component.setProps({
         ...props,
-        selectedSourceWithContent: createMockSourceWithContent({
+        selectedSource: createMockSourceWithContent({
           loadedState: "loaded",
         }),
         selectedLocation: { sourceId: "foo", line: 1, column: 1 },
       });
 
       expect(mockEditor.replaceDocument.mock.calls[0][0].getValue()).toBe(
         "Loading…"
       );
--- a/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js
+++ b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js
@@ -24,30 +24,30 @@ function generateDefaults(overrides) {
     editor: {
       codeMirror: {
         doc: {},
         cursorActivity: jest.fn(),
         on: jest.fn(),
       },
     },
     endPanelCollapsed: false,
-    selectedSourceWithContent: {
-      source: createSourceObject("foo"),
+    selectedSource: {
+      ...createSourceObject("foo"),
       content: null,
     },
     ...overrides,
   };
 }
 
 function render(overrides = {}, position = { line: 0, column: 0 }) {
   const clear = jest.fn();
   const props = generateDefaults(overrides);
 
   const doc = createMockDocument(clear, position);
-  setDocument(props.selectedSourceWithContent.source.id, doc);
+  setDocument(props.selectedSource.id, doc);
 
   // $FlowIgnore
   const component = shallow(<SourceFooter.WrappedComponent {...props} />, {
     lifecycleExperimental: true,
   });
   return { component, props, clear, doc };
 }
 
--- a/devtools/client/debugger/src/components/PrimaryPanes/Outline.js
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js
@@ -258,24 +258,22 @@ export class Outline extends Component<P
         </div>
       </div>
     );
   }
 }
 
 const mapStateToProps = state => {
   const selectedSource = getSelectedSourceWithContent(state);
-  const symbols = selectedSource
-    ? getSymbols(state, selectedSource.source)
-    : null;
+  const symbols = selectedSource ? getSymbols(state, selectedSource) : null;
 
   return {
     cx: getContext(state),
     symbols,
-    selectedSource: selectedSource && selectedSource.source,
+    selectedSource: (selectedSource: ?Source),
     selectedLocation: getSelectedLocation(state),
     getFunctionText: line => {
       if (selectedSource) {
         return findFunctionText(line, selectedSource, symbols);
       }
 
       return null;
     },
--- a/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js
+++ b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js
@@ -54,17 +54,17 @@ class DOMMutationBreakpointsContents ext
           type="checkbox"
           checked={breakpoint.enabled}
           onChange={() => toggleBreakpoint(breakpoint.id, !breakpoint.enabled)}
         />
         <div className="dom-mutation-info">
           <div className="dom-mutation-label">
             {Rep({
               object: translateNodeFrontToGrip(breakpoint.nodeFront),
-              mode: MODE.LONG,
+              mode: MODE.TINY,
               onDOMNodeClick: grip => openElementInInspector(grip),
               onInspectIconClick: grip => openElementInInspector(grip),
               onDOMNodeMouseOver: grip => highlightDomElement(grip),
               onDOMNodeMouseOut: grip => unHighlightDomElement(grip),
             })}
           </div>
           <div className="dom-mutation-type">
             {localizationTerms[breakpoint.mutationType] ||
--- a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js
@@ -201,18 +201,18 @@ describe("Frames", () => {
       const frames = [
         makeMockFrame("1", source1),
         makeMockFrame("2", source2),
         makeMockFrame("3", source1),
         makeMockFrame("8", source2),
       ];
 
       const sources: SourceResourceState = insertResources(createInitial(), [
-        source1,
-        source2,
+        { ...source1, content: null },
+        { ...source2, content: null },
       ]);
 
       const processedFrames = formatCallStackFrames(frames, sources, source1);
       const selectedFrame = frames[0];
 
       const component = render({
         frames: processedFrames,
         frameworkGroupingOn: false,
--- a/devtools/client/debugger/src/reducers/source-actors.js
+++ b/devtools/client/debugger/src/reducers/source-actors.js
@@ -2,29 +2,44 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import type { Action } from "../actions/types";
 import type { SourceId, ThreadId } from "../types";
 import {
+  asSettled,
+  type AsyncValue,
+  type SettledValue,
+} from "../utils/async-value";
+import {
   createInitial,
   insertResources,
+  updateResources,
   removeResources,
   hasResource,
   getResource,
+  getMappedResource,
+  makeWeakQuery,
   makeIdQuery,
   makeReduceAllQuery,
   type Resource,
   type ResourceState,
+  type WeakQuery,
   type IdQuery,
   type ReduceAllQuery,
 } from "../utils/resource";
 
+import { asyncActionAsValue } from "../actions/utils/middleware/promise";
+import type {
+  SourceActorBreakpointColumnsAction,
+  SourceActorBreakableLinesAction,
+} from "../actions/types/SourceActorAction";
+
 export opaque type SourceActorId: string = string;
 export type SourceActor = {|
   +id: SourceActorId,
   +actor: string,
   +thread: ThreadId,
   +source: SourceId,
 
   +isBlackBoxed: boolean,
@@ -42,49 +57,107 @@ export type SourceActor = {|
 
   // The debugger's Debugger.Source API provides type information for the
   // cause of this source's creation.
   +introductionType: string | null,
 |};
 
 type SourceActorResource = Resource<{
   ...SourceActor,
+
+  // The list of breakpoint positions on each line of the file.
+  breakpointPositions: Map<number, AsyncValue<Array<number>>>,
+
+  // The list of lines that contain breakpoints.
+  breakableLines: AsyncValue<Array<number>> | null,
 }>;
 export type SourceActorsState = ResourceState<SourceActorResource>;
 export type SourceActorOuterState = { sourceActors: SourceActorsState };
 
 const initial: SourceActorsState = createInitial();
 
 export default function update(
   state: SourceActorsState = initial,
   action: Action
 ): SourceActorsState {
   switch (action.type) {
     case "INSERT_SOURCE_ACTORS": {
       const { items } = action;
-      state = insertResources(state, items);
+      state = insertResources(
+        state,
+        items.map(item => ({
+          ...item,
+          breakpointPositions: new Map(),
+          breakableLines: null,
+        }))
+      );
       break;
     }
     case "REMOVE_SOURCE_ACTORS": {
       const { items } = action;
       state = removeResources(state, items);
       break;
     }
 
     case "NAVIGATE": {
       state = initial;
       break;
     }
+
+    case "SET_SOURCE_ACTOR_BREAKPOINT_COLUMNS":
+      state = updateBreakpointColumns(state, action);
+      break;
+
+    case "SET_SOURCE_ACTOR_BREAKABLE_LINES":
+      state = updateBreakableLines(state, action);
+      break;
   }
 
   return state;
 }
 
-export function resourceAsSourceActor(r: SourceActorResource): SourceActor {
-  return r;
+function updateBreakpointColumns(
+  state: SourceActorsState,
+  action: SourceActorBreakpointColumnsAction
+): SourceActorsState {
+  const { sourceId, line } = action;
+  const value = asyncActionAsValue(action);
+
+  if (!hasResource(state, sourceId)) {
+    return state;
+  }
+
+  const breakpointPositions = new Map(
+    getResource(state, sourceId).breakpointPositions
+  );
+  breakpointPositions.set(line, value);
+
+  return updateResources(state, [{ id: sourceId, breakpointPositions }]);
+}
+
+function updateBreakableLines(
+  state: SourceActorsState,
+  action: SourceActorBreakableLinesAction
+): SourceActorsState {
+  const value = asyncActionAsValue(action);
+  const { sourceId } = action;
+
+  if (!hasResource(state, sourceId)) {
+    return state;
+  }
+
+  return updateResources(state, [{ id: sourceId, breakableLines: value }]);
+}
+
+export function resourceAsSourceActor({
+  breakpointPositions,
+  breakableLines,
+  ...sourceActor
+}: SourceActorResource): SourceActor {
+  return sourceActor;
 }
 
 // Because we are using an opaque type for our source actor IDs, these
 // functions are required to convert back and forth in order to get a string
 // version of the IDs. That should be super rarely used, but it means that
 // we can very easily see where we're relying on the string version of IDs.
 export function stringToSourceActorId(s: string): SourceActorId {
   return s;
@@ -96,17 +169,17 @@ export function hasSourceActor(
 ): boolean {
   return hasResource(state.sourceActors, id);
 }
 
 export function getSourceActor(
   state: SourceActorOuterState,
   id: SourceActorId
 ): SourceActor {
-  return getResource(state.sourceActors, id);
+  return getMappedResource(state.sourceActors, id, resourceAsSourceActor);
 }
 
 /**
  * Get all of the source actors for a set of IDs. Caches based on the identity
  * of "ids" when possible.
  */
 const querySourceActorsById: IdQuery<
   SourceActorResource,
@@ -161,8 +234,45 @@ const queryThreadsBySourceObject: Reduce
     }, {})
 );
 
 export function getThreadsBySource(
   state: SourceActorOuterState
 ): { [SourceId]: Array<ThreadId> } {
   return queryThreadsBySourceObject(state.sourceActors);
 }
+
+export function getSourceActorBreakableLines(
+  state: SourceActorOuterState,
+  id: SourceActorId
+): SettledValue<Array<number>> | null {
+  const { breakableLines } = getResource(state.sourceActors, id);
+
+  return asSettled(breakableLines);
+}
+
+export function getSourceActorBreakpointColumns(
+  state: SourceActorOuterState,
+  id: SourceActorId,
+  line: number
+): SettledValue<Array<number>> | null {
+  const { breakpointPositions } = getResource(state.sourceActors, id);
+
+  return asSettled(breakpointPositions.get(line) || null);
+}
+
+export const getBreakableLinesForSourceActors: WeakQuery<
+  SourceActorResource,
+  Array<SourceActorId>,
+  Array<number>
+> = makeWeakQuery({
+  filter: (state, ids) => ids,
+  map: ({ breakableLines }) => breakableLines,
+  reduce: items =>
+    Array.from(
+      items.reduce((acc, item) => {
+        if (item && item.state === "fulfilled") {
+          acc = acc.concat(item.value);
+        }
+        return acc;
+      }, [])
+    ),
+});
--- a/devtools/client/debugger/src/reducers/sources.js
+++ b/devtools/client/debugger/src/reducers/sources.js
@@ -19,17 +19,19 @@ import {
   getPlainUrl,
 } from "../utils/source";
 import {
   createInitial,
   insertResources,
   updateResources,
   hasResource,
   getResource,
+  getMappedResource,
   getResourceIds,
+  memoizeResourceShallow,
   makeReduceQuery,
   makeReduceAllQuery,
   makeMapWithArgs,
   type Resource,
   type ResourceState,
   type ReduceQuery,
   type ReduceAllQuery,
 } from "../utils/resource";
@@ -40,16 +42,17 @@ import type { AsyncValue, SettledValue }
 import { originalToGeneratedId } from "devtools-source-map";
 import { prefs } from "../utils/prefs";
 
 import {
   hasSourceActor,
   getSourceActor,
   getSourceActors,
   getThreadsBySource,
+  getBreakableLinesForSourceActors,
   type SourceActorId,
   type SourceActorOuterState,
 } from "./source-actors";
 import type {
   Source,
   SourceId,
   SourceActor,
   SourceLocation,
@@ -61,40 +64,50 @@ import type {
 } from "../types";
 import type { PendingSelectedLocation, Selector } from "./types";
 import type { Action, DonePromiseAction, FocusItem } from "../actions/types";
 import type { LoadSourceAction } from "../actions/types/SourceAction";
 import type { DebuggeeState } from "./debuggee";
 import { uniq } from "lodash";
 
 export type SourcesMap = { [SourceId]: Source };
-type SourcesContentMap = {
-  [SourceId]: AsyncValue<SourceContent> | null,
-};
 export type SourcesMapByThread = { [ThreadId]: SourcesMap };
 
 export type BreakpointPositionsMap = { [SourceId]: BreakpointPositions };
 type SourceActorMap = { [SourceId]: Array<SourceActorId> };
 
 type UrlsMap = { [string]: SourceId[] };
 type PlainUrlsMap = { [string]: string[] };
 
+export type SourceBase = {|
+  +id: SourceId,
+  +url: string,
+  +sourceMapURL?: string,
+  +isBlackBoxed: boolean,
+  +isPrettyPrinted: boolean,
+  +relativeUrl: string,
+  +introductionUrl: ?string,
+  +introductionType: ?string,
+  +extensionName: ?string,
+  +isExtension: boolean,
+  +isWasm: boolean,
+|};
+
 type SourceResource = Resource<{
-  ...Source,
+  ...SourceBase,
+  content: AsyncValue<SourceContent> | null,
 }>;
 export type SourceResourceState = ResourceState<SourceResource>;
 
 export type SourcesState = {
   epoch: number,
 
   // All known sources.
   sources: SourceResourceState,
 
-  content: SourcesContentMap,
-
   breakpointPositions: BreakpointPositionsMap,
   breakableLines: { [SourceId]: Array<number> },
 
   // A link between each source object and the source actor they wrap over.
   actors: SourceActorMap,
 
   // All sources associated with a given URL. When using source maps, multiple
   // sources can have the same URL.
@@ -199,17 +212,17 @@ function update(
         updateBlackBoxList(url, isBlackBoxed);
         return updateBlackboxFlag(state, id, isBlackBoxed);
       }
       break;
 
     case "SET_PROJECT_DIRECTORY_ROOT":
       return updateProjectDirectoryRoot(state, action.url);
 
-    case "SET_BREAKABLE_LINES": {
+    case "SET_ORIGINAL_BREAKABLE_LINES": {
       const { breakableLines, sourceId } = action;
       return {
         ...state,
         breakableLines: {
           ...state.breakableLines,
           [sourceId]: breakableLines,
         },
       };
@@ -235,46 +248,55 @@ function update(
 
     case "SET_FOCUSED_SOURCE_ITEM":
       return { ...state, focusedItem: action.item };
   }
 
   return state;
 }
 
-function resourceAsSource(r: SourceResource): Source {
-  return r;
-}
+const resourceAsSourceBase = memoizeResourceShallow(
+  ({ content, ...source }: SourceResource): SourceBase => source
+);
+
+const resourceAsSourceWithContent = memoizeResourceShallow(
+  ({ content, ...source }: SourceResource): SourceWithContent => ({
+    ...source,
+    content: asyncValue.asSettled(content),
+  })
+);
 
 /*
  * Add sources to the sources store
  * - Add the source to the sources store
  * - Add the source URL to the urls map
  */
-function addSources(state: SourcesState, sources: Source[]): SourcesState {
+function addSources(state: SourcesState, sources: SourceBase[]): SourcesState {
   state = {
     ...state,
-    content: { ...state.content },
     urls: { ...state.urls },
     plainUrls: { ...state.plainUrls },
   };
 
-  state.sources = insertResources(state.sources, sources);
+  state.sources = insertResources(
+    state.sources,
+    sources.map(source => ({
+      ...source,
+      content: null,
+    }))
+  );
 
   for (const source of sources) {
-    // 1. Add the source to the sources map
-    state.content[source.id] = null;
-
-    // 2. Update the source url map
+    // 1. Update the source url map
     const existing = state.urls[source.url] || [];
     if (!existing.includes(source.id)) {
       state.urls[source.url] = [...existing, source.id];
     }
 
-    // 3. Update the plain url map
+    // 2. Update the plain url map
     if (source.url) {
       const plainUrl = getPlainUrl(source.url);
       const existingPlainUrls = state.plainUrls[plainUrl] || [];
       if (!existingPlainUrls.includes(source.url)) {
         state.plainUrls[plainUrl] = [...existingPlainUrls, source.url];
       }
     }
   }
@@ -348,17 +370,17 @@ function updateProjectDirectoryRoot(stat
   return updateRootRelativeValues({
     ...state,
     projectDirectoryRoot: root,
   });
 }
 
 function updateRootRelativeValues(
   state: SourcesState,
-  sources?: Array<Source>
+  sources?: $ReadOnlyArray<Source>
 ) {
   const ids = sources
     ? sources.map(source => source.id)
     : getResourceIds(state.sources);
 
   state = {
     ...state,
   };
@@ -384,17 +406,17 @@ function updateRootRelativeValues(
 function updateLoadedState(
   state: SourcesState,
   action: LoadSourceAction
 ): SourcesState {
   const { sourceId } = action;
 
   // If there was a navigation between the time the action was started and
   // completed, we don't want to update the store.
-  if (action.epoch !== state.epoch || !(sourceId in state.content)) {
+  if (action.epoch !== state.epoch || !hasResource(state.sources, sourceId)) {
     return state;
   }
 
   let content;
   if (action.status === "start") {
     content = asyncValue.pending();
   } else if (action.status === "error") {
     content = asyncValue.rejected(action.error);
@@ -408,20 +430,22 @@ function updateLoadedState(
     content = asyncValue.fulfilled({
       type: "wasm",
       value: action.value.text,
     });
   }
 
   return {
     ...state,
-    content: {
-      ...state.content,
-      [sourceId]: content,
-    },
+    sources: updateResources(state.sources, [
+      {
+        id: sourceId,
+        content,
+      },
+    ]),
   };
 }
 
 function clearSourceMaps(
   state: SourcesState,
   sourceId: SourceId
 ): SourcesState {
   if (!hasResource(state.sources, sourceId)) {
@@ -509,17 +533,19 @@ export function getSourceThreads(
     )
   );
 }
 
 export function getSourceInSources(
   sources: SourceResourceState,
   id: string
 ): ?Source {
-  return hasResource(sources, id) ? getResource(sources, id) : null;
+  return hasResource(sources, id)
+    ? getMappedResource(sources, id, resourceAsSourceBase)
+    : null;
 }
 
 export function getSource(state: OuterState, id: SourceId): ?Source {
   return getSourceInSources(getSources(state), id);
 }
 
 export function getSourceFromId(state: OuterState, id: string): Source {
   const source = getSource(state, id);
@@ -543,17 +569,19 @@ export function getSourceByActorId(
 export function getSourcesByURLInSources(
   sources: SourceResourceState,
   urls: UrlsMap,
   url: string
 ): Source[] {
   if (!url || !urls[url]) {
     return [];
   }
-  return urls[url].map(id => getResource(sources, id));
+  return urls[url].map(id =>
+    getMappedResource(sources, id, resourceAsSourceBase)
+  );
 }
 
 export function getSourcesByURL(state: OuterState, url: string): Source[] {
   return getSourcesByURLInSources(getSources(state), getUrls(state), url);
 }
 
 export function getSourceByURL(state: OuterState, url: string): ?Source {
   const foundSources = getSourcesByURL(state, url);
@@ -662,17 +690,17 @@ export function getHasSiblingOfSameName(
   }
 
   return getSourcesUrlsInSources(state, source.url).length > 1;
 }
 
 const querySourceList: ReduceAllQuery<
   SourceResource,
   Array<Source>
-> = makeReduceAllQuery(resourceAsSource, sources => sources.slice());
+> = makeReduceAllQuery(resourceAsSourceBase, sources => sources.slice());
 
 export function getSources(state: OuterState): SourceResourceState {
   return state.sources.sources;
 }
 
 export function getSourcesEpoch(state: OuterState) {
   return state.sources.epoch;
 }
@@ -729,77 +757,44 @@ export const getSelectedSource: Selector
     return getSourceInSources(sources, selectedLocation.sourceId);
   }
 );
 
 type GSSWC = Selector<?SourceWithContent>;
 export const getSelectedSourceWithContent: GSSWC = createSelector(
   getSelectedLocation,
   getSources,
-  state => state.sources.content,
   (
     selectedLocation: ?SourceLocation,
-    sources: SourceResourceState,
-    content: SourcesContentMap
+    sources: SourceResourceState
   ): SourceWithContent | null => {
     const source =
       selectedLocation &&
       getSourceInSources(sources, selectedLocation.sourceId);
     return source
-      ? getSourceWithContentInner(sources, content, source.id)
+      ? getMappedResource(sources, source.id, resourceAsSourceWithContent)
       : null;
   }
 );
 export function getSourceWithContent(
   state: OuterState,
   id: SourceId
 ): SourceWithContent {
-  return getSourceWithContentInner(
+  return getMappedResource(
     state.sources.sources,
-    state.sources.content,
-    id
+    id,
+    resourceAsSourceWithContent
   );
 }
 export function getSourceContent(
   state: OuterState,
   id: SourceId
 ): SettledValue<SourceContent> | null {
-  // Assert the resource exists.
-  getResource(state.sources.sources, id);
-  const content = state.sources.content[id];
-
-  if (!content || content.state === "pending") {
-    return null;
-  }
-
-  return content;
-}
-
-const contentLookup: WeakMap<Source, SourceWithContent> = new WeakMap();
-function getSourceWithContentInner(
-  sources: SourceResourceState,
-  content: SourcesContentMap,
-  id: SourceId
-): SourceWithContent {
-  const source = getResource(sources, id);
-  let contentValue = content[source.id];
-
-  let result = contentLookup.get(source);
-  if (!result || result.content !== contentValue) {
-    if (contentValue && contentValue.state === "pending") {
-      contentValue = null;
-    }
-    result = {
-      source,
-      content: contentValue,
-    };
-    contentLookup.set(source, result);
-  }
-
-  return result;
+  const { content } = getResource(state.sources.sources, id);
+  return asyncValue.asSettled(content);
 }
 
 export function getSelectedSourceId(state: OuterState) {
   const source = getSelectedSource((state: any));
   return source && source.id;
 }
 
 export function getProjectDirectoryRoot(state: OuterState): string {
@@ -965,30 +960,46 @@ export function getBreakpointPositionsFo
   state: OuterState,
   location: SourceLocation
 ): ?MappedLocation {
   const { sourceId } = location;
   const positions = getBreakpointPositionsForSource(state, sourceId);
   return findPosition(positions, location);
 }
 
-export function getBreakableLines(state: OuterState, sourceId: string) {
+export function getBreakableLines(
+  state: OuterState & SourceActorOuterState,
+  sourceId: string
+): ?Array<number> {
   if (!sourceId) {
     return null;
   }
+  const source = getSource(state, sourceId);
+  if (!source) {
+    return null;
+  }
 
-  return state.sources.breakableLines[sourceId];
+  if (isOriginalSource(source)) {
+    return state.sources.breakableLines[sourceId];
+  }
+
+  // We pull generated file breakable lines directly from the source actors
+  // so that breakable lines can be added as new source actors on HTML loads.
+  return getBreakableLinesForSourceActors(
+    state.sourceActors,
+    state.sources.actors[sourceId]
+  );
 }
 
 export const getSelectedBreakableLines: Selector<Set<number>> = createSelector(
   state => {
     const sourceId = getSelectedSourceId(state);
-    return sourceId && state.sources.breakableLines[sourceId];
+    return sourceId && getBreakableLines(state, sourceId);
   },
   breakableLines => new Set(breakableLines || [])
 );
 
 export function isSourceLoadingOrLoaded(state: OuterState, sourceId: string) {
-  const content = state.sources.content[sourceId];
+  const { content } = getResource(state.sources.sources, sourceId);
   return content !== null;
 }
 
 export default update;
--- a/devtools/client/debugger/src/reducers/tests/sources.spec.js
+++ b/devtools/client/debugger/src/reducers/tests/sources.spec.js
@@ -5,17 +5,17 @@
 // @flow
 declare var describe: (name: string, func: () => void) => void;
 declare var it: (desc: string, func: () => void) => void;
 declare var expect: (value: any) => any;
 
 import update, { initialSourcesState, getDisplayedSources } from "../sources";
 import { initialDebuggeeState } from "../debuggee";
 import updateSourceActors from "../source-actors";
-import type { Source, SourceActor } from "../../types";
+import type { SourceActor } from "../../types";
 import { prefs } from "../../utils/prefs";
 import { makeMockSource, mockcx } from "../../utils/test-mockup";
 import { getResourceIds } from "../../utils/resource";
 
 const extensionSource = {
   ...makeMockSource(),
   id: "extensionId",
   url: "http://example.com/script.js",
@@ -77,17 +77,17 @@ describe("sources reducer", () => {
 describe("sources selectors", () => {
   it("should return all extensions when chrome preference enabled", () => {
     prefs.chromeAndExtenstionsEnabled = true;
     let state = initialSourcesState();
     state = {
       sources: update(state, {
         type: "ADD_SOURCES",
         cx: mockcx,
-        sources: ((mockedSources: any): Source[]),
+        sources: mockedSources,
       }),
       sourceActors: undefined,
     };
     const insertAction = {
       type: "INSERT_SOURCE_ACTORS",
       items: mockSourceActors,
     };
     state = {
@@ -101,17 +101,17 @@ describe("sources selectors", () => {
 
   it("should omit all extensions when chrome preference enabled", () => {
     prefs.chromeAndExtenstionsEnabled = false;
     let state = initialSourcesState();
     state = {
       sources: update(state, {
         type: "ADD_SOURCES",
         cx: mockcx,
-        sources: ((mockedSources: any): Source[]),
+        sources: mockedSources,
       }),
       sourceActors: undefined,
     };
 
     const insertAction = {
       type: "INSERT_SOURCE_ACTORS",
       items: mockSourceActors,
     };
--- a/devtools/client/debugger/src/selectors/visibleColumnBreakpoints.js
+++ b/devtools/client/debugger/src/selectors/visibleColumnBreakpoints.js
@@ -143,36 +143,31 @@ function convertToList(
 ): BreakpointPosition[] {
   return ([].concat(...Object.values(breakpointPositions)): any);
 }
 
 export function getColumnBreakpoints(
   positions: BreakpointPosition[],
   breakpoints: ?(Breakpoint[]),
   viewport: ?Range,
-  selectedSourceWithContent: ?SourceWithContent
+  selectedSource: ?SourceWithContent
 ) {
-  if (!positions || !selectedSourceWithContent) {
+  if (!positions || !selectedSource) {
     return [];
   }
 
-  const {
-    source: selectedSource,
-    content: selectedContent,
-  } = selectedSourceWithContent;
-
   // We only want to show a column breakpoint if several conditions are matched
   // - it is the first breakpoint to appear at an the original location
   // - the position is in the current viewport
   // - there is atleast one other breakpoint on that line
   // - there is a breakpoint on that line
   const breakpointMap = groupBreakpoints(breakpoints, selectedSource);
   positions = filterByLineCount(positions, selectedSource);
   positions = filterVisible(positions, selectedSource, viewport);
-  positions = filterInLine(positions, selectedSource, selectedContent);
+  positions = filterInLine(positions, selectedSource, selectedSource.content);
   positions = filterByBreakpoints(positions, selectedSource, breakpointMap);
 
   return formatPositions(positions, selectedSource, breakpointMap);
 }
 
 const getVisibleBreakpointPositions = createSelector(
   getSelectedSource,
   getBreakpointPositions,
--- a/devtools/client/debugger/src/types.js
+++ b/devtools/client/debugger/src/types.js
@@ -2,18 +2,19 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import type { SettledValue, FulfilledValue } from "./utils/async-value";
 import type { SourcePayload } from "./client/firefox/types";
 import type { SourceActorId, SourceActor } from "./reducers/source-actors";
+import type { SourceBase } from "./reducers/sources";
 
-export type { SourceActorId, SourceActor };
+export type { SourceActorId, SourceActor, SourceBase };
 
 export type SearchModifiers = {
   caseSensitive: boolean,
   wholeWord: boolean,
   regexMatch: boolean,
 };
 
 export type Mode =
@@ -376,45 +377,45 @@ export type TextSourceContent = {|
   contentType: string | void,
 |};
 export type WasmSourceContent = {|
   type: "wasm",
   value: {| binary: Object |},
 |};
 export type SourceContent = TextSourceContent | WasmSourceContent;
 
-export type SourceWithContent = {|
-  source: Source,
+export type SourceWithContent = $ReadOnly<{
+  ...SourceBase,
   +content: SettledValue<SourceContent> | null,
-|};
-export type SourceWithContentAndType<+Content: SourceContent> = {|
-  source: Source,
+}>;
+export type SourceWithContentAndType<+Content: SourceContent> = $ReadOnly<{
+  ...SourceBase,
   +content: FulfilledValue<Content>,
-|};
+}>;
 
 /**
  * Source
  *
  * @memberof types
  * @static
  */
 
-export type Source = {|
+export type Source = {
   +id: SourceId,
   +url: string,
   +sourceMapURL?: string,
   +isBlackBoxed: boolean,
   +isPrettyPrinted: boolean,
   +relativeUrl: string,
   +introductionUrl: ?string,
   +introductionType: ?string,
   +extensionName: ?string,
   +isExtension: boolean,
   +isWasm: boolean,
-|};
+};
 
 /**
  * Script
  * This describes scripts which are sent to the debug server to be eval'd
  * @memberof types
  * @static
  * FIXME: This needs a real type definition
  */
--- a/devtools/client/debugger/src/utils/async-value.js
+++ b/devtools/client/debugger/src/utils/async-value.js
@@ -23,16 +23,22 @@ export function pending(): PendingValue 
 }
 export function fulfilled<+T>(value: T): FulfilledValue<T> {
   return { state: "fulfilled", value };
 }
 export function rejected(value: mixed): RejectedValue {
   return { state: "rejected", value };
 }
 
+export function asSettled<T>(
+  value: AsyncValue<T> | null
+): SettledValue<T> | null {
+  return value && value.state !== "pending" ? value : null;
+}
+
 export function isPending(value: AsyncValue<mixed>): boolean %checks {
   return value.state === "pending";
 }
 export function isFulfilled(value: AsyncValue<mixed>): boolean %checks {
   return value.state === "fulfilled";
 }
 export function isRejected(value: AsyncValue<mixed>): boolean %checks {
   return value.state === "rejected";
--- a/devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js
+++ b/devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js
@@ -8,19 +8,17 @@ import { getASTLocation } from "../astBr
 import {
   populateSource,
   populateOriginalSource,
 } from "../../../workers/parser/tests/helpers";
 import { getSymbols } from "../../../workers/parser/getSymbols";
 import cases from "jest-in-case";
 
 async function setup({ file, location, functionName, original }) {
-  const { source } = original
-    ? populateOriginalSource(file)
-    : populateSource(file);
+  const source = original ? populateOriginalSource(file) : populateSource(file);
 
   const symbols = getSymbols(source.id);
 
   const astLocation = getASTLocation(source, symbols, location);
   expect(astLocation.name).toBe(functionName);
   expect(astLocation).toMatchSnapshot();
 }
 
--- a/devtools/client/debugger/src/utils/editor/tests/editor.spec.js
+++ b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js
@@ -21,17 +21,17 @@ import {
   getTextForLine,
   getCursorLine,
 } from "../index";
 
 import { makeMockSource, makeMockSourceAndContent } from "../../test-mockup";
 
 describe("shouldShowPrettyPrint", () => {
   it("shows pretty print for a source", () => {
-    const { source, content } = makeMockSourceAndContent(
+    const { content, ...source } = makeMockSourceAndContent(
       "http://example.com/index.js",
       "test-id-123",
       "text/javascript",
       "some text here"
     );
     expect(shouldShowPrettyPrint(source, content)).toEqual(true);
   });
 });
--- a/devtools/client/debugger/src/utils/function.js
+++ b/devtools/client/debugger/src/utils/function.js
@@ -6,39 +6,39 @@
 import { isFulfilled } from "./async-value";
 import { findClosestFunction } from "./ast";
 import { correctIndentation } from "./indentation";
 import type { SourceWithContent } from "../types";
 import type { Symbols } from "../reducers/ast";
 
 export function findFunctionText(
   line: number,
-  { source, content }: SourceWithContent,
+  source: SourceWithContent,
   symbols: ?Symbols
 ): ?string {
   const func = findClosestFunction(symbols, {
     sourceId: source.id,
     line,
     column: Infinity,
   });
 
   if (
     source.isWasm ||
     !func ||
-    !content ||
-    !isFulfilled(content) ||
-    content.value.type !== "text"
+    !source.content ||
+    !isFulfilled(source.content) ||
+    source.content.value.type !== "text"
   ) {
     return null;
   }
 
   const {
     location: { start, end },
   } = func;
-  const lines = content.value.value.split("\n");
+  const lines = source.content.value.value.split("\n");
   const firstLine = lines[start.line - 1].slice(start.column);
   const lastLine = lines[end.line - 1].slice(0, end.column);
   const middle = lines.slice(start.line, end.line - 1);
   const functionText = [firstLine, ...middle, lastLine].join("\n");
   const indentedFunctionText = correctIndentation(functionText);
 
   return indentedFunctionText;
 }
--- a/devtools/client/debugger/src/utils/isMinified.js
+++ b/devtools/client/debugger/src/utils/isMinified.js
@@ -8,26 +8,30 @@ import type { SourceWithContent } from "
 import { isFulfilled } from "./async-value";
 
 // Used to detect minification for automatic pretty printing
 const SAMPLE_SIZE = 50;
 const INDENT_COUNT_THRESHOLD = 5;
 const CHARACTER_LIMIT = 250;
 const _minifiedCache = new Map();
 
-export function isMinified({ source, content }: SourceWithContent) {
+export function isMinified(source: SourceWithContent) {
   if (_minifiedCache.has(source.id)) {
     return _minifiedCache.get(source.id);
   }
 
-  if (!content || !isFulfilled(content) || content.value.type !== "text") {
+  if (
+    !source.content ||
+    !isFulfilled(source.content) ||
+    source.content.value.type !== "text"
+  ) {
     return false;
   }
 
-  let text = content.value.value;
+  let text = source.content.value.value;
 
   let lineEndIndex = 0;
   let lineStartIndex = 0;
   let lines = 0;
   let indentCount = 0;
   let overCharLimit = false;
 
   // Strip comments.
--- a/devtools/client/debugger/src/utils/memoizableAction.js
+++ b/devtools/client/debugger/src/utils/memoizableAction.js
@@ -1,34 +1,31 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import type { ThunkArgs } from "../actions/types";
+import { asSettled, type AsyncValue } from "./async-value";
 
 export type MemoizedAction<
   Args,
   Result
-> = Args => ThunkArgs => Promise<?Result>;
+> = Args => ThunkArgs => Promise<Result | null>;
 type MemoizableActionParams<Args, Result> = {
-  exitEarly?: (args: Args, thunkArgs: ThunkArgs) => boolean,
-  hasValue: (args: Args, thunkArgs: ThunkArgs) => boolean,
-  getValue: (args: Args, thunkArgs: ThunkArgs) => Result,
+  getValue: (args: Args, thunkArgs: ThunkArgs) => AsyncValue<Result> | null,
   createKey: (args: Args, thunkArgs: ThunkArgs) => string,
-  action: (args: Args, thunkArgs: ThunkArgs) => Promise<Result>,
+  action: (args: Args, thunkArgs: ThunkArgs) => Promise<mixed>,
 };
 
 /*
  * memoizableActon is a utility for actions that should only be performed
  * once per key. It is useful for loading sources, parsing symbols ...
  *
- * @exitEarly - if true, do not attempt to perform the action
- * @hasValue - checks to see if the result is in the redux store
  * @getValue - gets the result from the redux store
  * @createKey - creates a key for the requests map
  * @action - kicks off the async work for the action
  *
  *
  * For Example
  *
  * export const setItem = memoizeableAction(
@@ -39,46 +36,52 @@ type MemoizableActionParams<Args, Result
  *     createKey: ({ a }) => a,
  *     action: ({ a }, thunkArgs) => doSetItem(a, thunkArgs)
  *   }
  * );
  *
  */
 export function memoizeableAction<Args, Result>(
   name: string,
-  {
-    hasValue,
-    getValue,
-    createKey,
-    action,
-    exitEarly,
-  }: MemoizableActionParams<Args, Result>
+  { getValue, createKey, action }: MemoizableActionParams<Args, Result>
 ): MemoizedAction<Args, Result> {
   const requests = new Map();
-  return args => async (thunkArgs: ThunkArgs) => {
-    if (exitEarly && exitEarly(args, thunkArgs)) {
-      return;
-    }
+  return args => async thunkArgs => {
+    let result = asSettled(getValue(args, thunkArgs));
+    if (!result) {
+      const key = createKey(args, thunkArgs);
+      if (!requests.has(key)) {
+        requests.set(
+          key,
+          (async () => {
+            try {
+              await action(args, thunkArgs);
+            } catch (e) {
+              console.warn(`Action ${name} had an exception:`, e);
+            } finally {
+              requests.delete(key);
+            }
+          })()
+        );
+      }
 
-    if (hasValue(args, thunkArgs)) {
-      return getValue(args, thunkArgs);
+      await requests.get(key);
+
+      result = asSettled(getValue(args, thunkArgs));
+
+      if (!result) {
+        // Returning null here is not ideal. This means that the action
+        // resolved but 'getValue' didn't return a loaded value, for instance
+        // if the data the action was meant to store was deleted. In a perfect
+        // world we'd throw a ContextError here or handle cancellation somehow.
+        // Throwing will also allow us to change the return type on the action
+        // to always return a promise for the getValue AsyncValue type, but
+        // for now we have to add an additional '| null' for this case.
+        return null;
+      }
     }
 
-    const key = createKey(args, thunkArgs);
-    if (!requests.has(key)) {
-      requests.set(
-        key,
-        (async () => {
-          try {
-            await action(args, thunkArgs);
-          } catch (e) {
-            console.warn(`Action ${name} had an exception:`, e);
-          } finally {
-            requests.delete(key);
-          }
-        })()
-      );
+    if (result.state === "rejected") {
+      throw result.value;
     }
-
-    await requests.get(key);
-    return getValue(args, thunkArgs);
+    return result.value;
   };
 }
--- a/devtools/client/debugger/src/utils/test-mockup.js
+++ b/devtools/client/debugger/src/utils/test-mockup.js
@@ -22,18 +22,22 @@ import type {
   SourceId,
   SourceWithContentAndType,
   SourceWithContent,
   TextSourceContent,
   WasmSourceContent,
   Why,
 } from "../types";
 import * as asyncValue from "./async-value";
+import type { SourceBase } from "../reducers/sources";
 
-function makeMockSource(url: string = "url", id: SourceId = "source"): Source {
+function makeMockSource(
+  url: string = "url",
+  id: SourceId = "source"
+): SourceBase {
   return {
     id,
     url,
     isBlackBoxed: false,
     isPrettyPrinted: false,
     relativeUrl: url,
     introductionUrl: null,
     introductionType: undefined,
@@ -47,46 +51,46 @@ function makeMockSourceWithContent(
   url?: string,
   id?: SourceId,
   contentType?: string = "text/javascript",
   text?: string = ""
 ): SourceWithContent {
   const source = makeMockSource(url, id);
 
   return {
-    source,
+    ...source,
     content: text
       ? asyncValue.fulfilled({
           type: "text",
           value: text,
           contentType,
         })
       : null,
   };
 }
 
 function makeMockSourceAndContent(
   url?: string,
   id?: SourceId,
   contentType?: string = "text/javascript",
   text: string = ""
-): { source: Source, content: TextSourceContent } {
+): { ...SourceBase, content: TextSourceContent } {
   const source = makeMockSource(url, id);
 
   return {
-    source,
+    ...source,
     content: {
       type: "text",
       value: text,
       contentType,
     },
   };
 }
 
-function makeMockWasmSource(): Source {
+function makeMockWasmSource(): SourceBase {
   return {
     id: "wasm-source-id",
     url: "url",
     isBlackBoxed: false,
     isPrettyPrinted: false,
     relativeUrl: "url",
     introductionUrl: null,
     introductionType: undefined,
@@ -97,17 +101,17 @@ function makeMockWasmSource(): Source {
 }
 
 function makeMockWasmSourceWithContent(text: {|
   binary: Object,
 |}): SourceWithContentAndType<WasmSourceContent> {
   const source = makeMockWasmSource();
 
   return {
-    source,
+    ...source,
     content: asyncValue.fulfilled({
       type: "wasm",
       value: text,
     }),
   };
 }
 
 function makeMockScope(
--- a/devtools/client/debugger/src/utils/tests/ast.spec.js
+++ b/devtools/client/debugger/src/utils/tests/ast.spec.js
@@ -5,17 +5,17 @@
 // @flow
 
 import { findBestMatchExpression } from "../ast";
 
 import { getSymbols } from "../../workers/parser/getSymbols";
 import { populateSource } from "../../workers/parser/tests/helpers";
 
 describe("find the best expression for the token", () => {
-  const { source } = populateSource("computed-props");
+  const source = populateSource("computed-props");
   const symbols = getSymbols(source.id);
 
   it("should find the identifier", () => {
     const expression = findBestMatchExpression(symbols, {
       line: 1,
       column: 13,
     });
     expect(expression).toMatchSnapshot();
--- a/devtools/client/debugger/src/utils/tests/function.spec.js
+++ b/devtools/client/debugger/src/utils/tests/function.spec.js
@@ -8,56 +8,56 @@ import { findFunctionText } from "../fun
 
 import { getSymbols } from "../../workers/parser/getSymbols";
 import { populateOriginalSource } from "../../workers/parser/tests/helpers";
 
 describe("function", () => {
   describe("findFunctionText", () => {
     it("finds function", () => {
       const source = populateOriginalSource("func");
-      const symbols = getSymbols(source.source.id);
+      const symbols = getSymbols(source.id);
       const text = findFunctionText(14, source, symbols);
       expect(text).toMatchSnapshot();
     });
 
     it("finds function signature", () => {
       const source = populateOriginalSource("func");
-      const symbols = getSymbols(source.source.id);
+      const symbols = getSymbols(source.id);
 
       const text = findFunctionText(13, source, symbols);
       expect(text).toMatchSnapshot();
     });
 
     it("misses function closing brace", () => {
       const source = populateOriginalSource("func");
-      const symbols = getSymbols(source.source.id);
+      const symbols = getSymbols(source.id);
 
       const text = findFunctionText(15, source, symbols);
 
       // TODO: we should try and match the closing bracket.
       expect(text).toEqual(null);
     });
 
     it("finds property function", () => {
       const source = populateOriginalSource("func");
-      const symbols = getSymbols(source.source.id);
+      const symbols = getSymbols(source.id);
 
       const text = findFunctionText(29, source, symbols);
       expect(text).toMatchSnapshot();
     });
 
     it("finds class function", () => {
       const source = populateOriginalSource("func");
-      const symbols = getSymbols(source.source.id);
+      const symbols = getSymbols(source.id);
 
       const text = findFunctionText(33, source, symbols);
       expect(text).toMatchSnapshot();
     });
 
     it("cant find function", () => {
       const source = populateOriginalSource("func");
-      const symbols = getSymbols(source.source.id);
+      const symbols = getSymbols(source.id);
 
       const text = findFunctionText(20, source, symbols);
       expect(text).toEqual(null);
     });
   });
 });
--- a/devtools/client/debugger/src/utils/tests/source.spec.js
+++ b/devtools/client/debugger/src/utils/tests/source.spec.js
@@ -232,57 +232,53 @@ describe("sources", () => {
         getFileURL(makeMockSource(`http://${encodedUnicode.repeat(39)}.html`))
       ).toBe(`…ttp://${unicode.repeat(39)}.html`);
     });
   });
 
   describe("isJavaScript", () => {
     it("is not JavaScript", () => {
       {
-        const { source, content } = makeMockSourceAndContent(
-          "foo.html",
-          undefined,
-          ""
-        );
-        expect(isJavaScript(source, content)).toBe(false);
+        const source = makeMockSourceAndContent("foo.html", undefined, "");
+        expect(isJavaScript(source, source.content)).toBe(false);
       }
       {
-        const { source, content } = makeMockSourceAndContent(
+        const source = makeMockSourceAndContent(
           undefined,
           undefined,
           "text/html"
         );
-        expect(isJavaScript(source, content)).toBe(false);
+        expect(isJavaScript(source, source.content)).toBe(false);
       }
     });
 
     it("is JavaScript", () => {
       {
-        const { source, content } = makeMockSourceAndContent("foo.js");
-        expect(isJavaScript(source, content)).toBe(true);
+        const source = makeMockSourceAndContent("foo.js");
+        expect(isJavaScript(source, source.content)).toBe(true);
       }
       {
-        const { source, content } = makeMockSourceAndContent("bar.jsm");
-        expect(isJavaScript(source, content)).toBe(true);
+        const source = makeMockSourceAndContent("bar.jsm");
+        expect(isJavaScript(source, source.content)).toBe(true);
       }
       {
-        const { source, content } = makeMockSourceAndContent(
+        const source = makeMockSourceAndContent(
           undefined,
           undefined,
           "text/javascript"
         );
-        expect(isJavaScript(source, content)).toBe(true);
+        expect(isJavaScript(source, source.content)).toBe(true);
       }
       {
-        const { source, content } = makeMockSourceAndContent(
+        const source = makeMockSourceAndContent(
           undefined,
           undefined,
           "application/javascript"
         );
-        expect(isJavaScript(source, content)).toBe(true);
+        expect(isJavaScript(source, source.content)).toBe(true);
       }
     });
   });
 
   describe("isThirdParty", () => {
     it("node_modules", () => {
       expect(isThirdParty(makeMockSource("/node_modules/foo.js"))).toBe(true);
     });
@@ -295,186 +291,189 @@ describe("sources", () => {
 
     it("not third party", () => {
       expect(isThirdParty(makeMockSource("/bar/foo.js"))).toBe(false);
     });
   });
 
   describe("getMode", () => {
     it("//@flow", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "text/javascript",
         "// @flow"
       );
-      expect(getMode(source, content)).toEqual({
+      expect(getMode(source, source.content)).toEqual({
         name: "javascript",
         typescript: true,
       });
     });
 
     it("/* @flow */", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "text/javascript",
         "   /* @flow */"
       );
-      expect(getMode(source, content)).toEqual({
+      expect(getMode(source, source.content)).toEqual({
         name: "javascript",
         typescript: true,
       });
     });
 
     it("mixed html", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "",
         " <html"
       );
-      expect(getMode(source, content)).toEqual({ name: "htmlmixed" });
+      expect(getMode(source, source.content)).toEqual({ name: "htmlmixed" });
     });
 
     it("elm", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "text/x-elm",
         'main = text "Hello, World!"'
       );
-      expect(getMode(source, content)).toEqual({ name: "elm" });
+      expect(getMode(source, source.content)).toEqual({ name: "elm" });
     });
 
     it("returns jsx if contentType jsx is given", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "text/jsx",
         "<h1></h1>"
       );
-      expect(getMode(source, content)).toEqual({ name: "jsx" });
+      expect(getMode(source, source.content)).toEqual({ name: "jsx" });
     });
 
     it("returns jsx if sourceMetaData says it's a react component", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "",
         "<h1></h1>"
       );
       expect(
-        getMode(source, content, { ...defaultSymbolDeclarations, hasJsx: true })
+        getMode(source, source.content, {
+          ...defaultSymbolDeclarations,
+          hasJsx: true,
+        })
       ).toEqual({ name: "jsx" });
     });
 
     it("returns jsx if the fileExtension is .jsx", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         "myComponent.jsx",
         undefined,
         "",
         "<h1></h1>"
       );
-      expect(getMode(source, content)).toEqual({ name: "jsx" });
+      expect(getMode(source, source.content)).toEqual({ name: "jsx" });
     });
 
     it("returns text/x-haxe if the file extension is .hx", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         "myComponent.hx",
         undefined,
         "",
         "function foo(){}"
       );
-      expect(getMode(source, content)).toEqual({ name: "text/x-haxe" });
+      expect(getMode(source, source.content)).toEqual({ name: "text/x-haxe" });
     });
 
     it("typescript", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "text/typescript",
         "function foo(){}"
       );
-      expect(getMode(source, content)).toEqual({
+      expect(getMode(source, source.content)).toEqual({
         name: "javascript",
         typescript: true,
       });
     });
 
     it("typescript-jsx", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "text/typescript-jsx",
         "<h1></h1>"
       );
-      expect(getMode(source, content).base).toEqual({
+      expect(getMode(source, source.content).base).toEqual({
         name: "javascript",
         typescript: true,
       });
     });
 
     it("cross-platform clojure(script) with reader conditionals", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         "my-clojurescript-source-with-reader-conditionals.cljc",
         undefined,
         "text/x-clojure",
         "(defn str->int [s] " +
           "  #?(:clj  (java.lang.Integer/parseInt s) " +
           "     :cljs (js/parseInt s)))"
       );
-      expect(getMode(source, content)).toEqual({ name: "clojure" });
+      expect(getMode(source, source.content)).toEqual({ name: "clojure" });
     });
 
     it("clojurescript", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         "my-clojurescript-source.cljs",
         undefined,
         "text/x-clojurescript",
         "(+ 1 2 3)"
       );
-      expect(getMode(source, content)).toEqual({ name: "clojure" });
+      expect(getMode(source, source.content)).toEqual({ name: "clojure" });
     });
 
     it("coffeescript", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         undefined,
         undefined,
         "text/coffeescript",
         "x = (a) -> 3"
       );
-      expect(getMode(source, content)).toEqual({ name: "coffeescript" });
+      expect(getMode(source, source.content)).toEqual({ name: "coffeescript" });
     });
 
     it("wasm", () => {
-      const { source, content } = makeMockWasmSourceWithContent({
+      const source = makeMockWasmSourceWithContent({
         binary: "\x00asm\x01\x00\x00\x00",
       });
-      expect(getMode(source, content.value)).toEqual({ name: "text" });
+      expect(getMode(source, source.content.value)).toEqual({ name: "text" });
     });
 
     it("marko", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         "http://localhost.com:7999/increment/sometestfile.marko",
         undefined,
         "does not matter",
         "function foo(){}"
       );
-      expect(getMode(source, content)).toEqual({ name: "javascript" });
+      expect(getMode(source, source.content)).toEqual({ name: "javascript" });
     });
 
     it("es6", () => {
-      const { source, content } = makeMockSourceAndContent(
+      const source = makeMockSourceAndContent(
         "http://localhost.com:7999/increment/sometestfile.es6",
         undefined,
         "does not matter",
         "function foo(){}"
       );
-      expect(getMode(source, content)).toEqual({ name: "javascript" });
+      expect(getMode(source, source.content)).toEqual({ name: "javascript" });
     });
   });
 
   describe("getSourceLineCount", () => {
     it("should give us the amount bytes for wasm source", () => {
       const { content } = makeMockWasmSourceWithContent({
         binary: "\x00asm\x01\x00\x00\x00",
       });
--- a/devtools/client/debugger/src/utils/tests/wasm.spec.js
+++ b/devtools/client/debugger/src/utils/tests/wasm.spec.js
@@ -32,49 +32,49 @@ describe("wasm", () => {
   const SIMPLE_WASM_NOP_OFFSET = 46;
 
   describe("isWasm", () => {
     it("should give us the false when wasm text was not registered", () => {
       const sourceId = "source.0";
       expect(isWasm(sourceId)).toEqual(false);
     });
     it("should give us the true when wasm text was registered", () => {
-      const { source, content } = makeMockWasmSourceWithContent(SIMPLE_WASM);
-      renderWasmText(source.id, content.value);
+      const source = makeMockWasmSourceWithContent(SIMPLE_WASM);
+      renderWasmText(source.id, source.content.value);
       expect(isWasm(source.id)).toEqual(true);
       // clear shall remove
       clearWasmStates();
       expect(isWasm(source.id)).toEqual(false);
     });
   });
 
   describe("renderWasmText", () => {
     it("render simple wasm", () => {
-      const { source, content } = makeMockWasmSourceWithContent(SIMPLE_WASM);
-      const lines = renderWasmText(source.id, content.value);
+      const source = makeMockWasmSourceWithContent(SIMPLE_WASM);
+      const lines = renderWasmText(source.id, source.content.value);
       expect(lines.join("\n")).toEqual(SIMPLE_WASM_TEXT);
       clearWasmStates();
     });
   });
 
   describe("lineToWasmOffset", () => {
     // Test data sanity check: checking if 'nop' is found in the SIMPLE_WASM.
     expect(SIMPLE_WASM.binary[SIMPLE_WASM_NOP_OFFSET]).toEqual("\x01");
 
     it("get simple wasm nop offset", () => {
-      const { source, content } = makeMockWasmSourceWithContent(SIMPLE_WASM);
-      renderWasmText(source.id, content.value);
+      const source = makeMockWasmSourceWithContent(SIMPLE_WASM);
+      renderWasmText(source.id, source.content.value);
       const offset = lineToWasmOffset(source.id, SIMPLE_WASM_NOP_TEXT_LINE);
       expect(offset).toEqual(SIMPLE_WASM_NOP_OFFSET);
       clearWasmStates();
     });
   });
 
   describe("wasmOffsetToLine", () => {
     it("get simple wasm nop line", () => {
-      const { source, content } = makeMockWasmSourceWithContent(SIMPLE_WASM);
-      renderWasmText(source.id, content.value);
+      const source = makeMockWasmSourceWithContent(SIMPLE_WASM);
+      renderWasmText(source.id, source.content.value);
       const line = wasmOffsetToLine(source.id, SIMPLE_WASM_NOP_OFFSET);
       expect(line).toEqual(SIMPLE_WASM_NOP_TEXT_LINE);
       clearWasmStates();
     });
   });
 });
--- a/devtools/client/debugger/src/workers/parser/tests/findOutOfScopeLocations.spec.js
+++ b/devtools/client/debugger/src/workers/parser/tests/findOutOfScopeLocations.spec.js
@@ -15,58 +15,58 @@ function formatLines(actual) {
       ({ start, end }) =>
         `(${start.line}, ${start.column}) -> (${end.line}, ${end.column})`
     )
     .join("\n");
 }
 
 describe("Parser.findOutOfScopeLocations", () => {
   it("should exclude non-enclosing function blocks", () => {
-    const { source } = populateSource("outOfScope");
+    const source = populateSource("outOfScope");
     const actual = findOutOfScopeLocations(source.id, {
       line: 5,
       column: 5,
     });
 
     expect(formatLines(actual)).toMatchSnapshot();
   });
 
   it("should roll up function blocks", () => {
-    const { source } = populateSource("outOfScope");
+    const source = populateSource("outOfScope");
     const actual = findOutOfScopeLocations(source.id, {
       line: 24,
       column: 0,
     });
 
     expect(formatLines(actual)).toMatchSnapshot();
   });
 
   it("should exclude function for locations on declaration", () => {
-    const { source } = populateSource("outOfScope");
+    const source = populateSource("outOfScope");
     const actual = findOutOfScopeLocations(source.id, {
       line: 3,
       column: 12,
     });
 
     expect(formatLines(actual)).toMatchSnapshot();
   });
 
   it("should treat comments as out of scope", () => {
-    const { source } = populateSource("outOfScopeComment");
+    const source = populateSource("outOfScopeComment");
     const actual = findOutOfScopeLocations(source.id, {
       line: 3,
       column: 2,
     });
 
     expect(actual).toEqual([
       { end: { column: 15, line: 1 }, start: { column: 0, line: 1 } },
     ]);
   });
 
   it("should not exclude in-scope inner locations", () => {
-    const { source } = populateSource("outOfScope");
+    const source = populateSource("outOfScope");
     const actual = findOutOfScopeLocations(source.id, {
       line: 61,
       column: 0,
     });
     expect(formatLines(actual)).toMatchSnapshot();
   });
 });
--- a/devtools/client/debugger/src/workers/parser/tests/framework.spec.js
+++ b/devtools/client/debugger/src/workers/parser/tests/framework.spec.js
@@ -6,17 +6,17 @@
 
 import { getSymbols } from "../getSymbols";
 import { populateOriginalSource } from "./helpers";
 import cases from "jest-in-case";
 
 cases(
   "Parser.getFramework",
   ({ name, file, value }) => {
-    const { source } = populateOriginalSource("frameworks/plainJavascript");
+    const source = populateOriginalSource("frameworks/plainJavascript");
     const symbols = getSymbols(source.id);
     expect(symbols.framework).toBeUndefined();
   },
   [
     {
       name: "is undefined when no framework",
       file: "frameworks/plainJavascript",
       value: undefined,
--- a/devtools/client/debugger/src/workers/parser/tests/getScopes.spec.js
+++ b/devtools/client/debugger/src/workers/parser/tests/getScopes.spec.js
@@ -7,17 +7,17 @@
 
 import getScopes from "../getScopes";
 import { populateOriginalSource } from "./helpers";
 import cases from "jest-in-case";
 
 cases(
   "Parser.getScopes",
   ({ name, file, type, locations }) => {
-    const { source } = populateOriginalSource(file, type);
+    const source = populateOriginalSource(file, type);
 
     locations.forEach(([line, column]) => {
       const scopes = getScopes({
         sourceId: source.id,
         line,
         column,
       });
 
--- a/devtools/client/debugger/src/workers/parser/tests/getSymbols.spec.js
+++ b/devtools/client/debugger/src/workers/parser/tests/getSymbols.spec.js
@@ -7,17 +7,17 @@
 
 import { formatSymbols } from "../utils/formatSymbols";
 import { populateSource, populateOriginalSource } from "./helpers";
 import cases from "jest-in-case";
 
 cases(
   "Parser.getSymbols",
   ({ name, file, original, type }) => {
-    const { source } = original
+    const source = original
       ? populateOriginalSource(file, type)
       : populateSource(file, type);
 
     expect(formatSymbols(source.id)).toMatchSnapshot();
   },
   [
     { name: "es6", file: "es6", original: true },
     { name: "func", file: "func", original: true },
--- a/devtools/client/debugger/src/workers/parser/tests/helpers/index.js
+++ b/devtools/client/debugger/src/workers/parser/tests/helpers/index.js
@@ -5,16 +5,17 @@
 // @flow
 
 import fs from "fs";
 import path from "path";
 
 import type {
   Source,
   TextSourceContent,
+  SourceBase,
   SourceWithContent,
 } from "../../../../types";
 import { makeMockSourceAndContent } from "../../../../utils/test-mockup";
 import { setSource } from "../../sources";
 import * as asyncValue from "../../../../utils/async-value";
 
 export function getFixture(name: string, type: string = "js") {
   return fs.readFileSync(
@@ -42,68 +43,68 @@ function getSourceContent(
   return {
     type: "text",
     value: text,
     contentType,
   };
 }
 
 export function getSource(name: string, type?: string): Source {
-  return getSourceWithContent(name, type).source;
+  return getSourceWithContent(name, type);
 }
 
 export function getSourceWithContent(
   name: string,
   type?: string
-): { source: Source, content: TextSourceContent } {
+): { ...SourceBase, content: TextSourceContent } {
   const { value: text, contentType } = getSourceContent(name, type);
 
   return makeMockSourceAndContent(undefined, name, contentType, text);
 }
 
 export function populateSource(name: string, type?: string): SourceWithContent {
-  const { source, content } = getSourceWithContent(name, type);
+  const { content, ...source } = getSourceWithContent(name, type);
   setSource({
     id: source.id,
     text: content.value,
     contentType: content.contentType,
     isWasm: false,
   });
   return {
-    source,
+    ...source,
     content: asyncValue.fulfilled(content),
   };
 }
 
 export function getOriginalSource(name: string, type?: string): Source {
-  return getOriginalSourceWithContent(name, type).source;
+  return getOriginalSourceWithContent(name, type);
 }
 
 export function getOriginalSourceWithContent(
   name: string,
   type?: string
-): { source: Source, content: TextSourceContent } {
+): { ...SourceBase, content: TextSourceContent } {
   const { value: text, contentType } = getSourceContent(name, type);
 
   return makeMockSourceAndContent(
     undefined,
     `${name}/originalSource-1`,
     contentType,
     text
   );
 }
 
 export function populateOriginalSource(
   name: string,
   type?: string
 ): SourceWithContent {
-  const { source, content } = getOriginalSourceWithContent(name, type);
+  const { content, ...source } = getOriginalSourceWithContent(name, type);
   setSource({
     id: source.id,
     text: content.value,
     contentType: content.contentType,
     isWasm: false,
   });
   return {
-    source,
+    ...source,
     content: asyncValue.fulfilled(content),
   };
 }
--- a/devtools/client/debugger/src/workers/parser/tests/steps.spec.js
+++ b/devtools/client/debugger/src/workers/parser/tests/steps.spec.js
@@ -5,56 +5,56 @@
 // @flow
 
 import { getNextStep } from "../steps";
 import { populateSource } from "./helpers";
 
 describe("getNextStep", () => {
   describe("await", () => {
     it("first await call", () => {
-      const { source } = populateSource("async");
+      const source = populateSource("async");
       const pausePosition = { line: 8, column: 2, sourceId: source.id };
       expect(getNextStep(source.id, pausePosition)).toEqual({
         ...pausePosition,
         line: 9,
       });
     });
 
     it("first await call expression", () => {
-      const { source } = populateSource("async");
+      const source = populateSource("async");
       const pausePosition = { line: 8, column: 9, sourceId: source.id };
       expect(getNextStep(source.id, pausePosition)).toEqual({
         ...pausePosition,
         line: 9,
         column: 2,
       });
     });
 
     it("second await call", () => {
-      const { source } = populateSource("async");
+      const source = populateSource("async");
       const pausePosition = { line: 9, column: 2, sourceId: source.id };
       expect(getNextStep(source.id, pausePosition)).toEqual(null);
     });
 
     it("second call expression", () => {
-      const { source } = populateSource("async");
+      const source = populateSource("async");
       const pausePosition = { line: 9, column: 9, sourceId: source.id };
       expect(getNextStep(source.id, pausePosition)).toEqual(null);
     });
   });
 
   describe("yield", () => {
     it("first yield call", () => {
-      const { source } = populateSource("generators");
+      const source = populateSource("generators");
       const pausePosition = { line: 2, column: 2, sourceId: source.id };
       expect(getNextStep(source.id, pausePosition)).toEqual({
         ...pausePosition,
         line: 3,
       });
     });
 
     it("second yield call", () => {
-      const { source } = populateSource("generators");
+      const source = populateSource("generators");
       const pausePosition = { line: 3, column: 2, sourceId: source.id };
       expect(getNextStep(source.id, pausePosition)).toEqual(null);
     });
   });
 });
--- a/devtools/client/debugger/src/workers/parser/utils/tests/ast.spec.js
+++ b/devtools/client/debugger/src/workers/parser/utils/tests/ast.spec.js
@@ -18,26 +18,21 @@ const astKeys = [
   "program",
   "comments",
   "tokens",
 ];
 
 cases(
   "ast.getAst",
   ({ name }) => {
-    const { source, content } = makeMockSourceAndContent(
-      undefined,
-      "foo",
-      name,
-      "2"
-    );
+    const source = makeMockSourceAndContent(undefined, "foo", name, "2");
     setSource({
       id: source.id,
-      text: content.value || "",
-      contentType: content.contentType,
+      text: source.content.value || "",
+      contentType: source.content.contentType,
       isWasm: false,
     });
     const ast = getAst("foo");
     expect(ast && Object.keys(ast)).toEqual(astKeys);
   },
   [
     { name: "text/javascript" },
     { name: "application/javascript" },
--- a/devtools/client/debugger/test/mochitest/helpers.js
+++ b/devtools/client/debugger/test/mochitest/helpers.js
@@ -236,18 +236,18 @@ function waitForSelectedSource(dbg, url)
     getSelectedSourceWithContent,
     hasSymbols,
     getBreakableLines,
   } = dbg.selectors;
 
   return waitForState(
     dbg,
     state => {
-      const { source, content } = getSelectedSourceWithContent() || {};
-      if (!content) {
+      const source = getSelectedSourceWithContent() || {};
+      if (!source.content) {
         return false;
       }
 
       if (!url) {
         return true;
       }
 
       const newSource = findSource(dbg, url, { silent: true });
@@ -313,19 +313,18 @@ function assertPausedLocation(dbg) {
   assertDebugLine(dbg, pauseLine);
 
   ok(isVisibleInEditor(dbg, getCM(dbg).display.gutters), "gutter is visible");
 }
 
 function assertDebugLine(dbg, line) {
   // Check the debug line
   const lineInfo = getCM(dbg).lineInfo(line - 1);
-  const { source, content } =
-    dbg.selectors.getSelectedSourceWithContent() || {};
-  if (source && !content) {
+  const source = dbg.selectors.getSelectedSourceWithContent() || {};
+  if (source && !source.content) {
     const url = source.url;
     ok(
       false,
       `Looks like the source ${url} is still loading. Try adding waitForLoadedSource in the test.`
     );
     return;
   }
 
@@ -510,24 +509,19 @@ function waitForTime(ms) {
 }
 
 function isSelectedFrameSelected(dbg, state) {
   const frame = dbg.selectors.getVisibleSelectedFrame();
 
   // Make sure the source text is completely loaded for the
   // source we are paused in.
   const sourceId = frame.location.sourceId;
-  const { source, content } =
-    dbg.selectors.getSelectedSourceWithContent() || {};
+  const source = dbg.selectors.getSelectedSourceWithContent() || {};
 
-  if (!source) {
-    return false;
-  }
-
-  if (!content) {
+  if (!source || !source.content) {
     return false;
   }
 
   return source.id == sourceId;
 }
 
 /**
  * Clear all the debugger related preferences.
--- a/devtools/client/definitions.js
+++ b/devtools/client/definitions.js
@@ -158,19 +158,18 @@ Tools.inspector = {
     return l10n("inspector.tooltip2", ctrlShiftC);
   },
   inMenu: true,
 
   preventClosingOnKey: true,
   // preventRaisingOnKey is used to keep the focus on the content window for shortcuts
   // that trigger the element picker.
   preventRaisingOnKey: true,
-  onkey: async function(panel, toolbox) {
-    const inspectorFront = await toolbox.target.getFront("inspector");
-    inspectorFront.nodePicker.togglePicker();
+  onkey: function(panel, toolbox) {
+    toolbox.nodePicker.togglePicker();
   },
 
   isTargetSupported: function(target) {
     return target.hasActor("inspector");
   },
 
   build: function(iframeWindow, toolbox) {
     return new InspectorPanel(iframeWindow, toolbox);
--- a/devtools/client/framework/test/browser_keybindings_01.js
+++ b/devtools/client/framework/test/browser_keybindings_01.js
@@ -99,23 +99,22 @@ add_task(async function() {
     await inspectorShouldBeOpenAndHighlighting(inspectorKeys[1]);
   }
 
   gBrowser.removeCurrentTab();
 
   async function inspectorShouldBeOpenAndHighlighting(inspector) {
     is(toolbox.currentToolId, "inspector", "Correct tool has been loaded");
 
-    const nodePicker = (await toolbox.target.getFront("inspector")).nodePicker;
-    await nodePicker.once("picker-started");
+    await toolbox.nodePicker.once("picker-started");
 
     ok(true, "picker-started event received, highlighter started");
     inspector.synthesizeKey();
 
-    await nodePicker.once("picker-stopped");
+    await toolbox.nodePicker.once("picker-stopped");
     ok(true, "picker-stopped event received, highlighter stopped");
   }
 
   function webconsoleShouldBeSelected() {
     is(toolbox.currentToolId, "webconsole", "webconsole should be selected.");
   }
 
   function netmonitorShouldBeSelected() {
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -33,16 +33,21 @@ var {
 const { KeyCodes } = require("devtools/client/shared/keycodes");
 var Startup = Cc["@mozilla.org/devtools/startup-clh;1"].getService(
   Ci.nsISupports
 ).wrappedJSObject;
 
 const { BrowserLoader } = ChromeUtils.import(
   "resource://devtools/client/shared/browser-loader.js"
 );
+loader.lazyRequireGetter(
+  this,
+  "NodePicker",
+  "devtools/client/inspector/node-picker"
+);
 
 const { LocalizationHelper } = require("devtools/shared/l10n");
 const L10N = new LocalizationHelper(
   "devtools/client/locales/toolbox.properties"
 );
 
 loader.lazyRequireGetter(
   this,
@@ -1762,52 +1767,52 @@ Toolbox.prototype = {
       this.hostType === Toolbox.HostType.RIGHT;
     const currentPanel = this.getCurrentPanel();
     if (currentPanel.togglePicker) {
       currentPanel.togglePicker(focus);
     } else {
       if (!this.inspectorFront) {
         await this.initInspector();
       }
-      this.inspectorFront.nodePicker.togglePicker(focus);
+      this.nodePicker.togglePicker(focus);
     }
   },
 
   /**
    * If the picker is activated, then allow the Escape key to deactivate the
    * functionality instead of the default behavior of toggling the console.
    */
   _onPickerKeypress: function(event) {
     if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) {
       const currentPanel = this.getCurrentPanel();
       if (currentPanel.cancelPicker) {
         currentPanel.cancelPicker();
       } else {
-        this.inspectorFront.nodePicker.cancel();
+        this.nodePicker.cancel();
       }
       // Stop the console from toggling.
       event.stopImmediatePropagation();
     }
   },
 
   _onPickerStarting: async function() {
     this.tellRDMAboutPickerState(true);
     this.pickerButton.isChecked = true;
     await this.selectTool("inspector", "inspect_dom");
-    this.on("select", this.inspectorFront.nodePicker.stop);
+    this.on("select", this.nodePicker.stop);
   },
 
   _onPickerStarted: async function() {
     this.doc.addEventListener("keypress", this._onPickerKeypress, true);
     this.telemetry.scalarAdd("devtools.inspector.element_picker_used", 1);
   },
 
   _onPickerStopped: function() {
     this.tellRDMAboutPickerState(false);
-    this.off("select", this.inspectorFront.nodePicker.stop);
+    this.off("select", this.nodePicker.stop);
     this.doc.removeEventListener("keypress", this._onPickerKeypress, true);
     this.pickerButton.isChecked = false;
   },
 
   /**
    * When the picker is canceled, make sure the toolbox
    * gets the focus.
    */
@@ -3318,41 +3323,24 @@ Toolbox.prototype = {
       this._initInspector = async function() {
         // Temporary fix for bug #1493131 - inspector has a different life cycle
         // than most other fronts because it is closely related to the toolbox.
         // TODO: replace with getFront once inspector is separated from the toolbox
         // TODO: remove these bindings
         this._inspector = await this.target.getFront("inspector");
         this._walker = this.inspectorFront.walker;
         this._highlighter = this.inspectorFront.highlighter;
-
-        this.inspectorFront.nodePicker.on(
-          "picker-starting",
-          this._onPickerStarting
-        );
-        this.inspectorFront.nodePicker.on(
-          "picker-started",
-          this._onPickerStarted
-        );
-        this.inspectorFront.nodePicker.on(
-          "picker-stopped",
-          this._onPickerStopped
-        );
-        this.inspectorFront.nodePicker.on(
-          "picker-node-canceled",
-          this._onPickerCanceled
-        );
-        this.inspectorFront.nodePicker.on(
-          "picker-node-picked",
-          this._onPickerPicked
-        );
-        this.inspectorFront.nodePicker.on(
-          "picker-node-previewed",
-          this._onPickerPreviewed
-        );
+        this.nodePicker = new NodePicker(this.target, this.selection);
+
+        this.nodePicker.on("picker-starting", this._onPickerStarting);
+        this.nodePicker.on("picker-started", this._onPickerStarted);
+        this.nodePicker.on("picker-stopped", this._onPickerStopped);
+        this.nodePicker.on("picker-node-canceled", this._onPickerCanceled);
+        this.nodePicker.on("picker-node-picked", this._onPickerPicked);
+        this.nodePicker.on("picker-node-previewed", this._onPickerPreviewed);
         registerWalkerListeners(this);
       }.bind(this)();
     }
     return this._initInspector;
   },
 
   /**
    * An helper function that returns an object contain a highlighter and unhighlighter
--- a/devtools/client/inspector/animation/animation.js
+++ b/devtools/client/inspector/animation/animation.js
@@ -145,21 +145,21 @@ class AnimationInspector {
         simulateAnimation,
         simulateAnimationForKeyframesProgressBar,
         toggleElementPicker,
       })
     );
     this.provider = provider;
 
     this.inspector.sidebar.on("select", this.onSidebarSelectionChanged);
-    this.inspector.inspectorFront.nodePicker.on(
+    this.inspector.toolbox.nodePicker.on(
       "picker-started",
       this.onElementPickerStarted
     );
-    this.inspector.inspectorFront.nodePicker.on(
+    this.inspector.toolbox.nodePicker.on(
       "picker-stopped",
       this.onElementPickerStopped
     );
     this.inspector.toolbox.on("select", this.onSidebarSelectionChanged);
   }
 
   _getAnimationsFront() {
     if (this.animationsFrontPromise) {
@@ -178,21 +178,21 @@ class AnimationInspector {
     this.setAnimationStateChangedListenerEnabled(false);
     this.inspector.off("new-root", this.onNavigate);
     this.inspector.selection.off("new-node-front", this.update);
     this.inspector.sidebar.off("select", this.onSidebarSelectionChanged);
     this.inspector.toolbox.off(
       "inspector-sidebar-resized",
       this.onSidebarResized
     );
-    this.inspector.inspectorFront.nodePicker.off(
+    this.inspector.toolbox.nodePicker.off(
       "picker-started",
       this.onElementPickerStarted
     );
-    this.inspector.inspectorFront.nodePicker.off(
+    this.inspector.toolbox.nodePicker.off(
       "picker-stopped",
       this.onElementPickerStopped
     );
     this.inspector.toolbox.off("select", this.onSidebarSelectionChanged);
 
     this.animationsFrontPromise.then(front => {
       front.off("mutations", this.onAnimationsMutation);
     });
@@ -706,17 +706,17 @@ class AnimationInspector {
       this.win,
       this.onCurrentTimeTimerUpdated
     );
     currentTimeTimer.start();
     this.currentTimeTimer = currentTimeTimer;
   }
 
   toggleElementPicker() {
-    this.inspector.inspectorFront.nodePicker.togglePicker();
+    this.inspector.toolbox.nodePicker.togglePicker();
   }
 
   async update() {
     const done = this.inspector.updating("animationinspector");
 
     const selection = this.inspector.selection;
     const animationsFront = await this.animationsFrontPromise;
     const animations =
--- a/devtools/client/inspector/boxmodel/box-model.js
+++ b/devtools/client/inspector/boxmodel/box-model.js
@@ -248,17 +248,20 @@ BoxModel.prototype = {
    * geometry editor enabled state.
    */
   onHideGeometryEditor() {
     const { markup, selection, inspector } = this.inspector;
 
     this.highlighters.hideGeometryEditor();
     this.store.dispatch(updateGeometryEditorEnabled(false));
 
-    inspector.nodePicker.off("picker-started", this.onHideGeometryEditor);
+    inspector.toolbox.nodePicker.off(
+      "picker-started",
+      this.onHideGeometryEditor
+    );
     selection.off("new-node-front", this.onHideGeometryEditor);
     markup.off("leave", this.onMarkupViewLeave);
     markup.off("node-hover", this.onMarkupViewNodeHover);
   },
 
   /**
    * Handler function that re-shows the geometry editor for an element that already
    * had the geometry editor enabled. This handler function is called on a "leave" event
@@ -437,23 +440,29 @@ BoxModel.prototype = {
     const state = this.store.getState();
     const enabled = !state.boxModel.geometryEditorEnabled;
 
     this.highlighters.toggleGeometryHighlighter(nodeFront);
     this.store.dispatch(updateGeometryEditorEnabled(enabled));
 
     if (enabled) {
       // Hide completely the geometry editor if the picker is clicked or a new node front
-      inspector.nodePicker.on("picker-started", this.onHideGeometryEditor);
+      inspector.toolbox.nodePicker.on(
+        "picker-started",
+        this.onHideGeometryEditor
+      );
       selection.on("new-node-front", this.onHideGeometryEditor);
       // Temporary hide the geometry editor
       markup.on("leave", this.onMarkupViewLeave);
       markup.on("node-hover", this.onMarkupViewNodeHover);
     } else {
-      inspector.nodePicker.off("picker-started", this.onHideGeometryEditor);
+      inspector.toolbox.nodePicker.off(
+        "picker-started",
+        this.onHideGeometryEditor
+      );
       selection.off("new-node-front", this.onHideGeometryEditor);
       markup.off("leave", this.onMarkupViewLeave);
       markup.off("node-hover", this.onMarkupViewNodeHover);
     }
   },
 };
 
 module.exports = BoxModel;
--- a/devtools/client/inspector/boxmodel/test/head.js
+++ b/devtools/client/inspector/boxmodel/test/head.js
@@ -26,19 +26,17 @@ registerCleanupFunction(() => {
  * @param  {InspectorPanel} inspector
  *         The instance of InspectorPanel currently loaded in the toolbox.
  * @return {Promise} a promise that resolves when the inspector is updated with the new
  *         node.
  */
 async function selectAndHighlightNode(selectorOrNodeFront, inspector) {
   info("Highlighting and selecting the node " + selectorOrNodeFront);
   const nodeFront = await getNodeFront(selectorOrNodeFront, inspector);
-  const onHovered = inspector.inspectorFront.nodePicker.once(
-    "picker-node-hovered"
-  );
+  const onHovered = inspector.toolbox.nodePicker.once("picker-node-hovered");
   inspector.selection.setNodeFront(nodeFront, { reason: "test-highlight" });
   await onHovered;
 }
 
 /**
  * Is the given node visible in the page (rendered in the frame tree).
  * @param {DOMNode}
  * @return {Boolean}
--- a/devtools/client/inspector/markup/markup.js
+++ b/devtools/client/inspector/markup/markup.js
@@ -313,21 +313,21 @@ function MarkupView(inspector, frame, co
   this._elt.addEventListener("mouseout", this._onMouseOut);
   this._frame.addEventListener("focus", this._onFocus);
   this.inspector.selection.on("new-node-front", this._onNewSelection);
   this.walker.on("display-change", this._onWalkerNodeStatesChanged);
   this.walker.on("scrollable-change", this._onWalkerNodeStatesChanged);
   this.walker.on("mutations", this._mutationObserver);
   this.win.addEventListener("copy", this._onCopy);
   this.win.addEventListener("mouseup", this._onMouseUp);
-  this.inspector.inspectorFront.nodePicker.on(
+  this.inspector.toolbox.nodePicker.on(
     "picker-node-canceled",
     this._onToolboxPickerCanceled
   );
-  this.inspector.inspectorFront.nodePicker.on(
+  this.inspector.toolbox.nodePicker.on(
     "picker-node-hovered",
     this._onToolboxPickerHover
   );
 
   if (flags.testing) {
     // In tests, we start listening immediately to avoid having to simulate a mousemove.
     this._initTooltips();
   } else {
@@ -2264,17 +2264,17 @@ MarkupView.prototype = {
 
     this._elt.removeEventListener("blur", this._onBlur, true);
     this._elt.removeEventListener("click", this._onMouseClick);
     this._elt.removeEventListener("contextmenu", this._onContextMenu);
     this._elt.removeEventListener("mousemove", this._onMouseMove);
     this._elt.removeEventListener("mouseout", this._onMouseOut);
     this._frame.removeEventListener("focus", this._onFocus);
     this.inspector.selection.off("new-node-front", this._onNewSelection);
-    this.inspector.inspectorFront.nodePicker.off(
+    this.inspector.toolbox.nodePicker.off(
       "picker-node-hovered",
       this._onToolboxPickerHover
     );
     this.walker.off("display-change", this._onWalkerNodeStatesChanged);
     this.walker.off("scrollable-change", this._onWalkerNodeStatesChanged);
     this.walker.off("mutations", this._mutationObserver);
     this.win.removeEventListener("copy", this._onCopy);
     this.win.removeEventListener("mouseup", this._onMouseUp);
--- a/devtools/client/inspector/moz.build
+++ b/devtools/client/inspector/moz.build
@@ -17,16 +17,17 @@ DIRS += [
     'rules',
     'shared'
 ]
 
 DevToolsModules(
     'breadcrumbs.js',
     'inspector-search.js',
     'inspector.js',
+    'node-picker.js',
     'panel.js',
     'reducers.js',
     'store.js',
     'toolsidebar.js',
 )
 
 BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
 
rename from devtools/shared/fronts/inspector/node-picker.js
rename to devtools/client/inspector/node-picker.js
--- a/devtools/shared/fronts/inspector/node-picker.js
+++ b/devtools/client/inspector/node-picker.js
@@ -12,130 +12,172 @@ loader.lazyRequireGetter(this, "EventEmi
  */
 
 /**
  * Get the NodePicker instance for an inspector front.
  * The NodePicker wraps the highlighter so that it can interact with the
  * walkerFront and selection api. The nodeFront is stateless, with the
  * HighlighterFront managing it's own state.
  *
- * @param {highlighter} highlighterFront
- * @param {walker} walkerFront
- * @return {Object} the NodePicker public API
+ * @param {Target} target
+ *        The target the toolbox will debug
+ * @param {Selection} selection
+ *        The global Selection object
  */
 class NodePicker extends EventEmitter {
-  constructor(highlighter, walker) {
+  constructor(target, selection) {
     super();
-    this.highlighter = highlighter;
-    this.walker = walker;
+
+    this.target = target;
+    this.selection = selection;
+
+    // Whether or not the node picker is active.
+    this.isPicking = false;
+
+    // The list of inspector fronts corresponding to the frames where picking happens.
+    this._currentInspectorFronts = [];
 
     this.cancel = this.cancel.bind(this);
     this.start = this.start.bind(this);
     this.stop = this.stop.bind(this);
     this.togglePicker = this.togglePicker.bind(this);
 
     this._onHovered = this._onHovered.bind(this);
     this._onPicked = this._onPicked.bind(this);
     this._onPreviewed = this._onPreviewed.bind(this);
     this._onCanceled = this._onCanceled.bind(this);
   }
 
   /**
+   * Get all of the InspectorFront instances corresponding to the frames where the node
+   * picking should occur.
+   *
+   * @return {Array<InspectorFront>}
+   *         The list of InspectorFront instances
+   */
+  async getAllInspectorFronts() {
+    // TODO: For Fission, we should list all remote frames here.
+    // TODO: For the Browser Toolbox, we should list all remote browsers here.
+    // TODO: For now we just return a single item in the array.
+    const inspectorFront = await this.target.getFront("inspector");
+    return [inspectorFront];
+  }
+
+  /**
    * Start/stop the element picker on the debuggee target.
-   * @param {Boolean} doFocus - Optionally focus the content area once the picker is
-   *                            activated.
+   *
+   * @param {Boolean} doFocus
+   *        Optionally focus the content area once the picker is activated.
    * @return Promise that resolves when done
    */
   togglePicker(doFocus) {
-    if (this.highlighter.isPicking) {
+    if (this.isPicking) {
       return this.stop();
     }
     return this.start(doFocus);
   }
 
   /**
    * Start the element picker on the debuggee target.
    * This will request the inspector actor to start listening for mouse events
    * on the target page to highlight the hovered/picked element.
    * Depending on the server-side capabilities, this may fire events when nodes
    * are hovered.
-   * @param {Boolean} doFocus - Optionally focus the content area once the picker is
-   *                            activated.
-   * @return Promise that resolves when the picker has started or immediately
-   * if it is already started
+   *
+   * @param {Boolean} doFocus
+   *        Optionally focus the content area once the picker is activated.
    */
   async start(doFocus) {
-    if (this.highlighter.isPicking) {
-      return null;
+    if (this.isPicking) {
+      return;
     }
+    this.isPicking = true;
+
     this.emit("picker-starting");
-    this.walker.on("picker-node-hovered", this._onHovered);
-    this.walker.on("picker-node-picked", this._onPicked);
-    this.walker.on("picker-node-previewed", this._onPreviewed);
-    this.walker.on("picker-node-canceled", this._onCanceled);
+
+    this._currentInspectorFronts = await this.getAllInspectorFronts();
 
-    const picked = await this.highlighter.pick(doFocus);
+    for (const { walker, highlighter } of this._currentInspectorFronts) {
+      walker.on("picker-node-hovered", this._onHovered);
+      walker.on("picker-node-picked", this._onPicked);
+      walker.on("picker-node-previewed", this._onPreviewed);
+      walker.on("picker-node-canceled", this._onCanceled);
+
+      await highlighter.pick(doFocus);
+    }
+
     this.emit("picker-started");
-    return picked;
   }
 
   /**
    * Stop the element picker. Note that the picker is automatically stopped when
-   * an element is picked
-   * @return Promise that resolves when the picker has stopped or immediately
-   * if it is already stopped
+   * an element is picked.
    */
   async stop() {
-    if (!this.highlighter.isPicking) {
+    if (!this.isPicking) {
       return;
     }
-    await this.highlighter.cancelPick();
-    this.walker.off("picker-node-hovered", this._onHovered);
-    this.walker.off("picker-node-picked", this._onPicked);
-    this.walker.off("picker-node-previewed", this._onPreviewed);
-    this.walker.off("picker-node-canceled", this._onCanceled);
+    this.isPicking = false;
+
+    for (const { walker, highlighter } of this._currentInspectorFronts) {
+      await highlighter.cancelPick();
+
+      walker.off("picker-node-hovered", this._onHovered);
+      walker.off("picker-node-picked", this._onPicked);
+      walker.off("picker-node-previewed", this._onPreviewed);
+      walker.off("picker-node-canceled", this._onCanceled);
+    }
+
+    this._currentInspectorFronts = [];
+
     this.emit("picker-stopped");
   }
 
   /**
    * Stop the picker, but also emit an event that the picker was canceled.
    */
   async cancel() {
     await this.stop();
     this.emit("picker-node-canceled");
   }
 
   /**
    * When a node is hovered by the mouse when the highlighter is in picker mode
-   * @param {Object} data Information about the node being hovered
+   *
+   * @param {Object} data
+   *        Information about the node being hovered
    */
   _onHovered(data) {
     this.emit("picker-node-hovered", data.node);
   }
 
   /**
    * When a node has been picked while the highlighter is in picker mode
-   * @param {Object} data Information about the picked node
+   *
+   * @param {Object} data
+   *        Information about the picked node
    */
   _onPicked(data) {
     this.emit("picker-node-picked", data.node);
     return this.stop();
   }
 
   /**
    * When a node has been shift-clicked (previewed) while the highlighter is in
    * picker mode
-   * @param {Object} data Information about the picked node
+   *
+   * @param {Object} data
+   *        Information about the picked node
    */
   _onPreviewed(data) {
     this.emit("picker-node-previewed", data.node);
   }
 
   /**
    * When the picker is canceled, stop the picker, and make sure the toolbox
    * gets the focus.
    */
   _onCanceled() {
     return this.cancel();
   }
 }
 
-exports.NodePicker = NodePicker;
+module.exports = NodePicker;
--- a/devtools/client/inspector/rules/test/browser_rules_colorpicker-hides-element-picker.js
+++ b/devtools/client/inspector/rules/test/browser_rules_colorpicker-hides-element-picker.js
@@ -6,20 +6,18 @@
 // Tests that on selecting colorpicker eyedropper stops picker
 // if the picker is already selected.
 
 const TEST_URI = `<style>body{background:red}</style>`;
 
 add_task(async function() {
   await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
 
-  const { inspector, toolbox, view } = await openRuleView();
-  const pickerStopped = inspector.inspectorFront.nodePicker.once(
-    "picker-stopped"
-  );
+  const { toolbox, view } = await openRuleView();
+  const pickerStopped = toolbox.nodePicker.once("picker-stopped");
 
   await startPicker(toolbox);
 
   info("Get the background property from the rule-view");
   const property = getRuleViewProperty(view, "body", "background");
   const swatch = property.valueSpan.querySelector(".ruleview-colorswatch");
   ok(swatch, "Color swatch is displayed for the background property");
 
--- a/devtools/client/inspector/test/browser_inspector_highlighter-03.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-03.js
@@ -61,21 +61,21 @@ add_task(async function() {
 
   ok(
     await testActor.assertHighlightedNode(iframeBodySelector),
     "highlighter shows the right node"
   );
   await testActor.isNodeCorrectlyHighlighted(iframeBodySelector, is);
 
   info("Waiting for the element picker to deactivate.");
-  await inspector.inspectorFront.nodePicker.stop();
+  await toolbox.nodePicker.stop();
 
   function moveMouseOver(selector, x, y) {
     info("Waiting for element " + selector + " to be highlighted");
     testActor.synthesizeMouse({
       selector,
       x,
       y,
       options: { type: "mousemove" },
     });
-    return inspector.inspectorFront.nodePicker.once("picker-node-hovered");
+    return toolbox.nodePicker.once("picker-node-hovered");
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_highlighter-07.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-07.js
@@ -90,11 +90,11 @@ add_task(async function() {
   function moveMouseOver(selector, x, y) {
     info("Waiting for element " + selector + " to be highlighted");
     testActor.synthesizeMouse({
       selector,
       x,
       y,
       options: { type: "mousemove" },
     });
-    return inspector.inspectorFront.nodePicker.once("picker-node-hovered");
+    return toolbox.nodePicker.once("picker-node-hovered");
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_highlighter-cancel.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-cancel.js
@@ -34,17 +34,17 @@ add_task(async function() {
   ok(
     isSelectedMarkupNodeInView(),
     "The currently selected node is focused back on the screen."
   );
 
   function cancelPickerByShortcut() {
     info("Key pressed. Waiting for picker to be canceled.");
     testActor.synthesizeKey({ key: "VK_ESCAPE", options: {} });
-    return inspector.inspectorFront.nodePicker.once("picker-node-canceled");
+    return toolbox.nodePicker.once("picker-node-canceled");
   }
 
   function moveMouseOver(selector) {
     info(`Waiting for element ${selector} to be hovered in the markup view`);
     testActor.synthesizeMouse({
       options: { type: "mousemove" },
       center: true,
       selector: selector,
--- a/devtools/client/inspector/test/browser_inspector_highlighter-iframes_01.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-iframes_01.js
@@ -21,17 +21,17 @@ const OUTER_FRAME_SRC =
 const TEST_URI =
   "data:text/html;charset=utf-8," +
   "iframe tests for inspector" +
   '<iframe src="' +
   OUTER_FRAME_SRC +
   '" />';
 
 add_task(async function() {
-  const { inspector, testActor } = await openInspectorForURL(TEST_URI);
+  const { toolbox, inspector, testActor } = await openInspectorForURL(TEST_URI);
   const outerFrameDiv = ["iframe", "div"];
   const innerFrameDiv = ["iframe", "iframe", "div"];
 
   info("Waiting for element picker to activate.");
   await startPicker(inspector.toolbox);
 
   info("Moving mouse over outerFrameDiv");
   await moveMouseOver(outerFrameDiv);
@@ -65,23 +65,21 @@ add_task(async function() {
 
   is(
     inspector.breadcrumbs.nodeHierarchy.length,
     9,
     "Breadcrumbs have 9 items."
   );
 
   info("Waiting for element picker to deactivate.");
-  await inspector.inspectorFront.nodePicker.stop();
+  await toolbox.nodePicker.stop();
 
   function moveMouseOver(selector) {
     info("Waiting for element " + selector + " to be highlighted");
     testActor
       .synthesizeMouse({
         selector: selector,
         options: { type: "mousemove" },
         center: true,
       })
-      .then(() =>
-        inspector.inspectorFront.nodePicker.once("picker-node-hovered")
-      );
+      .then(() => toolbox.nodePicker.once("picker-node-hovered"));
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_highlighter-keybinding_01.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-keybinding_01.js
@@ -4,18 +4,17 @@
 
 "use strict";
 
 // Test that the keybindings for Picker work alright
 
 const TEST_URL = URL_ROOT + "doc_inspector_highlighter_dom.html";
 
 add_task(async function() {
-  const { inspector, toolbox, testActor } = await openInspectorForURL(TEST_URL);
-  const { inspectorFront } = inspector;
+  const { toolbox, testActor } = await openInspectorForURL(TEST_URL);
 
   await startPicker(toolbox);
 
   info("Selecting the simple-div1 DIV");
   await moveMouseOver("#simple-div1");
 
   ok(
     await testActor.assertHighlightedNode("#simple-div1"),
@@ -50,26 +49,26 @@ add_task(async function() {
   ok(
     await testActor.assertHighlightedNode("#simple-div1"),
     "The highlighter shows #simple-div1. OK."
   );
 
   info("First child selection test Passed.");
 
   info("Stopping the picker");
-  await inspectorFront.nodePicker.stop();
+  await toolbox.nodePicker.stop();
 
   function doKeyHover(args) {
     info("Key pressed. Waiting for element to be highlighted/hovered");
     testActor.synthesizeKey(args);
-    return inspectorFront.nodePicker.once("picker-node-hovered");
+    return toolbox.nodePicker.once("picker-node-hovered");
   }
 
   function moveMouseOver(selector) {
     info("Waiting for element " + selector + " to be highlighted");
     testActor.synthesizeMouse({
       options: { type: "mousemove" },
       center: true,
       selector: selector,
     });
-    return inspectorFront.nodePicker.once("picker-node-hovered");
+    return toolbox.nodePicker.once("picker-node-hovered");
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_highlighter-keybinding_02.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-keybinding_02.js
@@ -4,18 +4,17 @@
 
 "use strict";
 
 // Test that the keybindings for Picker work alright
 
 const TEST_URL = URL_ROOT + "doc_inspector_highlighter_dom.html";
 
 add_task(async function() {
-  const { inspector, toolbox, testActor } = await openInspectorForURL(TEST_URL);
-  const { inspectorFront } = inspector;
+  const { toolbox, testActor } = await openInspectorForURL(TEST_URL);
 
   await startPicker(toolbox);
 
   // Previously chosen child memory
   info("Testing whether previously chosen child is remembered");
 
   info("Selecting the ahoy paragraph DIV");
   await moveMouseOver("#ahoy");
@@ -44,32 +43,28 @@ add_task(async function() {
   ok(
     await testActor.assertHighlightedNode("#simple-div2"),
     "The highlighter shows #simple-div2. OK."
   );
 
   info("Previously chosen child is remembered. Passed.");
 
   info("Stopping the picker");
-  await inspectorFront.nodePicker.stop();
+  await toolbox.nodePicker.stop();
 
   function doKeyHover(args) {
     info("Key pressed. Waiting for element to be highlighted/hovered");
-    const onPickerNodeHovered = inspectorFront.nodePicker.once(
-      "picker-node-hovered"
-    );
+    const onPickerNodeHovered = toolbox.nodePicker.once("picker-node-hovered");
     testActor.synthesizeKey(args);
     return onPickerNodeHovered;
   }
 
   function moveMouseOver(selector) {
     info("Waiting for element " + selector + " to be highlighted");
-    const onPickerNodeHovered = inspectorFront.nodePicker.once(
-      "picker-node-hovered"
-    );
+    const onPickerNodeHovered = toolbox.nodePicker.once("picker-node-hovered");
     testActor.synthesizeMouse({
       options: { type: "mousemove" },
       center: true,
       selector: selector,
     });
     return onPickerNodeHovered;
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_highlighter-keybinding_03.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-keybinding_03.js
@@ -52,31 +52,29 @@ add_task(async function() {
   );
 
   function doKeyPick(args) {
     info("Key pressed. Waiting for element to be picked");
     testActor.synthesizeKey(args);
     return promise.all([
       inspector.selection.once("new-node-front"),
       inspector.once("inspector-updated"),
-      inspector.inspectorFront.nodePicker.once("picker-stopped"),
+      toolbox.nodePicker.once("picker-stopped"),
     ]);
   }
 
   function doKeyStop(args) {
     info("Key pressed. Waiting for picker to be canceled");
     testActor.synthesizeKey(args);
-    return inspector.inspectorFront.nodePicker.once("picker-stopped");
+    return toolbox.nodePicker.once("picker-stopped");
   }
 
   function moveMouseOver(selector) {
     info("Waiting for element " + selector + " to be highlighted");
-    const onPickerNodeHovered = inspector.inspectorFront.nodePicker.once(
-      "picker-node-hovered"
-    );
+    const onPickerNodeHovered = toolbox.nodePicker.once("picker-node-hovered");
     testActor.synthesizeMouse({
       options: { type: "mousemove" },
       center: true,
       selector: selector,
     });
     return onPickerNodeHovered;
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_highlighter-keybinding_04.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-keybinding_04.js
@@ -6,33 +6,30 @@
 
 // Tests that pressing ESC twice while in picker mode first stops the picker and
 // then opens the split-console (see bug 988278).
 
 const TEST_URL = "data:text/html;charset=utf8,<div></div>";
 
 add_task(async function() {
   const { inspector, toolbox, testActor } = await openInspectorForURL(TEST_URL);
-  const { inspectorFront } = inspector;
 
   await startPicker(toolbox);
 
   info("Start using the picker by hovering over nodes");
-  const onHover = inspectorFront.nodePicker.once("picker-node-hovered");
+  const onHover = toolbox.nodePicker.once("picker-node-hovered");
   testActor.synthesizeMouse({
     options: { type: "mousemove" },
     center: true,
     selector: "div",
   });
   await onHover;
 
   info("Press escape and wait for the picker to stop");
-  const onPickerStopped = inspectorFront.nodePicker.once(
-    "picker-node-canceled"
-  );
+  const onPickerStopped = toolbox.nodePicker.once("picker-node-canceled");
   testActor.synthesizeKey({
     key: "VK_ESCAPE",
     options: {},
   });
   await onPickerStopped;
 
   info("Press escape again and wait for the split console to open");
   const onSplitConsole = toolbox.once("split-console");
--- a/devtools/client/inspector/test/browser_inspector_highlighter-xbl.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-xbl.js
@@ -32,11 +32,11 @@ add_task(async function() {
 
   function moveMouseOver(selector) {
     info("Waiting for element " + selector + " to be highlighted");
     testActor.synthesizeMouse({
       options: { type: "mousemove" },
       center: true,
       selector: selector,
     });
-    return inspector.inspectorFront.nodePicker.once("picker-node-hovered");
+    return toolbox.nodePicker.once("picker-node-hovered");
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_iframe-navigation.js
+++ b/devtools/client/inspector/test/browser_inspector_iframe-navigation.js
@@ -7,26 +7,23 @@
 // navigations.
 
 const TEST_URI =
   "data:text/html;charset=utf-8," +
   "<p>bug 699308 - test iframe navigation</p>" +
   "<iframe src='data:text/html;charset=utf-8,hello world'></iframe>";
 
 add_task(async function() {
-  const { inspector, toolbox, testActor } = await openInspectorForURL(TEST_URI);
-  const { inspectorFront } = inspector;
+  const { toolbox, testActor } = await openInspectorForURL(TEST_URI);
 
   info("Starting element picker.");
   await startPicker(toolbox);
 
   info("Waiting for body to be hovered.");
-  const onHovered = inspector.inspectorFront.nodePicker.once(
-    "picker-node-hovered"
-  );
+  const onHovered = toolbox.nodePicker.once("picker-node-hovered");
   testActor.synthesizeMouse({
     selector: "body",
     options: { type: "mousemove" },
     x: 1,
     y: 1,
   });
   await onHovered;
 
@@ -38,10 +35,10 @@ add_task(async function() {
 
   await testActor.reloadFrame("iframe");
   info("Frame reloaded twice.");
 
   isVisible = await testActor.isHighlighting();
   ok(isVisible, "Inspector is highlighting after iframe nav.");
 
   info("Stopping element picker.");
-  await inspectorFront.nodePicker.stop();
+  await toolbox.nodePicker.stop();
 });
--- a/devtools/client/inspector/test/browser_inspector_picker-stop-on-tool-change.js
+++ b/devtools/client/inspector/test/browser_inspector_picker-stop-on-tool-change.js
@@ -7,19 +7,18 @@
 // Test that the highlighter's picker is stopped when a different tool is
 // selected
 
 const TEST_URI =
   "data:text/html;charset=UTF-8," +
   "testing the highlighter goes away on tool selection";
 
 add_task(async function() {
-  const { inspector, toolbox } = await openInspectorForURL(TEST_URI);
-  const { inspectorFront } = inspector;
-  const pickerStopped = inspectorFront.nodePicker.once("picker-stopped");
+  const { toolbox } = await openInspectorForURL(TEST_URI);
+  const pickerStopped = toolbox.nodePicker.once("picker-stopped");
 
   info("Starting the inspector picker");
   await startPicker(toolbox);
 
   info("Selecting another tool than the inspector in the toolbox");
   await toolbox.selectNextTool();
 
   info("Waiting for the picker-stopped event to be fired");
--- a/devtools/client/inspector/test/browser_inspector_switch-to-inspector-on-pick.js
+++ b/devtools/client/inspector/test/browser_inspector_switch-to-inspector-on-pick.js
@@ -58,22 +58,21 @@ add_task(async function() {
   Services.telemetry.clearEvents();
 
   // Ensure no events have been logged
   const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true);
   ok(!snapshot.parent, "No events have been logged for the main process");
 
   const tab = await addTab(TEST_URI);
   const toolbox = await openToolbox(tab);
-  const inspectorFront = await toolbox.target.getFront("inspector");
 
   await startPickerAndAssertSwitchToInspector(toolbox);
 
   info("Stoppping element picker.");
-  await inspectorFront.nodePicker.stop();
+  await toolbox.nodePicker.stop();
 
   checkResults();
 });
 
 async function openToolbox(tab) {
   info("Opening webconsole.");
   const target = await TargetFactory.forTab(tab);
   return gDevTools.showToolbox(target, "webconsole");
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -69,21 +69,19 @@ var navigateTo = async function(inspecto
 };
 
 /**
  * Start the element picker and focus the content window.
  * @param {Toolbox} toolbox
  * @param {Boolean} skipFocus - Allow tests to bypass the focus event.
  */
 var startPicker = async function(toolbox, skipFocus) {
-  const inspectorFront = await toolbox.target.getFront("inspector");
-
   info("Start the element picker");
   toolbox.win.focus();
-  await inspectorFront.nodePicker.start();
+  await toolbox.nodePicker.start();
   if (!skipFocus) {
     // By default make sure the content window is focused since the picker may not focus
     // the content window by default.
     await ContentTask.spawn(gBrowser.selectedBrowser, null, async function() {
       content.focus();
     });
   }
 };
@@ -126,19 +124,17 @@ function pickElement(inspector, testActo
  *        X-offset from the top-left corner of the element matching the provided selector
  * @param {Number} y
  *        Y-offset from the top-left corner of the element matching the provided selector
  * @return {Promise} promise that resolves when the "picker-node-hovered" event is
  *         emitted.
  */
 function hoverElement(inspector, testActor, selector, x, y) {
   info("Waiting for element " + selector + " to be hovered");
-  const onHovered = inspector.inspectorFront.nodePicker.once(
-    "picker-node-hovered"
-  );
+  const onHovered = inspector.toolbox.nodePicker.once("picker-node-hovered");
   testActor.synthesizeMouse({ selector, x, y, options: { type: "mousemove" } });
   return onHovered;
 }
 
 /**
  * Highlight a node and set the inspector's current selection to the node or
  * the first match of the given css selector.
  * @param {String|NodeFront} selector
--- a/devtools/client/locales/en-US/inspector.properties
+++ b/devtools/client/locales/en-US/inspector.properties
@@ -533,8 +533,14 @@ colorPickerTooltip.colorNameTitle=Closes
 
 # LOCALIZATION NOTE (colorPickerTooltip.hueSliderTitle): A title text for the
 # hue slider in the color picker tooltip.
 colorPickerTooltip.hueSliderTitle=Hue
 
 # LOCALIZATION NOTE (colorPickerTooltip.alphaSliderTitle): A title text for the
 # alpha slider in the color picker tooltip.
 colorPickerTooltip.alphaSliderTitle=Opacity
+
+# LOCALIZATION NOTE (colorPickerTooltip.contrast.large.title): A title text for the color
+# contrast ratio description in the color picker tooltip, used together with the specification
+# that the color contrast criteria used is for large text. %S in the content will be replaced by a
+# large text indicator span at run time.
+colorPickerTooltip.contrast.large.title=Contrast %S:
--- a/devtools/client/shared/test/browser_spectrum.js
+++ b/devtools/client/shared/test/browser_spectrum.js
@@ -4,31 +4,35 @@
 
 "use strict";
 
 // Tests that the spectrum color picker works correctly
 
 const { Spectrum } = require("devtools/client/shared/widgets/Spectrum");
 const {
   accessibility: {
-    SCORES: { FAIL, AAA },
+    SCORES: { FAIL, AAA, AA },
   },
 } = require("devtools/shared/constants");
 
 loader.lazyRequireGetter(
   this,
   "cssColors",
   "devtools/shared/css/color-db",
   true
 );
 
 const TEST_URI = CHROME_URL_ROOT + "doc_spectrum.html";
 const REGULAR_TEXT_PROPS = {
   "font-size": { value: "11px" },
   "font-weight": { value: "bold" },
+  opacity: { value: "1" },
+};
+const SINGLE_BG_COLOR = {
+  value: cssColors.white,
 };
 const ZERO_ALPHA_COLOR = [0, 255, 255, 0];
 
 add_task(async function() {
   const [host, , doc] = await createHost("bottom", TEST_URI);
 
   const container = doc.getElementById("spectrum-container");
 
@@ -38,16 +42,17 @@ add_task(async function() {
   await testChangingColorShouldEmitEvents(container, doc);
   await testSettingColorShoudUpdateTheUI(container);
   await testChangingColorShouldUpdateColorPreview(container);
   await testNotSettingTextPropsShouldNotShowContrastSection(container);
   await testSettingTextPropsAndColorShouldUpdateContrastValue(container);
   await testOnlySelectingLargeTextWithNonZeroAlphaShouldShowIndicator(
     container
   );
+  await testSettingMultiColoredBackgroundShouldShowContrastRange(container);
 
   host.destroy();
 });
 
 /**
  * Helper method for extracting the rgba overlay value of the color preview's background
  * image style.
  *
@@ -343,37 +348,38 @@ function testNotSettingTextPropsShouldNo
     "Contrast section is not shown."
   );
 
   s.destroy();
 }
 
 function testSpectrumContrast(
   spectrum,
+  contrastValueEl,
   rgb,
   expectedValue,
   expectedBadgeClass = "",
   expectLargeTextIndicator = false
 ) {
   setSpectrumProps(spectrum, { rgb });
 
   is(
-    spectrum.contrastValue.textContent,
+    contrastValueEl.textContent,
     expectedValue,
     "Contrast value has the correct text."
   );
   is(
-    spectrum.contrastValue.className,
+    contrastValueEl.className,
     `accessibility-contrast-value${
       expectedBadgeClass ? " " + expectedBadgeClass : ""
     }`,
     `Contrast value contains ${expectedBadgeClass || "base"} class.`
   );
   is(
-    spectrum.spectrumContrast.classList.contains("large-text"),
+    spectrum.contrastLabel.childNodes.length === 3,
     expectLargeTextIndicator,
     `Large text indicator is ${expectLargeTextIndicator ? "" : "not"} shown.`
   );
 }
 
 function testSettingTextPropsAndColorShouldUpdateContrastValue(container) {
   const s = new Spectrum(container, cssColors.white);
   s.show();
@@ -381,62 +387,119 @@ function testSettingTextPropsAndColorSho
   ok(
     !s.spectrumContrast.classList.contains("visible"),
     "Contrast value is not available yet."
   );
 
   info(
     "Test that contrast ratio is calculated on setting 'textProps' and 'rgb'."
   );
-  setSpectrumProps(s, { textProps: REGULAR_TEXT_PROPS }, false);
-  testSpectrumContrast(s, [50, 240, 234, 0.8], "1.35", FAIL);
+  setSpectrumProps(
+    s,
+    { textProps: REGULAR_TEXT_PROPS, backgroundColorData: SINGLE_BG_COLOR },
+    false
+  );
+  testSpectrumContrast(s, s.contrastValue, [50, 240, 234, 0.8], "1.35", FAIL);
 
   info("Test that contrast ratio is updated when color is changed.");
-  testSpectrumContrast(s, cssColors.black, "21.00", AAA);
+  testSpectrumContrast(s, s.contrastValue, cssColors.black, "21.00", AAA);
 
   info("Test that contrast ratio cannot be calculated with zero alpha.");
-  testSpectrumContrast(s, ZERO_ALPHA_COLOR, "Unable to calculate");
+  testSpectrumContrast(
+    s,
+    s.contrastValue,
+    ZERO_ALPHA_COLOR,
+    "Unable to calculate"
+  );
 
   s.destroy();
 }
 
 function testOnlySelectingLargeTextWithNonZeroAlphaShouldShowIndicator(
   container
 ) {
   let s = new Spectrum(container, cssColors.white);
   s.show();
 
   ok(
-    !s.spectrumContrast.classList.contains("large-text"),
+    s.contrastLabel.childNodes.length !== 3,
     "Large text indicator is initially hidden."
   );
 
   info(
     "Test that selecting large text with non-zero alpha shows large text indicator."
   );
   setSpectrumProps(
     s,
     {
       textProps: {
         "font-size": { value: "24px" },
         "font-weight": { value: "normal" },
+        opacity: { value: "1" },
       },
+      backgroundColorData: SINGLE_BG_COLOR,
     },
     false
   );
-  testSpectrumContrast(s, cssColors.black, "21.00", AAA, true);
+  testSpectrumContrast(s, s.contrastValue, cssColors.black, "21.00", AAA, true);
 
   info(
     "Test that selecting large text with zero alpha hides large text indicator."
   );
-  testSpectrumContrast(s, ZERO_ALPHA_COLOR, "Unable to calculate");
+  testSpectrumContrast(
+    s,
+    s.contrastValue,
+    ZERO_ALPHA_COLOR,
+    "Unable to calculate"
+  );
 
   // Spectrum should be closed and opened again to reflect changes in text size
   s.destroy();
   s = new Spectrum(container, cssColors.white);
   s.show();
 
   info("Test that selecting regular text does not show large text indicator.");
-  setSpectrumProps(s, { textProps: REGULAR_TEXT_PROPS }, false);
-  testSpectrumContrast(s, cssColors.black, "21.00", AAA);
+  setSpectrumProps(
+    s,
+    { textProps: REGULAR_TEXT_PROPS, backgroundColorData: SINGLE_BG_COLOR },
+    false
+  );
+  testSpectrumContrast(s, s.contrastValue, cssColors.black, "21.00", AAA);
 
   s.destroy();
 }
+
+function testSettingMultiColoredBackgroundShouldShowContrastRange(container) {
+  const s = new Spectrum(container, cssColors.white);
+  s.show();
+
+  info(
+    "Test setting text with non-zero alpha and multi-colored bg shows contrast range and empty single contrast."
+  );
+  setSpectrumProps(
+    s,
+    {
+      textProps: REGULAR_TEXT_PROPS,
+      backgroundColorData: {
+        min: cssColors.yellow,
+        max: cssColors.green,
+      },
+    },
+    false
+  );
+  testSpectrumContrast(s, s.contrastValueMin, cssColors.white, "1.07", FAIL);
+  testSpectrumContrast(s, s.contrastValueMax, cssColors.white, "5.14", AA);
+  testSpectrumContrast(s, s.contrastValue, cssColors.white, "");
+  ok(
+    s.spectrumContrast.classList.contains("range"),
+    "Contrast section contains range class."
+  );
+
+  info("Test setting text with zero alpha shows error in contrast min span.");
+  testSpectrumContrast(
+    s,
+    s.contrastValueMin,
+    ZERO_ALPHA_COLOR,
+    "Unable to calculate"
+  );
+
+  s.destroy();
+}
--- a/devtools/client/shared/widgets/Spectrum.js
+++ b/devtools/client/shared/widgets/Spectrum.js
@@ -9,52 +9,45 @@ const { MultiLocalizationHelper } = requ
 const L10N = new MultiLocalizationHelper(
   "devtools/shared/locales/en-US/accessibility.properties",
   "devtools/client/locales/en-US/accessibility.properties",
   "devtools/client/locales/en-US/inspector.properties"
 );
 const ARROW_KEYS = ["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"];
 const [ArrowUp, ArrowRight, ArrowDown, ArrowLeft] = ARROW_KEYS;
 const XHTML_NS = "http://www.w3.org/1999/xhtml";
-const COLOR_HEX_WHITE = "#ffffff";
 const SLIDER = {
   hue: {
     MIN: "0",
     MAX: "128",
     STEP: "1",
   },
   alpha: {
     MIN: "0",
     MAX: "1",
     STEP: "0.01",
   },
 };
 
 loader.lazyRequireGetter(this, "colorUtils", "devtools/shared/css/color", true);
 loader.lazyRequireGetter(
   this,
-  "cssColors",
-  "devtools/shared/css/color-db",
-  true
-);
-loader.lazyRequireGetter(
-  this,
   "labColors",
   "devtools/shared/css/color-db",
   true
 );
 loader.lazyRequireGetter(
   this,
-  "getContrastRatioScore",
+  "getTextProperties",
   "devtools/shared/accessibility",
   true
 );
 loader.lazyRequireGetter(
   this,
-  "getTextProperties",
+  "getContrastRatioAgainstBackground",
   "devtools/shared/accessibility",
   true
 );
 
 /**
  * Spectrum creates a color picker widget in any container you give it.
  *
  * Simple usage example:
@@ -105,26 +98,31 @@ function Spectrum(parentEl, rgb) {
     <section class="spectrum-controls">
       <div class="spectrum-color-preview"></div>
       <div class="spectrum-slider-container">
         <div class="spectrum-hue spectrum-box"></div>
         <div class="spectrum-alpha spectrum-checker spectrum-box"></div>
       </div>
     </section>
     <section class="spectrum-color-contrast accessibility-color-contrast">
-      <span class="contrast-ratio-label" role="presentation">
-        ${L10N.getStr("accessibility.contrast.ratio.label")}
-      </span>
-      <span class="accessibility-contrast-value"></span>
-      <span
-        class="accessibility-color-contrast-large-text"
-        title="${L10N.getStr("accessibility.contrast.large.title")}"
-      >
-        ${L10N.getStr("accessibility.contrast.large.text")}
-      </span>
+      <div class="contrast-ratio-header-and-single-ratio">
+        <span class="contrast-ratio-label" role="presentation"></span>
+        <span class="contrast-value-and-swatch contrast-ratio-single" role="presentation">
+          <span class="accessibility-contrast-value"></span>
+        </span>
+      </div>
+      <div class="contrast-ratio-range">
+        <span class="contrast-value-and-swatch contrast-ratio-min" role="presentation">
+          <span class="accessibility-contrast-value"></span>
+        </span>
+        <span class="accessibility-color-contrast-separator"></span>
+        <span class="contrast-value-and-swatch contrast-ratio-max" role="presentation">
+          <span class="accessibility-contrast-value"></span>
+        </span>
+      </div>
     </section>
   `;
 
   this.onElementClick = this.onElementClick.bind(this);
   this.element.addEventListener("click", this.onElementClick);
 
   this.parentEl.appendChild(this.element);
 
@@ -159,26 +157,31 @@ function Spectrum(parentEl, rgb) {
     "alpha",
     this.onAlphaSliderMove.bind(this)
   );
 
   // Color contrast
   this.spectrumContrast = this.element.querySelector(
     ".spectrum-color-contrast"
   );
-  this.contrastValue = this.element.querySelector(
-    ".accessibility-contrast-value"
-  );
+  this.contrastLabel = this.element.querySelector(".contrast-ratio-label");
+  [
+    this.contrastValue,
+    this.contrastValueMin,
+    this.contrastValueMax,
+  ] = this.element.querySelectorAll(".accessibility-contrast-value");
 
   // Create the learn more info button
   const learnMore = this.document.createElementNS(XHTML_NS, "button");
   learnMore.id = "learn-more-button";
   learnMore.className = "learn-more";
   learnMore.title = L10N.getStr("accessibility.learnMore");
-  this.spectrumContrast.appendChild(learnMore);
+  this.element
+    .querySelector(".contrast-ratio-header-and-single-ratio")
+    .appendChild(learnMore);
 
   if (rgb) {
     this.rgb = rgb;
     this.updateUI();
   }
 }
 
 module.exports.Spectrum = Spectrum;
@@ -356,57 +359,57 @@ Spectrum.draggable = function(element, d
 };
 
 /**
  * Calculates the contrast ratio for a DOM node's computed style against
  * a given background.
  *
  * @param  {Object} computedStyle
  *         The computed style for which we want to calculate the contrast ratio.
- * @param  {Array} background
- *         The rgba value array for background color (i.e. [255, 255, 255, 1]).
+ * @param  {Object} backgroundColor
+ *         Object with one or more of the following properties: value, min, max
  * @return {Object}
  *         An object that may contain one or more of the following fields: error,
  *         isLargeText, value, score for contrast.
  */
-function getContrastRatioAgainstSolidBg(
-  computedStyle,
-  background = cssColors.white
-) {
+function getContrastRatio(computedStyle, backgroundColor) {
   const props = getTextProperties(computedStyle);
+
   if (!props) {
     return {
       error: true,
     };
   }
 
-  const { color, isLargeText } = props;
-  const value = colorUtils.calculateContrastRatio(background, color);
-  return {
-    value,
-    color,
-    isLargeText,
-    score: getContrastRatioScore(value, isLargeText),
-  };
+  return getContrastRatioAgainstBackground(backgroundColor, props);
 }
 
 Spectrum.prototype = {
   set textProps(style) {
     this._textProps = style
       ? {
           fontSize: style["font-size"].value,
           fontWeight: style["font-weight"].value,
+          opacity: style.opacity.value,
         }
       : null;
   },
 
   set rgb(color) {
     this.hsv = Spectrum.rgbToHsv(color[0], color[1], color[2], color[3]);
   },
 
+  set backgroundColorData(colorData) {
+    this._backgroundColorData = colorData;
+  },
+
+  get backgroundColorData() {
+    return this._backgroundColorData;
+  },
+
   get textProps() {
     return this._textProps;
   },
 
   get rgb() {
     const rgb = Spectrum.hsvToRgb(
       this.hsv[0],
       this.hsv[1],
@@ -512,16 +515,92 @@ Spectrum.prototype = {
     slider.step = SLIDER[sliderType].STEP;
     slider.title = L10N.getStr(`colorPickerTooltip.${sliderType}SliderTitle`);
     slider.addEventListener("input", onSliderMove);
 
     container.appendChild(slider);
     return slider;
   },
 
+  /**
+   * Updates the contrast label with appropriate content (i.e. large text indicator
+   * if the contrast is calculated for large text, or a base label otherwise)
+   *
+   * @param  {Boolean} isLargeText
+   *         True if contrast is calculated for large text.
+   */
+  updateContrastLabel: function(isLargeText) {
+    if (!isLargeText) {
+      this.contrastLabel.textContent = L10N.getStr(
+        "accessibility.contrast.ratio.label"
+      );
+      return;
+    }
+
+    // Clear previously appended children before appending any new children
+    while (this.contrastLabel.firstChild) {
+      this.contrastLabel.firstChild.remove();
+    }
+
+    const largeTextStr = L10N.getStr("accessibility.contrast.large.text");
+    const contrastLabelStr = L10N.getFormatStr(
+      "colorPickerTooltip.contrast.large.title",
+      largeTextStr
+    );
+
+    // Build an array of children nodes for the contrast label element
+    const contents = contrastLabelStr
+      .split(new RegExp(largeTextStr), 2)
+      .map(content => this.document.createTextNode(content));
+    const largeTextIndicator = this.document.createElementNS(XHTML_NS, "span");
+    largeTextIndicator.className = "accessibility-color-contrast-large-text";
+    largeTextIndicator.textContent = largeTextStr;
+    largeTextIndicator.title = L10N.getStr(
+      "accessibility.contrast.large.title"
+    );
+    contents.splice(1, 0, largeTextIndicator);
+
+    // Append children to contrast label
+    for (const content of contents) {
+      this.contrastLabel.appendChild(content);
+    }
+  },
+
+  /**
+   * Updates a contrast value element with the given score, value and swatches.
+   *
+   * @param  {DOMNode} el
+   *         Contrast value element to update.
+   * @param  {String} score
+   *         Contrast ratio score.
+   * @param  {Number} value
+   *         Contrast ratio value.
+   * @param  {Array} backgroundColor
+   *         RGBA color array for the background color to show in the swatch.
+   */
+  updateContrastValueEl: function(el, score, value, backgroundColor) {
+    el.classList.toggle(score, true);
+    el.textContent = value.toFixed(2);
+    el.title = L10N.getFormatStr(
+      `accessibility.contrast.annotation.${score}`,
+      L10N.getFormatStr(
+        "colorPickerTooltip.contrastAgainstBgTitle",
+        `rgba(${backgroundColor})`
+      )
+    );
+    el.parentElement.style.setProperty(
+      "--accessibility-contrast-color",
+      this.rgbCssString
+    );
+    el.parentElement.style.setProperty(
+      "--accessibility-contrast-bg",
+      `rgba(${backgroundColor})`
+    );
+  },
+
   updateAlphaSlider: function() {
     // Set alpha slider background
     const rgb = this.rgb;
 
     const rgbNoAlpha = "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
     const rgbAlpha0 = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ", 0)";
     const alphaGradient =
       "linear-gradient(to right, " + rgbAlpha0 + ", " + rgbNoAlpha + ")";
@@ -588,65 +667,106 @@ Spectrum.prototype = {
     // Placing the hue slider
     this.hueSlider.value = h * this.hueSlider.max;
 
     // Placing the alpha slider
     this.alphaSlider.value = this.hsv[3] * this.alphaSlider.max;
   },
 
   /* Calculates the contrast ratio for the currently selected
-   * color against white background and displays/hides contrast ratio span
+   * color against a single or range of background colors and displays contrast ratio section
    * components depending on the contrast ratio calculated.
    *
    * Contrast ratio components include:
    *    - contrastLargeTextIndicator: Hidden by default, shown when text has large font
    *                                  size if there is no error in calculation.
-   *    - contrastValue:              Set to calculated value and score. Set to error text
+   *    - contrastValue(s):           Set to calculated value(s), score(s) and text color on
+   *                                  background swatches. Set to error text
    *                                  if there is an error in calculation.
    */
   updateContrast: function() {
     // Remove additional classes on spectrum contrast, leaving behind only base classes
-    this.spectrumContrast.classList.toggle("large-text", false);
     this.spectrumContrast.classList.toggle("visible", false);
-    // Assign only base class to contrastValue, removing any score class
-    this.contrastValue.className = "accessibility-contrast-value";
+    this.spectrumContrast.classList.toggle("range", false);
+    this.spectrumContrast.classList.toggle("error", false);
+    // Assign only base class to all contrastValues, removing any score class
+    this.contrastValue.className = this.contrastValueMin.className = this.contrastValueMax.className =
+      "accessibility-contrast-value";
 
     if (!this.contrastEnabled) {
       return;
     }
 
+    const isRange = this.backgroundColorData.min !== undefined;
     this.spectrumContrast.classList.toggle("visible", true);
+    this.spectrumContrast.classList.toggle("range", isRange);
+
+    const colorContrast = getContrastRatio(
+      {
+        ...this.textProps,
+        color: this.rgbCssString,
+      },
+      this.backgroundColorData
+    );
 
-    const contrastRatio = getContrastRatioAgainstSolidBg({
-      ...this.textProps,
-      color: this.rgbCssString,
-    });
-    const { value, score, isLargeText, error } = contrastRatio;
+    const {
+      value,
+      min,
+      max,
+      score,
+      scoreMin,
+      scoreMax,
+      backgroundColor,
+      backgroundColorMin,
+      backgroundColorMax,
+      isLargeText,
+      error,
+    } = colorContrast;
 
     if (error) {
-      this.contrastValue.textContent = L10N.getStr(
-        "accessibility.contrast.error"
-      );
-      this.contrastValue.title = L10N.getStr(
+      this.updateContrastLabel(false);
+      this.spectrumContrast.classList.toggle("error", true);
+
+      // If current background color is a range, show the error text in the contrast range
+      // span. Otherwise, show it in the single contrast span.
+      const contrastValEl = isRange
+        ? this.contrastValueMin
+        : this.contrastValue;
+      contrastValEl.textContent = L10N.getStr("accessibility.contrast.error");
+      contrastValEl.title = L10N.getStr(
         "accessibility.contrast.annotation.transparent.error"
       );
-      this.spectrumContrast.classList.remove("large-text");
+
       return;
     }
 
-    this.contrastValue.classList.toggle(score, true);
-    this.contrastValue.textContent = value.toFixed(2);
-    this.contrastValue.title = L10N.getFormatStr(
-      `accessibility.contrast.annotation.${score}`,
-      L10N.getFormatStr(
-        "colorPickerTooltip.contrastAgainstBgTitle",
-        COLOR_HEX_WHITE
-      )
+    this.updateContrastLabel(isLargeText);
+    if (!isRange) {
+      this.updateContrastValueEl(
+        this.contrastValue,
+        score,
+        value,
+        backgroundColor
+      );
+
+      return;
+    }
+
+    this.updateContrastValueEl(
+      this.contrastValueMin,
+      scoreMin,
+      min,
+      backgroundColorMin
     );
-    this.spectrumContrast.classList.toggle("large-text", isLargeText);
+    this.updateContrastValueEl(
+      this.contrastValueMax,
+      scoreMax,
+      max,
+      backgroundColorMax
+    );
   },
 
   updateUI: function() {
     this.updateHelperLocations();
 
     this.updateColorPreview();
     this.updateDragger();
     this.updateHueSlider();
@@ -663,11 +783,12 @@ Spectrum.prototype = {
 
     this.dragger = this.dragHelper = null;
     this.alphaSlider = null;
     this.hueSlider = null;
     this.colorPreview = null;
     this.element = null;
     this.parentEl = null;
     this.spectrumContrast = null;
-    this.contrastValue = null;
+    this.contrastValue = this.contrastValueMin = this.contrastValueMax = null;
+    this.contrastLabel = null;
   },
 };
--- a/devtools/client/shared/widgets/spectrum.css
+++ b/devtools/client/shared/widgets/spectrum.css
@@ -203,65 +203,123 @@ http://www.briangrinstead.com/blog/keep-
   height: 8px;
   width: 8px;
   border: 1px solid white;
   box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
 }
 
 .spectrum-color-contrast {
   padding-block-start: 8px;
-  padding-inline-start: 3px;
-  align-items: stretch;
+  padding-inline-start: 4px;
+  padding-inline-end: 4px;
   line-height: 1.2em;
 }
 
-.spectrum-color-contrast.visible.large-text .accessibility-color-contrast-large-text {
+.contrast-ratio-header-and-single-ratio,
+.contrast-ratio-range {
+  display: flex;
+  align-items: stretch;
+}
+
+.contrast-ratio-range {
+  margin-block-start: 4px;
+  margin-inline-start: 1px;
+  margin-block-end: 2px;
+}
+
+.spectrum-color-contrast.visible {
   display: block;
 }
 
-.spectrum-color-contrast.visible {
+.spectrum-color-contrast.visible:not(.range) .contrast-ratio-single,
+.spectrum-color-contrast.visible.range .contrast-ratio-range {
   display: flex;
 }
 
 .spectrum-color-contrast,
-.spectrum-color-contrast .accessibility-color-contrast-large-text {
+.spectrum-color-contrast .contrast-ratio-range,
+.spectrum-color-contrast.range .contrast-ratio-single,
+.spectrum-color-contrast.error .accessibility-color-contrast-separator,
+.spectrum-color-contrast.error .contrast-ratio-max {
   display: none;
 }
 
 .contrast-ratio-label {
+  font-size: 10px;
   padding-inline-end: 4px;
-  padding-inline-start: 2px;
   color: var(--theme-toolbar-color);
 }
 
-.spectrum-color-contrast .accessibility-color-contrast {
-  align-items: stretch;
+.spectrum-color-contrast .accessibility-contrast-value {
+  font-size: 10px;
+  color: var(--theme-body-color);
+  border-bottom: 1px solid var(--learn-more-underline);
 }
 
-.spectrum-color-contrast .accessibility-contrast-value {
-  color: var(--theme-body-color);
-  border-bottom: 1px solid var(--learn-more-underline);
+.spectrum-color-contrast.visible:not(.error) .contrast-ratio-single .accessibility-contrast-value {
+  margin-inline-start: 10px;
+}
+
+.spectrum-color-contrast.visible:not(.error) .contrast-ratio-min .accessibility-contrast-value,
+.spectrum-color-contrast.visible:not(.error) .contrast-ratio-max .accessibility-contrast-value{
+  margin-inline-start: 7px;
 }
 
 .spectrum-color-contrast .accessibility-contrast-value:not(:empty)::before {
   width: auto;
   content: none;
   padding-inline-start: 2px;
 }
 
+.spectrum-color-contrast.visible:not(.error) .contrast-value-and-swatch:before {
+  display: inline-flex;
+  content: "";
+  height: 9px;
+  width: 9px;
+  background-color: var(--accessibility-contrast-color);
+}
+
+.spectrum-color-contrast.visible:not(.error):-moz-locale-dir(ltr) .contrast-value-and-swatch:before {
+  box-shadow: 0 0 0 1px var(--grey-40), 6px 5px var(--accessibility-contrast-bg),
+    6px 5px 0 1px var(--grey-40);
+}
+
+.spectrum-color-contrast.visible:not(.error):-moz-locale-dir(rtl) .contrast-value-and-swatch:before {
+  box-shadow: 0 0 0 1px var(--grey-40), -6px 5px var(--accessibility-contrast-bg),
+    -6px 5px 0 1px var(--grey-40);
+}
+
+.spectrum-color-contrast .accessibility-color-contrast-separator:before {
+  margin-inline-end: 4px;
+  color: var(--theme-body-color);
+}
+
+.spectrum-color-contrast .accessibility-color-contrast-large-text {
+  margin-inline-start: 1px;
+  margin-inline-end: 1px;
+  unicode-bidi: isolate;
+}
+
 .learn-more {
-  background-size: 12px 12px;
   background-repeat: no-repeat;
   -moz-context-properties: fill;
   background-image: url(chrome://devtools/skin/images/info-small.svg);
   background-color: transparent;
   fill: var(--theme-icon-dimmed-color);
   border: none;
   margin-inline-start: auto;
-  margin-block-start: 2px;
+  margin-block-start: 1px;
+}
+
+.learn-more:-moz-locale-dir(ltr) {
+  margin-inline-end: -5px;
+}
+
+.learn-more:-moz-locale-dir(rtl) {
+  margin-inline-end: -2px;
 }
 
 .learn-more:hover,
 .learn-more:focus {
   fill: var(--theme-icon-color);
   cursor: pointer;
   outline: none;
 }
--- a/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
+++ b/devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js
@@ -105,30 +105,31 @@ class SwatchColorPickerTooltip extends S
   async show() {
     // set contrast enabled for the spectrum
     const name = this.activeSwatch.dataset.propertyName;
 
     if (this.isContrastCompatible === undefined) {
       const target = this.inspector.target;
       this.isContrastCompatible = await target.actorHasMethod(
         "domnode",
-        "getClosestBackgroundColor"
+        "getBackgroundColor"
       );
     }
 
-    // Only enable contrast and set spectrum text props if selected node is
+    // Only enable contrast and set text props and bg color if selected node is
     // contrast compatible and if the type of property is color.
     this.spectrum.contrastEnabled =
       name === "color" && this.isContrastCompatible;
-    this.spectrum.textProps = this.spectrum.contrastEnabled
-      ? await this.inspector.pageStyle.getComputed(
-          this.inspector.selection.nodeFront,
-          { filterProperties: ["font-size", "font-weight"] }
-        )
-      : null;
+    if (this.spectrum.contrastEnabled) {
+      this.spectrum.textProps = await this.inspector.pageStyle.getComputed(
+        this.inspector.selection.nodeFront,
+        { filterProperties: ["font-size", "font-weight", "opacity"] }
+      );
+      this.spectrum.backgroundColorData = await this.inspector.selection.nodeFront.getBackgroundColor();
+    }
 
     // Then set spectrum's color and listen to color changes to preview them
     if (this.activeSwatch) {
       this.currentSwatchColor = this.activeSwatch.nextSibling;
       this._originalColor = this.currentSwatchColor.textContent;
       const color = this.activeSwatch.style.backgroundColor;
 
       this.spectrum.off("changed", this._onSpectrumColorChange);
@@ -233,17 +234,17 @@ class SwatchColorPickerTooltip extends S
   _openEyeDropper() {
     const { inspectorFront, toolbox, telemetry } = this.inspector;
 
     telemetry
       .getHistogramById(TELEMETRY_PICKER_EYEDROPPER_OPEN_COUNT)
       .add(true);
 
     // cancelling picker(if it is already selected) on opening eye-dropper
-    inspectorFront.nodePicker.cancel();
+    toolbox.nodePicker.cancel();
 
     // pickColorFromPage will focus the content document. If the devtools are in a
     // separate window, the colorpicker tooltip will be closed before pickColorFromPage
     // resolves. Flip the flag early to avoid issues with onTooltipHidden().
     this.eyedropperOpen = true;
 
     inspectorFront.pickColorFromPage({ copyOnSelect: false }).then(() => {
       // close the colorpicker tooltip so that only the eyedropper is open.
--- a/devtools/client/themes/accessibility-color-contrast.css
+++ b/devtools/client/themes/accessibility-color-contrast.css
@@ -43,16 +43,21 @@
 }
 
 .accessibility-color-contrast
   .accessibility-contrast-value:not(:empty).AAA:after {
   content: "AAA\2713";
   unicode-bidi: isolate;
 }
 
+.accessibility-color-contrast .accessibility-color-contrast-separator:before {
+  content: "–";
+  margin-inline-start: 4px;
+}
+
 .accessibility-color-contrast-large-text {
   background-color: var(--badge-background-color);
   color: var(--badge-color);
   outline: 1px solid var(--badge-border-color);
   -moz-outline-radius: 3px;
   padding: 0px 2px;
   margin-inline-start: 6px;
   line-height: initial;
--- a/devtools/client/themes/tooltips.css
+++ b/devtools/client/themes/tooltips.css
@@ -435,17 +435,17 @@ strong {
   list-style-image: none;
   -moz-context-properties: fill;
   fill: currentColor;
   background: url(chrome://global/skin/icons/check.svg) no-repeat transparent;
   background-size: 11px 11px;
   background-position: center left 7px;
 }
 
-.tooltip-container[type="doorhanger"] .menuitem > .command[aria-checked="true"]:-moz-locale-dir(rtl) {
+.tooltip-container[type="doorhanger"] .menuitem > .command[aria-checked="true"]:dir(rtl) {
   background-position: center right 7px;
 }
 
 .tooltip-container[type="doorhanger"] .menuitem > .command > .label {
   flex: 1;
   font: menu;
   white-space: nowrap;
 }
--- a/devtools/client/webconsole/test/browser/head.js
+++ b/devtools/client/webconsole/test/browser/head.js
@@ -1131,26 +1131,25 @@ function isReverseSearchInputFocused(hud
  *
  * @param {Object} toolbox
  * @param {Object} testActor: A test actor registered on the target. Needed to click on
  *                            the content element.
  * @param {String} selector: The selector for the node we want to select.
  */
 async function selectNodeWithPicker(toolbox, testActor, selector) {
   const inspector = toolbox.getPanel("inspector");
-  const inspectorFront = inspector.inspectorFront;
 
-  const onPickerStarted = inspectorFront.nodePicker.once("picker-started");
-  inspectorFront.nodePicker.start();
+  const onPickerStarted = toolbox.nodePicker.once("picker-started");
+  toolbox.nodePicker.start();
   await onPickerStarted;
 
   info(
     `Picker mode started, now clicking on "${selector}" to select that node`
   );
-  const onPickerStopped = inspectorFront.nodePicker.once("picker-stopped");
+  const onPickerStopped = toolbox.nodePicker.once("picker-stopped");
   const onInspectorUpdated = inspector.once("inspector-updated");
 
   testActor.synthesizeMouse({
     selector,
     center: true,
     options: {},
   });
 
--- a/devtools/client/webreplay/mochitest/browser_rr_inspector-02.js
+++ b/devtools/client/webreplay/mochitest/browser_rr_inspector-02.js
@@ -19,21 +19,21 @@ add_task(async function() {
 
   await threadFront.interrupt();
   await threadFront.resume();
 
   await threadFront.interrupt();
   const bp = await setBreakpoint(threadFront, "doc_inspector_basic.html", 9);
   await rewindToLine(threadFront, 9);
 
-  const { inspector, testActor } = await openInspector();
+  const { testActor } = await openInspector();
 
   info("Waiting for element picker to become active.");
   toolbox.win.focus();
-  await inspector.inspectorFront.nodePicker.start();
+  await toolbox.nodePicker.start();
 
   info("Moving mouse over div.");
   await moveMouseOver("#maindiv", 1, 1);
 
   // Checks in isNodeCorrectlyHighlighted are off for an unknown reason, even
   // though the highlighting appears correctly in the UI.
   info("Performing checks");
   await testActor.isNodeCorrectlyHighlighted("#maindiv", is);
@@ -44,11 +44,11 @@ add_task(async function() {
   function moveMouseOver(selector, x, y) {
     info("Waiting for element " + selector + " to be highlighted");
     testActor.synthesizeMouse({
       selector,
       x,
       y,
       options: { type: "mousemove" },
     });
-    return inspector.inspectorFront.nodePicker.once("picker-node-hovered");
+    return toolbox.nodePicker.once("picker-node-hovered");
   }
 });
--- a/devtools/server/actors/accessibility/accessible.js
+++ b/devtools/server/actors/accessibility/accessible.js
@@ -31,16 +31,22 @@ loader.lazyRequireGetter(
 );
 loader.lazyRequireGetter(
   this,
   "findCssSelector",
   "devtools/shared/inspector/css-logic",
   true
 );
 loader.lazyRequireGetter(this, "events", "devtools/shared/event-emitter");
+loader.lazyRequireGetter(
+  this,
+  "getBounds",
+  "devtools/server/actors/highlighters/utils/accessibility",
+  true
+);
 
 const RELATIONS_TO_IGNORE = new Set([
   Ci.nsIAccessibleRelation.RELATION_CONTAINING_APPLICATION,
   Ci.nsIAccessibleRelation.RELATION_CONTAINING_TAB_PANE,
   Ci.nsIAccessibleRelation.RELATION_CONTAINING_WINDOW,
   Ci.nsIAccessibleRelation.RELATION_PARENT_WINDOW_OF,
   Ci.nsIAccessibleRelation.RELATION_SUBWINDOW_OF,
 ]);
@@ -452,17 +458,17 @@ const AccessibleActor = ActorClassWithSp
     const { DOMNode: rawNode } = this.rawAccessible;
     const win = rawNode.ownerGlobal;
 
     // Keep the reference to the walker actor in case the actor gets destroyed
     // during the colour contrast ratio calculation.
     const { walker } = this;
     walker.clearStyles(win);
     const contrastRatio = await getContrastRatioFor(rawNode.parentNode, {
-      bounds,
+      bounds: getBounds(win, bounds),
       win,
     });
 
     walker.restoreStyles(win);
 
     return contrastRatio;
   },
 
--- a/devtools/server/actors/accessibility/audit/contrast.js
+++ b/devtools/server/actors/accessibility/audit/contrast.js
@@ -8,22 +8,16 @@ loader.lazyRequireGetter(this, "colorUti
 loader.lazyRequireGetter(
   this,
   "CssLogic",
   "devtools/server/actors/inspector/css-logic",
   true
 );
 loader.lazyRequireGetter(
   this,
-  "getBounds",
-  "devtools/server/actors/highlighters/utils/accessibility",
-  true
-);
-loader.lazyRequireGetter(
-  this,
   "getCurrentZoom",
   "devtools/shared/layout/utils",
   true
 );
 loader.lazyRequireGetter(
   this,
   "addPseudoClassLock",
   "devtools/server/actors/highlighters/utils/markup",
@@ -32,17 +26,17 @@ loader.lazyRequireGetter(
 loader.lazyRequireGetter(
   this,
   "removePseudoClassLock",
   "devtools/server/actors/highlighters/utils/markup",
   true
 );
 loader.lazyRequireGetter(
   this,
-  "getContrastRatioScore",
+  "getContrastRatioAgainstBackground",
   "devtools/shared/accessibility",
   true
 );
 loader.lazyRequireGetter(
   this,
   "getTextProperties",
   "devtools/shared/accessibility",
   true
@@ -114,43 +108,34 @@ function getImageCtx(win, bounds, zoom, 
   if (node) {
     removePseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS);
   }
 
   return ctx;
 }
 
 /**
- * Calculates the contrast ratio of the referenced DOM node.
+ * Find RGBA or a range of RGBAs for the background pixels under the text.
  *
- * @param  {DOMNode} node
- *         The node for which we want to calculate the contrast ratio.
+ * @param  {DOMNode}  node
+ *         Node for which we want to get the background color data.
  * @param  {Object}  options
- *         - bounds   {Object}
- *                    Bounds for the accessible object.
- *         - win      {Object}
- *                    Target window.
- *
+ *         - bounds       {Object}
+ *                        Bounds for the accessible object.
+ *         - win          {Object}
+ *                        Target window.
+ *         - size         {Number}
+ *                        Font size of the selected text node
+ *         - isBoldText   {Boolean}
+ *                        True if selected text node is bold
  * @return {Object}
- *         An object that may contain one or more of the following fields: error,
- *         isLargeText, value, min, max values for contrast.
+ *         Object with one or more of the following RGBA fields: value, min, max
  */
-async function getContrastRatioFor(node, options = {}) {
-  const computedStyle = CssLogic.getComputedStyle(node);
-  const props = computedStyle ? getTextProperties(computedStyle) : null;
-
-  if (!props) {
-    return {
-      error: true,
-    };
-  }
-
-  const { color, isLargeText, isBoldText, size, opacity } = props;
-  const bounds = getBounds(options.win, options.bounds);
-  const zoom = 1 / getCurrentZoom(options.win);
+function getBackgroundFor(node, { win, bounds, size, isBoldText }) {
+  const zoom = 1 / getCurrentZoom(win);
   // When calculating colour contrast, we traverse image data for text nodes that are
   // drawn both with and without transparent text. Image data arrays are typically really
   // big. In cases when the font size is fairly large or when the page is zoomed in image
   // data is especially large (retrieving it and/or traversing it takes significant amount
   // of time). Here we optimize the size of the image data by scaling down the drawn nodes
   // to a size where their text size equals either BOLD_LARGE_TEXT_MIN_PIXELS or
   // LARGE_TEXT_MIN_PIXELS (lower threshold for large text size) depending on the font
   // weight.
@@ -164,40 +149,73 @@ async function getContrastRatioFor(node,
   // nodes with a lot of text.
   let scale =
     ((isBoldText ? BOLD_LARGE_TEXT_MIN_PIXELS : LARGE_TEXT_MIN_PIXELS) / size) *
     zoom;
   // We do not need to scale the images if the font is smaller than large or if the page
   // is zoomed out (scaling in this case would've been scaling up).
   scale = scale > 1 ? 1 : scale;
 
-  const textContext = getImageCtx(options.win, bounds, zoom, scale);
-  const backgroundContext = getImageCtx(options.win, bounds, zoom, scale, node);
+  const textContext = getImageCtx(win, bounds, zoom, scale);
+  const backgroundContext = getImageCtx(win, bounds, zoom, scale, node);
 
   const { data: dataText } = textContext.getImageData(
     0,
     0,
     bounds.width * scale,
     bounds.height * scale
   );
   const { data: dataBackground } = backgroundContext.getImageData(
     0,
     0,
     bounds.width * scale,
     bounds.height * scale
   );
 
-  const rgba = await worker.performTask(
+  return worker.performTask(
     "getBgRGBA",
     {
       dataTextBuf: dataText.buffer,
       dataBackgroundBuf: dataBackground.buffer,
     },
     [dataText.buffer, dataBackground.buffer]
   );
+}
+
+/**
+ * Calculates the contrast ratio of the referenced DOM node.
+ *
+ * @param  {DOMNode} node
+ *         The node for which we want to calculate the contrast ratio.
+ * @param  {Object}  options
+ *         - bounds                           {Object}
+ *                                            Bounds for the accessible object.
+ *         - win                              {Object}
+ *                                            Target window.
+ * @return {Object}
+ *         An object that may contain one or more of the following fields: error,
+ *         isLargeText, value, min, max values for contrast.
+ */
+async function getContrastRatioFor(node, options = {}) {
+  const computedStyle = CssLogic.getComputedStyle(node);
+  const props = computedStyle ? getTextProperties(computedStyle) : null;
+
+  if (!props) {
+    return {
+      error: true,
+    };
+  }
+
+  const { color, isLargeText, isBoldText, size, opacity } = props;
+
+  const rgba = await getBackgroundFor(node, {
+    ...options,
+    isBoldText,
+    size,
+  });
 
   if (!rgba) {
     // Fallback (original) contrast calculation algorithm. It tries to get the
     // closest background colour for the node and use it to calculate contrast.
     const backgroundColor = InspectorActorUtils.getClosestBackgroundColor(node);
     const backgroundImage = InspectorActorUtils.getClosestBackgroundImage(node);
 
     if (backgroundImage !== "none") {
@@ -210,54 +228,27 @@ async function getContrastRatioFor(node,
     let { r, g, b, a } = colorUtils.colorToRGBA(backgroundColor, true);
     // If the element has opacity in addition to background alpha value, take it
     // into account. TODO: this does not handle opacity set on ancestor
     // elements (see bug https://bugzilla.mozilla.org/show_bug.cgi?id=1544721).
     if (opacity < 1) {
       a = opacity * a;
     }
 
-    const value = colorUtils.calculateContrastRatio([r, g, b, a], color);
-    return {
-      value,
-      color,
-      backgroundColor: [r, g, b, a],
-      isLargeText,
-      score: getContrastRatioScore(value, isLargeText),
-    };
-  }
-
-  if (rgba.value) {
-    const value = colorUtils.calculateContrastRatio(rgba.value, color);
-    return {
-      value,
-      color,
-      backgroundColor: rgba.value,
-      isLargeText,
-      score: getContrastRatioScore(value, isLargeText),
-    };
+    return getContrastRatioAgainstBackground(
+      {
+        value: [r, g, b, a],
+      },
+      {
+        color,
+        isLargeText,
+      }
+    );
   }
 
-  let min = colorUtils.calculateContrastRatio(rgba.min, color);
-  let max = colorUtils.calculateContrastRatio(rgba.max, color);
-
-  // Flip minimum and maximum contrast ratios if necessary.
-  if (min > max) {
-    [min, max] = [max, min];
-    [rgba.min, rgba.max] = [rgba.max, rgba.min];
-  }
-
-  const score = getContrastRatioScore(min, isLargeText);
-
-  return {
-    min,
-    max,
+  return getContrastRatioAgainstBackground(rgba, {
     color,
-    backgroundColorMin: rgba.min,
-    backgroundColorMax: rgba.max,
     isLargeText,
-    score,
-    scoreMin: score,
-    scoreMax: getContrastRatioScore(max, isLargeText),
-  };
+  });
 }
 
 exports.getContrastRatioFor = getContrastRatioFor;
+exports.getBackgroundFor = getBackgroundFor;
--- a/devtools/server/actors/accessibility/walker.js
+++ b/devtools/server/actors/accessibility/walker.js
@@ -55,51 +55,41 @@ loader.lazyRequireGetter(
 loader.lazyRequireGetter(
   this,
   "isXUL",
   "devtools/server/actors/highlighters/utils/markup",
   true
 );
 loader.lazyRequireGetter(
   this,
-  "loadSheet",
-  "devtools/shared/layout/utils",
+  "loadSheetForBackgroundCalculation",
+  "devtools/server/actors/utils/accessibility",
   true
 );
 loader.lazyRequireGetter(
   this,
   "register",
   "devtools/server/actors/highlighters",
   true
 );
 loader.lazyRequireGetter(
   this,
-  "removeSheet",
-  "devtools/shared/layout/utils",
+  "removeSheetForBackgroundCalculation",
+  "devtools/server/actors/utils/accessibility",
   true
 );
 loader.lazyRequireGetter(
   this,
   "accessibility",
   "devtools/shared/constants",
   true
 );
 
 const kStateHover = 0x00000004; // NS_EVENT_STATE_HOVER
 
-const HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8,
-* {
-  transition: none !important;
-}
-
-:-moz-devtools-highlighted {
-  color: transparent !important;
-  text-shadow: none !important;
-}`;
-
 const {
   EVENT_TEXT_CHANGED,
   EVENT_TEXT_INSERTED,
   EVENT_TEXT_REMOVED,
   EVENT_ACCELERATOR_CHANGE,
   EVENT_ACTION_CHANGE,
   EVENT_DEFACTION_CHANGE,
   EVENT_DESCRIPTION_CHANGE,
@@ -741,17 +731,17 @@ const AccessibleWalkerActor = ActorClass
       this._loadedSheets.set(win, requests + 1);
       return;
     }
 
     // Disable potential mouse driven transitions (This is important because accessibility
     // highlighter temporarily modifies text color related CSS properties. In case where
     // there are transitions that affect them, there might be unexpected side effects when
     // taking a snapshot for contrast measurement).
-    loadSheet(win, HIGHLIGHTER_STYLES_SHEET);
+    loadSheetForBackgroundCalculation(win);
     this._loadedSheets.set(win, 1);
     this.hideHighlighter();
   },
 
   /**
    * Restore CSS and overlays that could've interfered with the audit for an
    * accessible object by unloading accessibility highlighter style sheet used
    * for preventing transitions and applying transparency when calculating
@@ -766,17 +756,17 @@ const AccessibleWalkerActor = ActorClass
     }
 
     if (requests > 1) {
       this._loadedSheets.set(win, requests - 1);
       return;
     }
 
     this.showHighlighter();
-    removeSheet(win, HIGHLIGHTER_STYLES_SHEET);
+    removeSheetForBackgroundCalculation(win);
     this._loadedSheets.delete(win);
   },
 
   hideHighlighter() {
     // TODO: Fix this workaround that temporarily removes higlighter bounds
     // overlay that can interfere with the contrast ratio calculation.
     if (this._highlighter) {
       const highlighter = this._highlighter.instance;
--- a/devtools/server/actors/inspector/node.js
+++ b/devtools/server/actors/inspector/node.js
@@ -682,16 +682,29 @@ const NodeActor = protocol.ActorClassWit
    *         String with the background color of the form rgba(r, g, b, a). Defaults to
    *         rgba(255, 255, 255, 1) if no background color is found.
    */
   getClosestBackgroundColor: function() {
     return InspectorActorUtils.getClosestBackgroundColor(this.rawNode);
   },
 
   /**
+   * Finds the background color range for the parent of a single text node
+   * (i.e. for multi-colored backgrounds with gradients, images) or a single
+   * background color for single-colored backgrounds. Defaults to the closest
+   * background color if an error is encountered.
+   *
+   * @return {Object}
+   *         Object with one or more of the following properties: value, min, max
+   */
+  getBackgroundColor: function() {
+    return InspectorActorUtils.getBackgroundColor(this);
+  },
+
+  /**
    * Returns an object with the width and height of the node's owner window.
    *
    * @return {Object}
    */
   getOwnerGlobalDimensions: function() {
     const win = this.rawNode.ownerGlobal;
     return {
       innerWidth: win.innerWidth,
--- a/devtools/server/actors/inspector/utils.js
+++ b/devtools/server/actors/inspector/utils.js
@@ -34,16 +34,46 @@ loader.lazyRequireGetter(
 );
 
 loader.lazyRequireGetter(
   this,
   "CssLogic",
   "devtools/server/actors/inspector/css-logic",
   true
 );
+loader.lazyRequireGetter(
+  this,
+  "getBackgroundFor",
+  "devtools/server/actors/accessibility/audit/contrast",
+  true
+);
+loader.lazyRequireGetter(
+  this,
+  "loadSheetForBackgroundCalculation",
+  "devtools/server/actors/utils/accessibility",
+  true
+);
+loader.lazyRequireGetter(
+  this,
+  "removeSheetForBackgroundCalculation",
+  "devtools/server/actors/utils/accessibility",
+  true
+);
+loader.lazyRequireGetter(
+  this,
+  "getAdjustedQuads",
+  "devtools/shared/layout/utils",
+  true
+);
+loader.lazyRequireGetter(
+  this,
+  "getTextProperties",
+  "devtools/shared/accessibility",
+  true
+);
 
 const XHTML_NS = "http://www.w3.org/1999/xhtml";
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 const IMAGE_FETCHING_TIMEOUT = 500;
 
 /**
  * Returns the properly cased version of the node's tag name, which can be
  * used when displaying said name in the UI.
@@ -401,19 +431,133 @@ function findGridParentContainerForNode(
     }
   } catch (e) {
     // Getting the parentNode can fail when the supplied node is in shadow DOM.
   }
 
   return null;
 }
 
+/**
+ * Finds the background color range for the parent of a single text node
+ * (i.e. for multi-colored backgrounds with gradients, images) or a single
+ * background color for single-colored backgrounds. Defaults to the closest
+ * background color if an error is encountered.
+ *
+ * @param  {Object}
+ *         Node actor containing the following properties:
+ *         {DOMNode} rawNode
+ *         Node for which we want to calculate the color contrast.
+ *         {WalkerActor} walker
+ *         Walker actor used to check whether the node is the parent elm of a single text node.
+ * @return {Object}
+ *         Object with one or more of the following properties:
+ *         {Array|null} value
+ *         RGBA array for single-colored background. Null for multi-colored backgrounds.
+ *         {Array|null} min
+ *         RGBA array for the min luminance color in a multi-colored background.
+ *         Null for single-colored backgrounds.
+ *         {Array|null} max
+ *         RGBA array for the max luminance color in a multi-colored background.
+ *         Null for single-colored backgrounds.
+ */
+async function getBackgroundColor({ rawNode: node, walker }) {
+  // Fall back to calculating contrast against closest bg if:
+  // - not element node
+  // - more than one child
+  // Avoid calculating bounds and creating doc walker by returning early.
+  if (node.nodeType != Node.ELEMENT_NODE || node.children.length > 0) {
+    return {
+      value: colorUtils.colorToRGBA(
+        getClosestBackgroundColor(node),
+        true,
+        true
+      ),
+    };
+  }
+
+  const bounds = getAdjustedQuads(
+    node.ownerGlobal,
+    node.firstChild,
+    "content"
+  )[0].bounds;
+
+  // Fall back to calculating contrast against closest bg if there are no bounds for text node.
+  // Avoid creating doc walker by returning early.
+  if (!bounds) {
+    return {
+      value: colorUtils.colorToRGBA(
+        getClosestBackgroundColor(node),
+        true,
+        true
+      ),
+    };
+  }
+
+  const docWalker = walker.getDocumentWalker(node);
+  const firstChild = docWalker.firstChild();
+
+  // Fall back to calculating contrast against closest bg if:
+  // - more than one child
+  // - unique child is not a text node
+  if (
+    !firstChild ||
+    docWalker.nextSibling() ||
+    firstChild.nodeType !== Node.TEXT_NODE
+  ) {
+    return {
+      value: colorUtils.colorToRGBA(
+        getClosestBackgroundColor(node),
+        true,
+        true
+      ),
+    };
+  }
+
+  // Try calculating complex backgrounds for node
+  const win = node.ownerGlobal;
+  loadSheetForBackgroundCalculation(win);
+  const computedStyle = CssLogic.getComputedStyle(node);
+  const props = computedStyle ? getTextProperties(computedStyle) : null;
+
+  // Fall back to calculating contrast against closest bg if there are no text props.
+  if (!props) {
+    return {
+      value: colorUtils.colorToRGBA(
+        getClosestBackgroundColor(node),
+        true,
+        true
+      ),
+    };
+  }
+
+  const bgColor = await getBackgroundFor(node, {
+    bounds,
+    win,
+    convertBoundsRelativeToViewport: false,
+    size: props.size,
+    isBoldText: props.isBoldText,
+  });
+  removeSheetForBackgroundCalculation(win);
+
+  return (
+    bgColor || {
+      value: colorUtils.colorToRGBA(
+        getClosestBackgroundColor(node),
+        true,
+        true
+      ),
+    }
+  );
+}
+
 module.exports = {
   allAnonymousContentTreeWalkerFilter,
   findGridParentContainerForNode,
+  getBackgroundColor,
   getClosestBackgroundColor,
   getClosestBackgroundImage,
   getNodeDisplayName,
   getNodeGridFlexType,
   imageToImageData,
   isNodeDead,
   nodeDocument,
   scrollbarTreeWalkerFilter,
--- a/devtools/server/actors/utils/accessibility.js
+++ b/devtools/server/actors/utils/accessibility.js
@@ -1,16 +1,40 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 loader.lazyRequireGetter(this, "Ci", "chrome", true);
 loader.lazyRequireGetter(this, "Services");
+loader.lazyRequireGetter(
+  this,
+  "loadSheet",
+  "devtools/shared/layout/utils",
+  true
+);
+loader.lazyRequireGetter(
+  this,
+  "removeSheet",
+  "devtools/shared/layout/utils",
+  true
+);
+
+// Highlighter style used for preventing transitions and applying transparency
+// when calculating colour contrast.
+const HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8,
+* {
+  transition: none !important;
+}
+
+:-moz-devtools-highlighted {
+  color: transparent !important;
+  text-shadow: none !important;
+}`;
 
 /**
  * Helper function that determines if nsIAccessible object is in defunct state.
  *
  * @param  {nsIAccessible}  accessible
  *         object to be tested.
  * @return {Boolean}
  *         True if accessible object is defunct, false otherwise.
@@ -32,9 +56,33 @@ function isDefunct(accessible) {
     defunct = !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT);
   } catch (e) {
     defunct = true;
   }
 
   return defunct;
 }
 
+/**
+ * Load highlighter style sheet used for preventing transitions and
+ * applying transparency when calculating colour contrast.
+ *
+ * @param  {Window} win
+ *         Window where highlighting happens.
+ */
+function loadSheetForBackgroundCalculation(win) {
+  loadSheet(win, HIGHLIGHTER_STYLES_SHEET);
+}
+
+/**
+ * Unload highlighter style sheet used for preventing transitions
+ * and applying transparency when calculating colour contrast.
+ *
+ * @param  {Window} win
+ *         Window where highlighting was happenning.
+ */
+function removeSheetForBackgroundCalculation(win) {
+  removeSheet(win, HIGHLIGHTER_STYLES_SHEET);
+}
+
 exports.isDefunct = isDefunct;
+exports.loadSheetForBackgroundCalculation = loadSheetForBackgroundCalculation;
+exports.removeSheetForBackgroundCalculation = removeSheetForBackgroundCalculation;
--- a/devtools/shared/accessibility.js
+++ b/devtools/shared/accessibility.js
@@ -107,11 +107,83 @@ function getTextProperties(computedStyle
     color: [r, g, b, a],
     isLargeText,
     isBoldText,
     size,
     opacity,
   };
 }
 
+/**
+ * Calculates contrast ratio or range of contrast ratios of the referenced DOM node
+ * against the given background color data. If background is multi-colored, return a
+ * range, otherwise a single contrast ratio.
+ *
+ * @param  {Object} backgroundColorData
+ *         Object with one or more of the following properties:
+ *         - value              {Array}
+ *                              rgba array for single color background
+ *         - min                {Array}
+ *                              min luminance rgba array for multi color background
+ *         - max                {Array}
+ *                              max luminance rgba array for multi color background
+ * @param  {Object}  textData
+ *         - color              {Array}
+ *                              rgba array for text of referenced DOM node
+ *         - isLargeText        {Boolean}
+ *                              True if text of referenced DOM node is large
+ * @return {Object}
+ *         An object that may contain one or more of the following fields: error,
+ *         isLargeText, value, min, max values for contrast.
+ */
+function getContrastRatioAgainstBackground(
+  backgroundColorData,
+  { color, isLargeText }
+) {
+  if (backgroundColorData.value) {
+    const value = colorUtils.calculateContrastRatio(
+      backgroundColorData.value,
+      color
+    );
+    return {
+      value,
+      color,
+      backgroundColor: backgroundColorData.value,
+      isLargeText,
+      score: getContrastRatioScore(value, isLargeText),
+    };
+  }
+
+  let {
+    min: backgroundColorMin,
+    max: backgroundColorMax,
+  } = backgroundColorData;
+  let min = colorUtils.calculateContrastRatio(backgroundColorMin, color);
+  let max = colorUtils.calculateContrastRatio(backgroundColorMax, color);
+
+  // Flip minimum and maximum contrast ratios if necessary.
+  if (min > max) {
+    [min, max] = [max, min];
+    [backgroundColorMin, backgroundColorMax] = [
+      backgroundColorMax,
+      backgroundColorMin,
+    ];
+  }
+
+  const score = getContrastRatioScore(min, isLargeText);
+
+  return {
+    min,
+    max,
+    color,
+    backgroundColorMin,
+    backgroundColorMax,
+    isLargeText,
+    score,
+    scoreMin: score,
+    scoreMax: getContrastRatioScore(max, isLargeText),
+  };
+}
+
 exports.getContrastRatioScore = getContrastRatioScore;
 exports.getTextProperties = getTextProperties;
+exports.getContrastRatioAgainstBackground = getContrastRatioAgainstBackground;
 exports.LARGE_TEXT = LARGE_TEXT;
--- a/devtools/shared/css/color.js
+++ b/devtools/shared/css/color.js
@@ -1240,22 +1240,28 @@ function parseOldStyleRgb(lexer, hasAlph
 
   return rgba;
 }
 
 /**
  * Convert a string representing a color to an object holding the
  * color's components.  Any valid CSS color form can be passed in.
  *
- * @param {String} name the color
- * @param {Boolean} useCssColor4ColorFunction use css-color-4 color function or not.
- * @return {Object} an object of the form {r, g, b, a}; or null if the
+ * @param {String} name
+ *        The color
+ * @param {Boolean} useCssColor4ColorFunction
+ *        Use css-color-4 color function or not.
+ * @param {Boolean} toArray
+ *        Return rgba array if true, otherwise object
+ * @return {Object|Array}
+ *         An object of the form {r, g, b, a} if toArray is false,
+ *         otherwise an array of the form [r, g, b, a]; or null if the
  *         name was not a valid color
  */
-function colorToRGBA(name, useCssColor4ColorFunction = false) {
+function colorToRGBA(name, useCssColor4ColorFunction = false, toArray = false) {
   name = name.trim().toLowerCase();
 
   if (name in cssColors) {
     const result = cssColors[name];
     return { r: result[0], g: result[1], b: result[2], a: result[3] };
   } else if (name === "transparent") {
     return { r: 0, g: 0, b: 0, a: 0 };
   } else if (name === "currentcolor") {
@@ -1299,17 +1305,17 @@ function colorToRGBA(name, useCssColor4C
 
   if (!vals) {
     return null;
   }
   if (getToken(lexer) !== null) {
     return null;
   }
 
-  return { r: vals[0], g: vals[1], b: vals[2], a: vals[3] };
+  return toArray ? vals : { r: vals[0], g: vals[1], b: vals[2], a: vals[3] };
 }
 
 /**
  * Check whether a string names a valid CSS color.
  *
  * @param {String} name The string to check
  * @param {Boolean} useCssColor4ColorFunction use css-color-4 color function or not.
  * @return {Boolean} True if the string is a CSS color name.
--- a/devtools/shared/fronts/inspector.js
+++ b/devtools/shared/fronts/inspector.js
@@ -2,17 +2,16 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const Services = require("Services");
 const defer = require("devtools/shared/defer");
 const Telemetry = require("devtools/client/shared/telemetry");
-const { NodePicker } = require("devtools/shared/fronts/inspector/node-picker");
 const {
   FrontClassWithSpec,
   types,
   registerFront,
 } = require("devtools/shared/protocol.js");
 const {
   inspectorSpec,
   walkerSpec,
@@ -485,17 +484,16 @@ class InspectorFront extends FrontClassW
 
     // Attribute name from which to retrieve the actorID out of the target actor's form
     this.formAttributeName = "inspectorActor";
   }
 
   // async initialization
   async initialize() {
     await Promise.all([this._getWalker(), this._getHighlighter()]);
-    this.nodePicker = new NodePicker(this.highlighter, this.walker);
   }
 
   async _getWalker() {
     const showAllAnonymousContent = Services.prefs.getBoolPref(
       SHOW_ALL_ANONYMOUS_CONTENT_PREF
     );
     const showUserAgentShadowRoots = Services.prefs.getBoolPref(
       SHOW_UA_SHADOW_ROOTS_PREF
--- a/devtools/shared/fronts/inspector/moz.build
+++ b/devtools/shared/fronts/inspector/moz.build
@@ -1,11 +1,10 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DevToolsModules(
-    'node-picker.js',
     'rule-rewriter.js',
 )
 
--- a/devtools/shared/specs/node.js
+++ b/devtools/shared/specs/node.js
@@ -125,16 +125,20 @@ const nodeSpec = generateActorSpec({
       response: RetVal("imageData"),
     },
     getClosestBackgroundColor: {
       request: {},
       response: {
         value: RetVal("string"),
       },
     },
+    getBackgroundColor: {
+      request: {},
+      response: RetVal("json"),
+    },
     getOwnerGlobalDimensions: {
       request: {},
       response: RetVal("windowDimensions"),
     },
     connectToRemoteFrame: {
       request: {},
       // We are passing a target actor form here.
       // As we are manually fetching the form JSON via DebuggerServer.connectToFrame,
--- a/dom/canvas/test/chrome/test_drawWindow_widget_layers.html
+++ b/dom/canvas/test/chrome/test_drawWindow_widget_layers.html
@@ -29,22 +29,26 @@
 
     // Need to open as a toplevel chrome window so that
     // DRAWWINDOW_USE_WIDGET_LAYERS is honored.
     sourceWindow = window.open("file_drawWindow_source.html", "",
                                `chrome,width=${WINDOW_INNER_WIDTH},height=${WINDOW_INNER_HEIGHT}`);
     SimpleTest.waitForFocus(runTests, sourceWindow);
   }
 
-  function runTests() {
+  async function runTests() {
     var cxInterfaceWrap = SpecialPowers.wrap(CanvasRenderingContext2D);
-    var flags = cxInterfaceWrap.DRAWWINDOW_USE_WIDGET_LAYERS |
-                cxInterfaceWrap.DRAWWINDOW_DRAW_CARET |
-                cxInterfaceWrap.DRAWWINDOW_DRAW_VIEW;
-    runDrawWindowTests(sourceWindow, flags, true);
+    
+    let snapshot = function(context, x, y, width, height, bg) {
+      var flags = cxInterfaceWrap.DRAWWINDOW_USE_WIDGET_LAYERS |
+                  cxInterfaceWrap.DRAWWINDOW_DRAW_CARET |
+                  cxInterfaceWrap.DRAWWINDOW_DRAW_VIEW;
+      context.drawWindow(sourceWindow, x, y, width, height, bg, flags);
+    }
+    await runDrawWindowTests(snapshot, true);
 
     sourceWindow.close();
 
     SimpleTest.finish();
   }
 
   </script>
 </head>
--- a/dom/canvas/test/file_drawWindow_common.js
+++ b/dom/canvas/test/file_drawWindow_common.js
@@ -1,12 +1,12 @@
 const CANVAS_WIDTH = 200;
 const CANVAS_HEIGHT = 100;
 
-function runDrawWindowTests(win, drawWindowFlags, transparentBackground) {
+async function runDrawWindowTests(snapshotCallback, transparentBackground) {
   function make_canvas() {
     var canvas = document.createElement("canvas");
     canvas.setAttribute("height", CANVAS_HEIGHT);
     canvas.setAttribute("width", CANVAS_WIDTH);
     document.body.appendChild(canvas);
     return canvas;
   }
 
@@ -32,24 +32,23 @@ function runDrawWindowTests(win, drawWin
   function clear(fillStyle) {
     clearRef(fillStyle);
     clearTest(fillStyle);
   }
 
   // Basic tests of drawing the whole document on a background
 
   clear("white");
-  testWrapCx.drawWindow(
-    win,
+  await snapshotCallback(
+    testWrapCx,
     0,
     0,
     CANVAS_WIDTH,
     CANVAS_HEIGHT,
-    "rgb(255, 255, 255)",
-    drawWindowFlags
+    "rgb(255, 255, 255)"
   );
   refCx.fillStyle = "fuchsia";
   refCx.fillRect(10, 10, 20, 20);
   refCx.fillStyle = "aqua";
   refCx.fillRect(50, 10, 20, 20);
   refCx.fillStyle = "yellow";
   refCx.fillRect(90, 10, 20, 20);
   assertSnapshots(
@@ -57,24 +56,23 @@ function runDrawWindowTests(win, drawWin
     refCanvas,
     true /* equal */,
     null /*no fuzz*/,
     "full draw of source on white background",
     "reference"
   );
 
   clearTest("white");
-  testWrapCx.drawWindow(
-    win,
+  await snapshotCallback(
+    testWrapCx,
     0,
     0,
     CANVAS_WIDTH,
     CANVAS_HEIGHT,
-    "rgb(255, 255, 0)",
-    drawWindowFlags
+    "rgb(255, 255, 0)"
   );
   assertSnapshots(
     testCanvas,
     refCanvas,
     !transparentBackground /* not equal */,
     null /*no fuzz*/,
     "full draw of source on yellow background",
     "reference"
@@ -97,34 +95,34 @@ function runDrawWindowTests(win, drawWin
     "reference"
   );
 
   // Test drawing a region within the document.
 
   clear("white");
 
   testCx.translate(17, 31);
-  testWrapCx.drawWindow(win, 40, 0, 40, 40, "white", drawWindowFlags);
+  await snapshotCallback(testWrapCx, 40, 0, 40, 40, "white");
 
   refCx.fillStyle = "aqua";
   refCx.fillRect(17 + 10, 31 + 10, 20, 20);
 
   assertSnapshots(
     testCanvas,
     refCanvas,
     true /* equal */,
     null /*no fuzz*/,
     "draw of subrect of source with matching background",
     "reference"
   );
 
   clear("blue");
 
   testCx.translate(17, 31);
-  testWrapCx.drawWindow(win, 40, 0, 35, 45, "green", drawWindowFlags);
+  await snapshotCallback(testWrapCx, 40, 0, 35, 45, "green");
 
   if (transparentBackground) {
     refCx.fillStyle = "green";
   } else {
     refCx.fillStyle = "white";
   }
   refCx.fillRect(17, 31, 35, 45);
   refCx.fillStyle = "aqua";
@@ -138,17 +136,17 @@ function runDrawWindowTests(win, drawWin
     "draw of subrect of source with different background",
     "reference"
   );
 
   // Test transparency of background not disturbing what is behind
   clear("blue");
 
   testCx.translate(17, 31);
-  testWrapCx.drawWindow(win, 40, 0, 35, 45, "transparent", drawWindowFlags);
+  await snapshotCallback(testWrapCx, 40, 0, 35, 45, "transparent");
 
   if (!transparentBackground) {
     refCx.fillStyle = "white";
     refCx.fillRect(17, 31, 35, 45);
   }
   refCx.fillStyle = "aqua";
   refCx.fillRect(17 + 10, 31 + 10, 20, 20);
 
@@ -161,22 +159,22 @@ function runDrawWindowTests(win, drawWin
     "reference"
   );
 
   // Test that multiple drawWindow calls draw at correct positions.
   clear("blue");
 
   testCx.translate(9, 3);
   // 5, 8 is 5, 2 from the corner of the fuchsia square
-  testWrapCx.drawWindow(win, 5, 8, 30, 25, "maroon", drawWindowFlags);
+  await snapshotCallback(testWrapCx, 5, 8, 30, 25, "maroon");
   // 35, 0 is 15, 10 from the corner of the aqua square
-  testWrapCx.drawWindow(win, 35, 0, 50, 40, "transparent", drawWindowFlags);
+  await snapshotCallback(testWrapCx, 35, 0, 50, 40, "transparent");
   testCx.translate(15, 0);
   // 85, 5 is 5, 5 from the corner of the yellow square
-  testWrapCx.drawWindow(win, 85, 5, 30, 25, "transparent", drawWindowFlags);
+  await snapshotCallback(testWrapCx, 85, 5, 30, 25, "transparent");
 
   if (transparentBackground) {
     refCx.fillStyle = "maroon";
     refCx.fillRect(9, 3, 30, 25);
     refCx.fillStyle = "fuchsia";
     refCx.fillRect(9 + 5, 3 + 2, 20, 20);
   } else {
     refCx.fillStyle = "white";
--- a/dom/canvas/test/mochitest.ini