Merge autoland to mozilla-central a=merge
authorCoroiu Cristina <ccoroiu@mozilla.com>
Fri, 16 Aug 2019 12:47:23 +0300
changeset 488477 5d4cbfe103bbc517599231eb33d4f3ebbbcede40
parent 488476 8da8443e0bcb7a6d9766d179332443660c926d8b (current diff)
parent 488454 0c769cff316532f09812f8315e97787e7cc67131 (diff)
child 488478 43423b54465d7509a26db82854d28722e3c9bc8f
child 488689 83fad6abe38a2584882e29477280b21d8a0c10c1
push id92771
push userccoroiu@mozilla.com
push dateFri, 16 Aug 2019 09:55:21 +0000
treeherderautoland@43423b54465d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone70.0a1
first release with
nightly linux32
5d4cbfe103bb / 70.0a1 / 20190816094815 / files
nightly linux64
5d4cbfe103bb / 70.0a1 / 20190816094815 / files
nightly mac
5d4cbfe103bb / 70.0a1 / 20190816094815 / files
nightly win32
5d4cbfe103bb / 70.0a1 / 20190816094815 / files
nightly win64
5d4cbfe103bb / 70.0a1 / 20190816094815 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge autoland to mozilla-central a=merge
devtools/client/debugger/src/actions/tests/helpers/threadFront.js
devtools/shared/fronts/inspector/node-picker.js
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
+++ b/dom/canvas/test/mochitest.ini
@@ -221,16 +221,18 @@ skip-if = os == "android"
 [test_canvas_strokeStyle_getter.html]
 [test_capture.html]
 support-files = captureStream_common.js
 [test_drawImageIncomplete.html]
 [test_drawImage_document_domain.html]
 [test_drawImage_edge_cases.html]
 [test_drawWindow.html]
 support-files = file_drawWindow_source.html file_drawWindow_common.js
+[test_drawSnapshot.html]
+support-files = file_drawWindow_source.html file_drawWindow_common.js
 [test_imagebitmap.html]
 skip-if = android_version == '19' # bug 1336581
 tags = imagebitmap
 [test_imagebitmap_close.html]
 tags = imagebitmap
 [test_imagebitmap_cropping.html]
 skip-if = android_v