This repository has been archived by the owner on Feb 29, 2020. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 113
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Bug 1531933 - Optimize image sizes & lazy load (#4885)
- Loading branch information
Showing
14 changed files
with
236 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import {cache} from "./cache"; | ||
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.state = { | ||
isSeen: false, | ||
optimizedImageFailed: false, | ||
}; | ||
} | ||
|
||
onSeen(entries) { | ||
if (this.state) { | ||
if (entries.some(entry => entry.isIntersecting)) { | ||
if (this.props.optimize) { | ||
this.setState({ | ||
containerWidth: ReactDOM.findDOMNode(this).clientWidth, | ||
}); | ||
} | ||
|
||
this.setState({ | ||
isSeen: true, | ||
}); | ||
|
||
// Stop observing since element has been seen | ||
this.observer.unobserve(ReactDOM.findDOMNode(this)); | ||
} | ||
} | ||
} | ||
|
||
reformatImageURL(url, width) { | ||
// 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}x0/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(url)}`; | ||
} | ||
|
||
componentDidMount() { | ||
this.observer = new IntersectionObserver(this.onSeen.bind(this)); | ||
this.observer.observe(ReactDOM.findDOMNode(this)); | ||
} | ||
|
||
componentWillUnmount() { | ||
// Remove observer on unmount | ||
if (this.observer) { | ||
this.observer.unobserve(ReactDOM.findDOMNode(this)); | ||
} | ||
} | ||
|
||
render() { | ||
const classNames = `ds-image${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}`; | ||
|
||
let img; | ||
|
||
if (this.state && this.state.isSeen) { | ||
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, | ||
cache.query(baseSource, this.state.containerWidth, `1x`) | ||
); | ||
|
||
source2x = this.reformatImageURL( | ||
baseSource, | ||
cache.query(baseSource, this.state.containerWidth * 2, `2x`) | ||
); | ||
|
||
img = (<img onError={this.onOptimizedImageError} src={source} srcSet={`${source2x} 2x`} />); | ||
} | ||
} else { | ||
img = (<img src={this.props.source} />); | ||
} | ||
} | ||
|
||
return ( | ||
<picture className={classNames}>{img}</picture> | ||
); | ||
} | ||
|
||
onOptimizedImageError() { | ||
// This will trigger a re-render and the unoptimized 450px image will be used as a fallback | ||
this.setState({ | ||
optimizedImageFailed: 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 | ||
}; |
13 changes: 13 additions & 0 deletions
13
content-src/components/DiscoveryStreamComponents/DSImage/DSImage.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
.ds-image { | ||
display: block; | ||
position: relative; | ||
|
||
img { | ||
background-color: var(--newtab-card-placeholder-color); | ||
position: absolute; | ||
top: 0; | ||
width: 100%; | ||
height: 100%; | ||
object-fit: cover; | ||
} | ||
} |
28 changes: 28 additions & 0 deletions
28
content-src/components/DiscoveryStreamComponents/DSImage/cache.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
// This cache is for tracking queued image source requests from DSImage instances and leveraging | ||
// larger sizes of images to scale down in the browser instead of making additional | ||
// requests for smaller sizes from the server. | ||
|
||
let cache = { | ||
query(url, size, set) { | ||
// Create an empty set if none exists | ||
// Need multiple cache sets because the browser decides what set to use based on pixel density | ||
if (this.queuedImages[set] === undefined) { | ||
this.queuedImages[set] = {}; | ||
} | ||
|
||
let sizeToRequest; // The px width to request from Thumbor via query value | ||
|
||
if (!this.queuedImages[set][url] || this.queuedImages[set][url] < size) { | ||
this.queuedImages[set][url] = size; | ||
sizeToRequest = size; | ||
} else { | ||
// Use the larger size already queued for download (and allow browser to scale down) | ||
sizeToRequest = this.queuedImages[set][url]; | ||
} | ||
|
||
return sizeToRequest; | ||
}, | ||
queuedImages: {}, | ||
}; | ||
|
||
export {cache}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import {cache} from "content-src/components/DiscoveryStreamComponents/DSImage/cache.js"; | ||
|
||
describe("ImgCache", () => { | ||
beforeEach(() => { | ||
// Wipe cache | ||
cache.queuedImages = {}; | ||
}); | ||
|
||
describe("group", () => { | ||
it("should create multiple cache sets", () => { | ||
cache.query(`http://example.org/a.jpg`, 500, `1x`); | ||
cache.query(`http://example.org/a.jpg`, 1000, `2x`); | ||
|
||
assert.equal(typeof cache.queuedImages[`1x`], `object`); | ||
assert.equal(typeof cache.queuedImages[`2x`], `object`); | ||
}); | ||
|
||
it("should cache larger values", () => { | ||
cache.query(`http://example.org/a.jpg`, 200, `1x`); | ||
cache.query(`http://example.org/a.jpg`, 500, `1x`); | ||
|
||
assert.equal(cache.queuedImages[`1x`][`http://example.org/a.jpg`], 500); | ||
}); | ||
|
||
it("should return a larger resolution if already cached", () => { | ||
cache.query(`http://example.org/a.jpg`, 500, `1x`); | ||
|
||
let result = cache.query(`http://example.org/a.jpg`, 200, `1x`); | ||
|
||
assert.equal(result, 500); | ||
}); | ||
}); | ||
}); |
35 changes: 35 additions & 0 deletions
35
test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import {DSImage} from "content-src/components/DiscoveryStreamComponents/DSImage/DSImage"; | ||
import {mount} from "enzyme"; | ||
import React from "react"; | ||
|
||
describe("Discovery Stream <DSImage>", () => { | ||
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" />); | ||
|
||
img.setState({ | ||
isSeen: true, | ||
containerWidth: 640, | ||
}); | ||
|
||
assert.equal(img.find("img").prop("src"), "https://img-getpocket.cdn.mozilla.net/640x0/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480"); | ||
assert.equal(img.find("img").prop("srcSet"), "https://img-getpocket.cdn.mozilla.net/1280x0/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 2x"); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters