Bug 1626721 - Make DSImages use native lazy loading, and declarative responsive images. r=thecount
authorMike Conley <mconley@mozilla.com>
Mon, 20 Apr 2020 22:26:48 +0000
changeset 524978 d4b29020425122bc53b44b44cc6939a919b32995
parent 524977 d525546954bb745e9d3984e5189eb5777dfa4811
child 524979 c81fcd640993c292770a02e85ed0a846e94565ff
push id113512
push usermconley@mozilla.com
push dateMon, 20 Apr 2020 22:30:24 +0000
treeherderautoland@d4b290204251 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersthecount
bugs1626721
milestone77.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1626721 - Make DSImages use native lazy loading, and declarative responsive images. r=thecount Differential Revision: https://phabricator.services.mozilla.com/D69868
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
browser/components/newtab/content-src/styles/_variables.scss
browser/components/newtab/data/content/activity-stream.bundle.js
browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
@@ -97,16 +97,44 @@ export class _DSCard extends React.PureC
       isSeen: false,
     };
 
     // If this is for the about:home startup cache, then we always want
     // to render the DSCard, regardless of whether or not its been seen.
     if (props.App.isForStartupCache) {
       this.state.isSeen = true;
     }
+
+    // We want to choose the optimal thumbnail for the underlying DSImage, but
+    // want to do it in a performant way. The breakpoints used in the
+    // CSS of the page are, unfortuntely, not easy to retrieve without
+    // causing a style flush. To avoid that, we hardcode them here.
+    //
+    // The values chosen here were the dimensions of the card thumbnails as
+    // computed by getBoundingClientRect() for each type of viewport width
+    // across both high-density and normal-density displays.
+    this.dsImageSizes = [
+      {
+        mediaMatcher: "(min-width: 1122px)",
+        width: 296,
+        height: 148,
+      },
+
+      {
+        mediaMatcher: "(min-width: 866px)",
+        width: 218,
+        height: 109,
+      },
+
+      {
+        mediaMatcher: "(max-width: 610px)",
+        width: 202,
+        height: 101,
+      },
+    ];
   }
 
   onLinkClick(event) {
     if (this.props.dispatch) {
       this.props.dispatch(
         ac.UserEvent({
           event: "CLICK",
           source: this.props.type.toUpperCase(),
@@ -197,16 +225,17 @@ export class _DSCard extends React.PureC
           onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined}
           url={this.props.url}
         >
           <div className="img-wrapper">
             <DSImage
               extraClassNames="img"
               source={this.props.image_src}
               rawSource={this.props.raw_image_src}
+              sizes={this.dsImageSizes}
             />
           </div>
           {isButtonCTA ? (
             <CTAButtonMeta
               display_engagement_labels={this.props.display_engagement_labels}
               source={this.props.source}
               title={this.props.title}
               excerpt={this.props.excerpt}
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
@@ -1,54 +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/. */
 
 import React from "react";
-import ReactDOM from "react-dom";
 
 export class DSImage extends React.PureComponent {
   constructor(props) {
     super(props);
 
     this.onOptimizedImageError = this.onOptimizedImageError.bind(this);
     this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this);
+    this.onLoad = this.onLoad.bind(this);
 
     this.state = {
-      isSeen: false,
+      isLoaded: false,
       optimizedImageFailed: false,
       useTransition: false,
     };
   }
 
-  onSeen(entries) {
-    if (this.state) {
-      const entry = entries.find(e => e.isIntersecting);
-
-      if (entry) {
-        if (this.props.optimize) {
-          this.setState({
-            // Thumbor doesn't handle subpixels and just errors out, so rounding...
-            containerWidth: Math.round(entry.boundingClientRect.width),
-            containerHeight: Math.round(entry.boundingClientRect.height),
-          });
-        }
-
-        this.setState({
-          isSeen: true,
-        });
-
-        // Stop observing since element has been seen
-        this.observer.unobserve(ReactDOM.findDOMNode(this));
-      }
-    }
-  }
-
   onIdleCallback() {
-    if (!this.state.isSeen) {
+    if (!this.state.isLoaded) {
       this.setState({
         useTransition: true,
       });
     }
   }
 
   reformatImageURL(url, width, height) {
     // Change the image URL to request a size tailored for the parent container width
@@ -58,83 +35,89 @@ export class DSImage extends React.PureC
       url
     )}`;
   }
 
   componentDidMount() {
     this.idleCallbackId = this.props.windowObj.requestIdleCallback(
       this.onIdleCallback.bind(this)
     );
-    this.observer = new IntersectionObserver(this.onSeen.bind(this), {
-      // Assume an image will be eventually seen if it is within
-      // half the average Desktop vertical screen size:
-      // http://gs.statcounter.com/screen-resolution-stats/desktop/north-america
-      rootMargin: `540px`,
-    });
-    this.observer.observe(ReactDOM.findDOMNode(this));
   }
 
   componentWillUnmount() {
-    // Remove observer on unmount
-    if (this.observer) {
-      this.observer.unobserve(ReactDOM.findDOMNode(this));
-    }
     if (this.idleCallbackId) {
       this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
     }
   }
 
   render() {
     let classNames = `ds-image
       ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}
       ${this.state && this.state.useTransition ? ` use-transition` : ``}
-      ${this.state && this.state.isSeen ? ` loaded` : ``}
+      ${this.state && this.state.isLoaded ? ` loaded` : ``}
     `;
 
     let img;
 
-    if (this.state && this.state.isSeen) {
+    if (this.state) {
       if (
         this.props.optimize &&
         this.props.rawSource &&
         !this.state.optimizedImageFailed
       ) {
-        let source;
-        let source2x;
-
-        if (this.state && this.state.containerWidth) {
-          let baseSource = this.props.rawSource;
+        let baseSource = this.props.rawSource;
 
-          source = this.reformatImageURL(
-            baseSource,
-            this.state.containerWidth,
-            this.state.containerHeight
-          );
+        let sizeRules = [];
+        let srcSetRules = [];
 
-          source2x = this.reformatImageURL(
+        for (let rule of this.props.sizes) {
+          let { mediaMatcher, width, height } = rule;
+          let sizeRule = `${mediaMatcher} ${width}px`;
+          sizeRules.push(sizeRule);
+          let srcSetRule = `${this.reformatImageURL(
             baseSource,
-            this.state.containerWidth * 2,
-            this.state.containerHeight * 2
-          );
+            width,
+            height
+          )} ${width}w`;
+          let srcSetRule2x = `${this.reformatImageURL(
+            baseSource,
+            width * 2,
+            height * 2
+          )} ${width * 2}w`;
+          srcSetRules.push(srcSetRule);
+          srcSetRules.push(srcSetRule2x);
+        }
 
-          img = (
-            <img
-              alt={this.props.alt_text}
-              crossOrigin="anonymous"
-              onError={this.onOptimizedImageError}
-              src={source}
-              srcSet={`${source2x} 2x`}
-            />
+        if (this.props.sizes.length) {
+          // We have to supply a fallback in the very unlikely event that none of
+          // the media queries match. The smallest dimension was chosen arbitrarily.
+          sizeRules.push(
+            `${this.props.sizes[this.props.sizes.length - 1].width}px`
           );
         }
+
+        img = (
+          <img
+            loading="lazy"
+            alt={this.props.alt_text}
+            crossOrigin="anonymous"
+            onLoad={this.onLoad}
+            onError={this.onOptimizedImageError}
+            sizes={sizeRules.join(",")}
+            src={baseSource}
+            srcSet={srcSetRules.join(",")}
+          />
+        );
       } else if (!this.state.nonOptimizedImageFailed) {
         img = (
           <img
+            loading="lazy"
             alt={this.props.alt_text}
             crossOrigin="anonymous"
+            onLoad={this.onLoad}
             onError={this.onNonOptimizedImageError}
             src={this.props.source}
           />
         );
       } else {
         // Remove the img element if both sources fail. Render a placeholder instead.
         img = <div className="broken-image" />;
       }
@@ -150,18 +133,25 @@ export class DSImage extends React.PureC
     });
   }
 
   onNonOptimizedImageError() {
     this.setState({
       nonOptimizedImageFailed: true,
     });
   }
+
+  onLoad() {
+    this.setState({
+      isLoaded: true,
+    });
+  }
 }
 
 DSImage.defaultProps = {
   source: null, // The current source style from Pocket API (always 450px)
   rawSource: null, // Unadulterated image URL to filter through Thumbor
   extraClassNames: null, // Additional classnames to append to component
   optimize: true, // Measure parent container to request exact sizes
   alt_text: null,
   windowObj: window, // Added to support unit tests
+  sizes: [],
 };
--- a/browser/components/newtab/content-src/styles/_variables.scss
+++ b/browser/components/newtab/content-src/styles/_variables.scss
@@ -117,16 +117,18 @@
 $wrapper-max-width-large: $grid-unit * 6 + $base-gutter * 5 + $section-horizontal-padding * 2; // 6 top sites
 $wrapper-max-width-widest: $grid-unit * 8 + $base-gutter * 7 + $section-horizontal-padding * 2; // 8 top sites
 // For the breakpoints, we need to add space for the scrollbar to avoid weird
 // layout issues when the scrollbar is visible. 16px is wide enough to cover all
 // OSes and keeps it simpler than a per-OS value.
 $scrollbar-width: 16px;
 
 // Breakpoints
+// If updating these breakpoints, don't forget to update uses of DSImage, which
+// might choose the right image src to use depending on the viewport size.
 $break-point-medium: $wrapper-max-width-medium + $base-gutter * 2 + $scrollbar-width; // 610px
 $break-point-large: $wrapper-max-width-large + $base-gutter * 2 + $scrollbar-width; // 866px
 $break-point-widest: $wrapper-max-width-widest + $base-gutter * 2 + $scrollbar-width; // 1122px
 
 $section-title-font-size: 13px;
 
 $card-width: $grid-unit * 2 + $base-gutter;
 $card-height: 266px;
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -4785,17 +4785,39 @@ class _DSCard extends react__WEBPACK_IMP
 
     this.state = {
       isSeen: false
     }; // If this is for the about:home startup cache, then we always want
     // to render the DSCard, regardless of whether or not its been seen.
 
     if (props.App.isForStartupCache) {
       this.state.isSeen = true;
-    }
+    } // We want to choose the optimal thumbnail for the underlying DSImage, but
+    // want to do it in a performant way. The breakpoints used in the
+    // CSS of the page are, unfortuntely, not easy to retrieve without
+    // causing a style flush. To avoid that, we hardcode them here.
+    //
+    // The values chosen here were the dimensions of the card thumbnails as
+    // computed by getBoundingClientRect() for each type of viewport width
+    // across both high-density and normal-density displays.
+
+
+    this.dsImageSizes = [{
+      mediaMatcher: "(min-width: 1122px)",
+      width: 296,
+      height: 148
+    }, {
+      mediaMatcher: "(min-width: 866px)",
+      width: 218,
+      height: 109
+    }, {
+      mediaMatcher: "(max-width: 610px)",
+      width: 202,
+      height: 101
+    }];
   }
 
   onLinkClick(event) {
     if (this.props.dispatch) {
       this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].UserEvent({
         event: "CLICK",
         source: this.props.type.toUpperCase(),
         action_position: this.props.pos,
@@ -4882,17 +4904,18 @@ class _DSCard extends react__WEBPACK_IMP
       dispatch: this.props.dispatch,
       onLinkClick: !this.props.placeholder ? this.onLinkClick : undefined,
       url: this.props.url
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: "img-wrapper"
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(_DSImage_DSImage_jsx__WEBPACK_IMPORTED_MODULE_1__["DSImage"], {
       extraClassNames: "img",
       source: this.props.image_src,
-      rawSource: this.props.raw_image_src
+      rawSource: this.props.raw_image_src,
+      sizes: this.dsImageSizes
     })), isButtonCTA ? react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(CTAButtonMeta, {
       display_engagement_labels: this.props.display_engagement_labels,
       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,
@@ -4950,123 +4973,108 @@ const PlaceholderDSCard = props => react
 /* 29 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DSImage", function() { return DSImage; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
-/* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(12);
-/* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_1__);
-/* 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/. */
-
+/* 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/. */
 
 class DSImage extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onOptimizedImageError = this.onOptimizedImageError.bind(this);
     this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this);
+    this.onLoad = this.onLoad.bind(this);
     this.state = {
-      isSeen: false,
+      isLoaded: false,
       optimizedImageFailed: false,
       useTransition: false
     };
   }
 
-  onSeen(entries) {
-    if (this.state) {
-      const entry = entries.find(e => e.isIntersecting);
-
-      if (entry) {
-        if (this.props.optimize) {
-          this.setState({
-            // Thumbor doesn't handle subpixels and just errors out, so rounding...
-            containerWidth: Math.round(entry.boundingClientRect.width),
-            containerHeight: Math.round(entry.boundingClientRect.height)
-          });
-        }
-
-        this.setState({
-          isSeen: true
-        }); // Stop observing since element has been seen
-
-        this.observer.unobserve(react_dom__WEBPACK_IMPORTED_MODULE_1___default.a.findDOMNode(this));
-      }
-    }
-  }
-
   onIdleCallback() {
-    if (!this.state.isSeen) {
+    if (!this.state.isLoaded) {
       this.setState({
         useTransition: true
       });
     }
   }
 
   reformatImageURL(url, width, height) {
     // Change the image URL to request a size tailored for the parent container width
     // Also: force JPEG, quality 60, no upscaling, no EXIF data
     // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html
     return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(url)}`;
   }
 
   componentDidMount() {
     this.idleCallbackId = this.props.windowObj.requestIdleCallback(this.onIdleCallback.bind(this));
-    this.observer = new IntersectionObserver(this.onSeen.bind(this), {
-      // Assume an image will be eventually seen if it is within
-      // half the average Desktop vertical screen size:
-      // http://gs.statcounter.com/screen-resolution-stats/desktop/north-america
-      rootMargin: `540px`
-    });
-    this.observer.observe(react_dom__WEBPACK_IMPORTED_MODULE_1___default.a.findDOMNode(this));
   }
 
   componentWillUnmount() {
-    // Remove observer on unmount
-    if (this.observer) {
-      this.observer.unobserve(react_dom__WEBPACK_IMPORTED_MODULE_1___default.a.findDOMNode(this));
-    }
-
     if (this.idleCallbackId) {
       this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
     }
   }
 
   render() {
     let classNames = `ds-image
       ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}
       ${this.state && this.state.useTransition ? ` use-transition` : ``}
-      ${this.state && this.state.isSeen ? ` loaded` : ``}
+      ${this.state && this.state.isLoaded ? ` loaded` : ``}
     `;
     let img;
 
-    if (this.state && this.state.isSeen) {
+    if (this.state) {
       if (this.props.optimize && this.props.rawSource && !this.state.optimizedImageFailed) {
-        let source;
-        let source2x;
-
-        if (this.state && this.state.containerWidth) {
-          let baseSource = this.props.rawSource;
-          source = this.reformatImageURL(baseSource, this.state.containerWidth, this.state.containerHeight);
-          source2x = this.reformatImageURL(baseSource, this.state.containerWidth * 2, this.state.containerHeight * 2);
-          img = react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", {
-            alt: this.props.alt_text,
-            crossOrigin: "anonymous",
-            onError: this.onOptimizedImageError,
-            src: source,
-            srcSet: `${source2x} 2x`
-          });
-        }
+        let baseSource = this.props.rawSource;
+        let sizeRules = [];
+        let srcSetRules = [];
+
+        for (let rule of this.props.sizes) {
+          let {
+            mediaMatcher,
+            width,
+            height
+          } = rule;
+          let sizeRule = `${mediaMatcher} ${width}px`;
+          sizeRules.push(sizeRule);
+          let srcSetRule = `${this.reformatImageURL(baseSource, width, height)} ${width}w`;
+          let srcSetRule2x = `${this.reformatImageURL(baseSource, width * 2, height * 2)} ${width * 2}w`;
+          srcSetRules.push(srcSetRule);
+          srcSetRules.push(srcSetRule2x);
+        }
+
+        if (this.props.sizes.length) {
+          // We have to supply a fallback in the very unlikely event that none of
+          // the media queries match. The smallest dimension was chosen arbitrarily.
+          sizeRules.push(`${this.props.sizes[this.props.sizes.length - 1].width}px`);
+        }
+
+        img = react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", {
+          loading: "lazy",
+          alt: this.props.alt_text,
+          crossOrigin: "anonymous",
+          onLoad: this.onLoad,
+          onError: this.onOptimizedImageError,
+          sizes: sizeRules.join(","),
+          src: baseSource,
+          srcSet: srcSetRules.join(",")
+        });
       } else if (!this.state.nonOptimizedImageFailed) {
         img = react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", {
+          loading: "lazy",
           alt: this.props.alt_text,
           crossOrigin: "anonymous",
+          onLoad: this.onLoad,
           onError: this.onNonOptimizedImageError,
           src: this.props.source
         });
       } else {
         // Remove the img element if both sources fail. Render a placeholder instead.
         img = react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", {
           className: "broken-image"
         });
@@ -5086,29 +5094,36 @@ class DSImage extends react__WEBPACK_IMP
   }
 
   onNonOptimizedImageError() {
     this.setState({
       nonOptimizedImageFailed: true
     });
   }
 
+  onLoad() {
+    this.setState({
+      isLoaded: true
+    });
+  }
+
 }
 DSImage.defaultProps = {
   source: null,
   // The current source style from Pocket API (always 450px)
   rawSource: null,
   // Unadulterated image URL to filter through Thumbor
   extraClassNames: null,
   // Additional classnames to append to component
   optimize: true,
   // Measure parent container to request exact sizes
   alt_text: null,
-  windowObj: window // Added to support unit tests
-
+  windowObj: window,
+  // Added to support unit tests
+  sizes: []
 };
 
 /***/ }),
 /* 30 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx
@@ -1,57 +1,71 @@
 import { DSImage } from "content-src/components/DiscoveryStreamComponents/DSImage/DSImage";
 import { mount } from "enzyme";
 import React from "react";
 
 describe("Discovery Stream <DSImage>", () => {
-  let sandbox;
-  beforeEach(() => {
-    sandbox = sinon.createSandbox();
-  });
-
   it("should have a child with class ds-image", () => {
     const img = mount(<DSImage />);
     const child = img.find(".ds-image");
 
     assert.lengthOf(child, 1);
   });
 
   it("should set proper sources if only `source` is available", () => {
     const img = mount(<DSImage source="https://placekitten.com/g/640/480" />);
 
-    img.setState({
-      isSeen: true,
-      containerWidth: 640,
-    });
-
     assert.equal(
       img.find("img").prop("src"),
       "https://placekitten.com/g/640/480"
     );
   });
 
   it("should set proper sources if `rawSource` is available", () => {
-    const img = mount(
-      <DSImage rawSource="https://placekitten.com/g/640/480" />
-    );
+    const testSizes = [
+      {
+        mediaMatcher: "(min-width: 1122px)",
+        width: 296,
+        height: 148,
+      },
+
+      {
+        mediaMatcher: "(min-width: 866px)",
+        width: 218,
+        height: 109,
+      },
 
-    img.setState({
-      isSeen: true,
-      containerWidth: 640,
-      containerHeight: 480,
-    });
+      {
+        mediaMatcher: "(max-width: 610px)",
+        width: 202,
+        height: 101,
+      },
+    ];
+
+    const img = mount(
+      <DSImage
+        rawSource="https://placekitten.com/g/640/480"
+        sizes={testSizes}
+      />
+    );
 
     assert.equal(
       img.find("img").prop("src"),
-      "https://img-getpocket.cdn.mozilla.net/640x480/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480"
+      "https://placekitten.com/g/640/480"
     );
     assert.equal(
       img.find("img").prop("srcSet"),
-      "https://img-getpocket.cdn.mozilla.net/1280x960/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 2x"
+      [
+        "https://img-getpocket.cdn.mozilla.net/296x148/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 296w",
+        "https://img-getpocket.cdn.mozilla.net/592x296/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 592w",
+        "https://img-getpocket.cdn.mozilla.net/218x109/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 218w",
+        "https://img-getpocket.cdn.mozilla.net/436x218/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 436w",
+        "https://img-getpocket.cdn.mozilla.net/202x101/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 202w",
+        "https://img-getpocket.cdn.mozilla.net/404x202/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 404w",
+      ].join(",")
     );
   });
 
   it("should fall back to unoptimized when optimized failed", () => {
     const img = mount(
       <DSImage
         source="https://placekitten.com/g/640/480"
         rawSource="https://placekitten.com/g/640/480"
@@ -77,45 +91,25 @@ describe("Discovery Stream <DSImage>", (
     img.setState({ isSeen: true });
 
     img.instance().onNonOptimizedImageError();
     img.update();
 
     assert.equal(img.find("div").prop("className"), "broken-image");
   });
 
-  it("should update state when seen", () => {
+  it("should update loaded state when seen", () => {
     const img = mount(
       <DSImage rawSource="https://placekitten.com/g/640/480" />
     );
 
-    img.instance().onSeen([
-      {
-        isIntersecting: true,
-        boundingClientRect: {
-          width: 640,
-          height: 480,
-        },
-      },
-    ]);
-
-    assert.equal(img.state().containerWidth, 640);
-    assert.equal(img.state().containerHeight, 480);
-    assert.propertyVal(img.state(), "isSeen", true);
+    img.instance().onLoad();
+    assert.propertyVal(img.state(), "isLoaded", true);
   });
 
-  it("should stop observing when removed", () => {
-    const img = mount(<DSImage />);
-    const { observer } = img.instance();
-    sandbox.stub(observer, "unobserve");
-
-    img.unmount();
-
-    assert.calledOnce(observer.unobserve);
-  });
   describe("DSImage with Idle Callback", () => {
     let wrapper;
     let windowStub = {
       requestIdleCallback: sinon.stub().returns(1),
       cancelIdleCallback: sinon.stub(),
     };
     beforeEach(() => {
       wrapper = mount(<DSImage windowObj={windowStub} />);