Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(gatsby-image): React hydration buster on image #24811

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9751de6
fix(gatsby-image): React hydration buster on image aspectRatio
Jun 5, 2020
7c1c432
chore: Appease the CI
Jun 5, 2020
420153c
fix(gatsby-image): React hydration buster on entire image object
Jun 6, 2020
56f0578
fix(gatsby-image): Use conditional as render guard
polarathene Jul 5, 2020
6f9e706
feat(gatsby-image): Art Direction - Embed MediaQuery styles
Jul 5, 2020
da56adc
chore: Remove "optimization", label magic numbers
Jul 6, 2020
f839c30
chore: Fix incorrect prop for `<ResponsiveQueries />`
Jul 6, 2020
5a922b3
chore: Update `gatsby-image` snapshot
Jul 6, 2020
ada3420
chore: Conditionally create `uniqueKey` classname
Jul 6, 2020
1f97459
chore: Revert `gatsby-image` snapshot
Jul 6, 2020
f6bacc2
fix: fnv-1a hash algorithm was inaccurate due to JS number caveats
Jul 6, 2020
352e3e7
feat: Switch base64 to base36
Jul 6, 2020
9270c41
feat: Use DJB2-xor instead of FNV-1a-32
Jul 7, 2020
80e3e85
chore: Minor changes
Jul 11, 2020
7b56472
chore: Refactor render guard
Jul 12, 2020
dd05e60
fiix: e2e gatsby-image tests
polarathene Jul 13, 2020
becc8bc
chore : Refactor ResponsiveQueries component
polarathene Jul 13, 2020
f232519
chore: Relocate `uniqueKey`
polarathene Jul 13, 2020
b35e4f6
feat: Proper handling for image variant transitions
polarathene Jul 13, 2020
d62bdb8
fix: Jest tests better `matchMedia()` mock
polarathene Jul 13, 2020
ebcb8f8
chore: Minor change to re-run CI
polarathene Jul 14, 2020
f39122a
chore: `handleVariantChange()` should set state based on `seenBefore`
polarathene Jul 29, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e-tests/gatsby-image/cypress/integration/fluid.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe(`fluid`, () => {
cy.getTestElement(fluidTestId)
.find(`.gatsby-image-wrapper > div`)
.should(`have.attr`, `style`)
.and(`match`, /width:100%;padding-bottom/)
.and(`match`, /width: 100%; padding-bottom/)
})

it(`renders sizes`, () => {
Expand Down
4 changes: 2 additions & 2 deletions e2e-tests/gatsby-image/cypress/integration/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ describe(`Production gatsby-image`, () => {
.should(`eq`, 1)
})

it(`contains position relative`, () => {
it(`has position relative`, () => {
cy.getTestElement(fluidTestId)
.find(`.gatsby-image-wrapper`)
.should(`have.attr`, `style`)
.and(`contains`, `position:relative`)
.and(`match`, /position: relative/)
})
})
})
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"jest-cli": "^24.9.0",
"jest-environment-jsdom-fourteen": "^0.1.0",
"jest-junit": "^10.0.0",
"jest-matchmedia-mock": "^1.0.1",
"jest-serializer-path": "^0.1.15",
"jest-silent-reporter": "^0.2.1",
"joi": "^14.3.1",
Expand Down
24 changes: 11 additions & 13 deletions packages/gatsby-image/src/__tests__/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import MatchMediaMock from "jest-matchmedia-mock"
import React from "react"
import { render, cleanup, fireEvent } from "@testing-library/react"
import Image from "../"
Expand Down Expand Up @@ -117,22 +118,13 @@ const setupImages = (
return container
}

let matchMedia
describe(`<Image />`, () => {
const OLD_MATCH_MEDIA = window.matchMedia
beforeEach(() => {
// None of the media conditions above match.
window.matchMedia = jest.fn(media =>
media === `only screen and (min-width: 1024px)`
? {
matches: true,
}
: {
matches: false,
}
)
beforeAll(() => {
matchMedia = new MatchMediaMock()
})
afterEach(() => {
window.matchMedia = OLD_MATCH_MEDIA
matchMedia.clear()
})

it(`should render fixed size images`, () => {
Expand Down Expand Up @@ -221,6 +213,9 @@ describe(`<Image />`, () => {
})

it(`should select the correct mocked image of fluid variants provided.`, () => {
const mediaQuery = `only screen and (min-width: 1024px)`
matchMedia.useMediaQuery(mediaQuery)

const tripleFluidImageShapeMock = fluidImagesShapeMock.concat({
aspectRatio: 5,
src: `test_image_4.jpg`,
Expand Down Expand Up @@ -251,6 +246,9 @@ describe(`<Image />`, () => {
})

it(`should select the correct mocked image of fixed variants provided.`, () => {
const mediaQuery = `only screen and (min-width: 1024px)`
matchMedia.useMediaQuery(mediaQuery)

const tripleFixedImageShapeMock = fixedImagesShapeMock.concat({
width: 1024,
height: 768,
Expand Down
142 changes: 129 additions & 13 deletions packages/gatsby-image/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ const logDeprecationNotice = (prop, replacement) => {
}
}

// DJB2a (XOR variant) is a simple hashing function, reduces input to 32-bits
const SEED = 5381
function djb2a(str) {
let h = SEED

for (let i = 0; i < str.length; i++) {
h = (h * 33) ^ str.charCodeAt(i)
}

// Cast to 32-bit uint
return h >>> 0
}

// Converts string input to a 32-bit base36 string (0-9, a-z)
// '_' prefix to prevent invalid first chars for CSS class names
const getShortKey = input => `_` + djb2a(input).toString(36)

// Handle legacy props during their deprecation phase
const convertProps = props => {
let convertedProps = { ...props }
Expand Down Expand Up @@ -54,7 +71,7 @@ const convertProps = props => {
* @param currentData {{media?: string}[]} The props to check for images.
* @return {boolean}
*/
const hasArtDirectionSupport = currentData =>
const isUsingArtDirection = currentData =>
!!currentData &&
Array.isArray(currentData) &&
currentData.some(image => typeof image.media !== `undefined`)
Expand Down Expand Up @@ -85,7 +102,7 @@ const getImageSrcKey = ({ fluid, fixed }) => {
* @return {{src: string, media?: string, maxWidth?: Number, maxHeight?: Number}}
*/
const getCurrentSrcData = currentData => {
if (isBrowser && hasArtDirectionSupport(currentData)) {
if (isBrowser && isUsingArtDirection(currentData)) {
// Do we have an image for the current Viewport?
const foundMedia = currentData.findIndex(matchesMedia)
if (foundMedia !== -1) {
Expand Down Expand Up @@ -193,6 +210,32 @@ function groupByMedia(imageVariants) {
return [...withMedia, ...without]
}

// For Art Direction: SSR + Initial load, removed upon rehydration
// Ensures correct CSS for image variants is applied
const ResponsiveQueries = ({ imageVariants, selectorClass }) => {
const variantRule = ({ width, height, aspectRatio }) => {
// '!important' required due to overriding inline styles
// 'aspectRatio' == fluid data
const styleOverride = aspectRatio
? `padding-bottom: ${100 / aspectRatio}% !important;`
: `width: ${width}px !important; height: ${height}px !important;`

return `.${selectorClass} { ${styleOverride} }`
}

const base = variantRule(imageVariants.find(v => !v.media))
return (
<>
{base && <style>{base}</style>}
{imageVariants
.filter(v => v.media)
.map(v => (
<style media={v.media}>{variantRule(v)}</style>
))}
</>
)
}

function generateTracedSVGSources(imageVariants) {
return imageVariants.map(({ src, media, tracedSVG }) => (
<source key={src} media={media} srcSet={tracedSVG} />
Expand Down Expand Up @@ -352,15 +395,48 @@ class Image extends React.Component {
imgLoaded: false,
imgCached: false,
fadeIn: !this.seenBefore && props.fadeIn,
isHydrated: false,
}

this.imageRef = React.createRef()
this.placeholderRef = props.placeholderRef || React.createRef()
this.handleImageLoaded = this.handleImageLoaded.bind(this)
this.handleRef = this.handleRef.bind(this)

// Supports Art Direction feature to correctly render image variants
const imageVariants = props.fluid || props.fixed
if (isUsingArtDirection(imageVariants)) {
this.uniqueKey =
!isBrowser && getShortKey(imageVariants.map(v => v.srcSet).join(``))

// When image variant changes, adapt via render immediately instead of delaying
// until new image request triggers 'onload' event.
// Can be verified on 'slow 3G' with no initial cache.
if (isBrowser) {
this.handleVariantChange = this.handleVariantChange.bind(this)
const base = imageVariants.find(v => !v.media)

this.mq = { base }
this.mq.queries = imageVariants.filter(v => v.media)
this.mq.queries.forEach(v => {
this.mq[v.media] = v
const mql = window.matchMedia(v.media)
if (mql.addEventListener) {
mql.addEventListener(`change`, this.handleVariantChange)
} else {
// Deprecated 'MediaQueryList' API, <Safari 14, IE, <Edge 16
mql.addListener(this.handleVariantChange)
}
})
}
}
}

componentDidMount() {
this.setState({
isHydrated: isBrowser,
})

if (this.state.isVisible && typeof this.props.onStartLoad === `function`) {
this.props.onStartLoad({ wasCached: inImageCache(this.props) })
}
Expand Down Expand Up @@ -413,13 +489,31 @@ class Image extends React.Component {
handleImageLoaded() {
activateCacheForImage(this.props)

this.setState({ imgLoaded: true })
if (!this.state.imgLoaded) {
this.setState({ imgLoaded: true })
}

if (this.props.onLoad) {
this.props.onLoad()
}
}

// 'window.matchMedia()' events on image variants media conditions.
// If variant is not in image cache when triggered, resets the image
// loading state so that the placeholder is visible instead of blank.
handleVariantChange(e) {
const match =
(e.matches && this.mq[e.media]) ||
this.mq.queries.find(matchesMedia) ||
this.mq.base

this.seenBefore = imageCache[match.src]
this.setState({
imgLoaded: this.seenBefore,
imgCached: this.seenBefore,
})
}

render() {
const {
title,
Expand All @@ -439,6 +533,12 @@ class Image extends React.Component {
draggable,
} = convertProps(this.props)

// Avoid render logic on client until component mounts (hydration complete)
// Prevents using mismatched initial state from the hydration phase
if (isBrowser && !this.state.isHydrated) {
return null
}

const shouldReveal = this.state.fadeIn === false || this.state.imgLoaded
const shouldFadeIn = this.state.fadeIn === true && !this.state.imgCached

Expand Down Expand Up @@ -470,13 +570,18 @@ class Image extends React.Component {
itemProp,
}

if (fluid) {
const imageVariants = fluid
const image = getCurrentSrcData(fluid)
const imageVariants = fluid || fixed
const image = getCurrentSrcData(imageVariants)

const activeVariant = getShortKey(image.srcSet)
const uniqueKey = this.uniqueKey

if (fluid) {
return (
<Tag
className={`${className ? className : ``} gatsby-image-wrapper`}
className={`${
(className ? className : ``) + (uniqueKey ? uniqueKey : ``)
} gatsby-image-wrapper`}
style={{
position: `relative`,
overflow: `hidden`,
Expand All @@ -485,8 +590,14 @@ class Image extends React.Component {
...style,
}}
ref={this.handleRef}
key={`fluid-${JSON.stringify(image.srcSet)}`}
key={activeVariant}
>
{uniqueKey && (
<ResponsiveQueries
imageVariants={imageVariants}
selectorClass={uniqueKey}
/>
)}
{/* Preserve the aspect ratio. */}
<Tag
aria-hidden
Expand Down Expand Up @@ -579,9 +690,6 @@ class Image extends React.Component {
}

if (fixed) {
const imageVariants = fixed
const image = getCurrentSrcData(fixed)

const divStyle = {
position: `relative`,
overflow: `hidden`,
Expand All @@ -597,11 +705,19 @@ class Image extends React.Component {

return (
<Tag
className={`${className ? className : ``} gatsby-image-wrapper`}
className={`${
(className ? className : ``) + (uniqueKey ? uniqueKey : ``)
} gatsby-image-wrapper`}
style={divStyle}
ref={this.handleRef}
key={`fixed-${JSON.stringify(image.srcSet)}`}
key={activeVariant}
>
{uniqueKey && (
<ResponsiveQueries
imageVariants={imageVariants}
selectorClass={uniqueKey}
/>
)}
{/* Show a solid background color. */}
{bgColor && (
<Tag
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13458,6 +13458,11 @@ jest-matcher-utils@^24.9.0:
jest-get-type "^24.9.0"
pretty-format "^24.9.0"

jest-matchmedia-mock@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/jest-matchmedia-mock/-/jest-matchmedia-mock-1.0.1.tgz#da1c75be82a3b3e46e6c8ae195aa54e4a6032eb4"
integrity sha512-IXk2AnldfsCRmhB4pFY0Eb9mk2NxFqOgOwA4DxKOJ22T5bn/j3DzM5s3cdKxuEowmpuXK1gPXAIlVGIC+kb/fA==

jest-message-util@^22.4.0, jest-message-util@^22.4.3:
version "22.4.3"
resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-22.4.3.tgz#cf3d38aafe4befddbfc455e57d65d5239e399eb7"
Expand Down