Skip to content

Commit

Permalink
fix(gatsby-plugin-image): image partial rendering (#30221)
Browse files Browse the repository at this point in the history
* fix srcset in picture tag to make sure we control loading

* SAVEPOINT

* add ref

* this is a key but it's not unique, we don't have an id

* use cachekey

* this makes way more sense

* Update packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx

* update test

* order matters

* mostly there, still need to bail out when image isn't available

* fix the warnings

* we shouldn't need to do this, but still verifying

* remove console log

* address comments

* Update packages/gatsby-plugin-image/src/components/layout-wrapper.tsx

Co-authored-by: Ward Peeters <ward@coding-tech.com>

* Update packages/gatsby-plugin-image/src/components/layout-wrapper.tsx

Co-authored-by: Ward Peeters <ward@coding-tech.com>

* Update packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx

Co-authored-by: Ward Peeters <ward@coding-tech.com>

* eslint ignore needs to follow comments

Co-authored-by: Ward Peeters <ward@coding-tech.com>
  • Loading branch information
LB and wardpeet committed Mar 19, 2021
1 parent 3c2888c commit d97a086
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 175 deletions.
339 changes: 202 additions & 137 deletions packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
/* eslint-disable no-unused-expressions */
import React, {
Component,
ElementType,
useEffect,
useRef,
createRef,
MutableRefObject,
FunctionComponent,
ImgHTMLAttributes,
useState,
RefObject,
CSSProperties,
} from "react"
import {
getWrapperProps,
hasNativeLazyLoadSupport,
storeImageloaded,
hasImageLoaded,
} from "./hooks"
import { PlaceholderProps } from "./placeholder"
import { MainImageProps } from "./main-image"
import { Layout } from "../image-utils"
import { getSizer } from "./layout-wrapper"
import { propTypes } from "./gatsby-image.server"
import { Unobserver } from "./intersection-observer"
import { render } from "react-dom"

// eslint-disable-next-line @typescript-eslint/naming-convention
export interface GatsbyImageProps
Expand Down Expand Up @@ -50,171 +53,233 @@ export interface IGatsbyImageData {
placeholder?: Pick<PlaceholderProps, "sources" | "fallback">
}

let hasShownWarning = false

export const GatsbyImageHydrator: FunctionComponent<GatsbyImageProps> = function GatsbyImageHydrator({
as: Type = `div`,
style,
className,
class: preactClass,
onStartLoad,
image,
onLoad: customOnLoad,
backgroundColor,
loading = `lazy`,
...props
}) {
if (!image) {
if (process.env.NODE_ENV === `development`) {
console.warn(`[gatsby-plugin-image] Missing image prop`)
class GatsbyImageHydrator extends Component<
GatsbyImageProps,
{ isLoading: boolean; isLoaded: boolean }
> {
root: RefObject<HTMLImageElement | undefined> = createRef<
HTMLImageElement | undefined
>()
hydrated: MutableRefObject<boolean> = { current: false }
lazyHydrator: () => void | null = null
ref = createRef<HTMLImageElement>()
unobserveRef: Unobserver

constructor(props) {
super(props)

this.state = {
isLoading: hasNativeLazyLoadSupport(),
isLoaded: false,
}
return null
}
if (preactClass) {
className = preactClass
}
const { width, height, layout, images } = image

const root = useRef<HTMLElement>()
const hydrated = useRef(false)
const unobserveRef = useRef<
((element: RefObject<HTMLElement | undefined>) => void) | null
>(null)
const lazyHydrator = useRef<(() => void) | null>(null)
const ref = useRef<HTMLImageElement | undefined>()
const [isLoading, toggleIsLoading] = useState(hasNativeLazyLoadSupport())
const [isLoaded, toggleIsLoaded] = useState(false)

if (!global.GATSBY___IMAGE && !hasShownWarning) {
hasShownWarning = true
console.warn(
`[gatsby-plugin-image] You're missing out on some cool performance features. Please add "gatsby-plugin-image" to your gatsby-config.js`

_lazyHydrate(props, state): Promise<void> {
const hasSSRHtml = this.root.current.querySelector(
`[data-gatsby-image-ssr]`
)
// On first server hydration do nothing
if (hasNativeLazyLoadSupport() && hasSSRHtml && !this.hydrated.current) {
this.hydrated.current = true
return Promise.resolve()
}

return import(`./lazy-hydrate`).then(({ lazyHydrate }) => {
this.lazyHydrator = lazyHydrate(
{
image: props.image.images,
isLoading: state.isLoading,
isLoaded: state.isLoaded,
toggleIsLoaded: () => {
props.onLoad?.()

this.setState({
isLoaded: true,
})
},
ref: this.ref,
...props,
},
this.root,
this.hydrated
)
})
}

const { style: wStyle, className: wClass, ...wrapperProps } = getWrapperProps(
width,
height,
layout
)
/**
* Choose if setupIntersectionObserver should use the image cache or not.
*/
_setupIntersectionObserver(useCache = true): void {
import(`./intersection-observer`).then(({ createIntersectionObserver }) => {
const intersectionObserver = createIntersectionObserver(() => {
if (this.root.current) {
const cacheKey = JSON.stringify(this.props.image.images)
this.props.onStartLoad?.({
wasCached: useCache && hasImageLoaded(cacheKey),
})
this.setState({
isLoading: true,
isLoaded: useCache && hasImageLoaded(cacheKey),
})
}
})

if (this.root.current) {
this.unobserveRef = intersectionObserver(this.root)
}
})
}

shouldComponentUpdate(nextProps, nextState): boolean {
let hasChanged = false

// this check mostly means people do not have the correct ref checks in place, we want to reset some state to suppport loading effects
if (this.props.image.images !== nextProps.image.images) {
// reset state, we'll rely on intersection observer to reload
if (this.unobserveRef) {
// unregister intersectionObserver
this.unobserveRef()

// // on unmount, make sure we cleanup
if (this.hydrated.current && this.lazyHydrator) {
render(null, this.root.current)
}
}

this.setState(
{
isLoading: false,
isLoaded: false,
},
() => {
this._setupIntersectionObserver(false)
}
)

hasChanged = true
}

if (this.root.current && !hasChanged) {
this._lazyHydrate(nextProps, nextState)
}

return false
}

useEffect((): (() => void) | undefined => {
if (root.current) {
const hasSSRHtml = root.current.querySelector(
componentDidMount(): void {
if (this.root.current) {
const ssrElement = this.root.current.querySelector(
`[data-gatsby-image-ssr]`
) as HTMLImageElement
const cacheKey = JSON.stringify(this.props.image.images)

// when SSR and native lazyload is supported we'll do nothing ;)
if (hasNativeLazyLoadSupport() && hasSSRHtml && global.GATSBY___IMAGE) {
onStartLoad?.({ wasCached: false })
if (hasNativeLazyLoadSupport() && ssrElement && global.GATSBY___IMAGE) {
this.props.onStartLoad?.({ wasCached: false })

if (hasSSRHtml.complete) {
customOnLoad?.()
storeImageloaded(JSON.stringify(images))
// When the image is already loaded before we have hydrated, we trigger onLoad and cache the item
if (ssrElement.complete) {
this.props.onLoad?.()
storeImageloaded(cacheKey)
} else {
hasSSRHtml.addEventListener(`load`, function onLoad() {
hasSSRHtml.removeEventListener(`load`, onLoad)
// We need the current class context (this) inside our named onLoad function
// The named function is necessary to easily remove the listener afterward.
// eslint-disable-next-line @typescript-eslint/no-this-alias
const _this = this
// add an onLoad to the image
ssrElement.addEventListener(`load`, function onLoad() {
ssrElement.removeEventListener(`load`, onLoad)

customOnLoad?.()
storeImageloaded(JSON.stringify(images))
_this.props.onLoad?.()
storeImageloaded(cacheKey)
})
}
return undefined

return
}

// Fallback to custom lazy loading (intersection observer)
import(`./intersection-observer`).then(
({ createIntersectionObserver }) => {
const intersectionObserver = createIntersectionObserver(() => {
if (root.current) {
onStartLoad?.({ wasCached: false })
toggleIsLoading(true)
}
})

if (root.current) {
unobserveRef.current = intersectionObserver(root)
}
}
)
this._setupIntersectionObserver(true)
}
}

return (): void => {
if (unobserveRef.current) {
unobserveRef.current(root)
componentWillUnmount(): void {
// Cleanup when onmount happens
if (this.unobserveRef) {
// unregister intersectionObserver
this.unobserveRef()

// on unmount, make sure we cleanup
if (hydrated.current && lazyHydrator.current) {
lazyHydrator.current()
}
// on unmount, make sure we cleanup
if (this.hydrated.current && this.lazyHydrator) {
this.lazyHydrator()
}
}
}, [])

useEffect(() => {
if (root.current) {
const hasSSRHtml = root.current.querySelector(`[data-gatsby-image-ssr]`)
// On first server hydration do nothing
if (hasNativeLazyLoadSupport() && hasSSRHtml && !hydrated.current) {
hydrated.current = true
return
}

import(`./lazy-hydrate`).then(({ lazyHydrate }) => {
lazyHydrator.current = lazyHydrate(
{
image,
isLoading,
isLoaded,
toggleIsLoaded: () => {
customOnLoad?.()
toggleIsLoaded(true)
},
ref,
loading,
...props,
},
root,
hydrated
)
})
return
}

render(): JSX.Element {
const Type = this.props.as || `div`
const { width, height, layout } = this.props.image
const {
style: wStyle,
className: wClass,
...wrapperProps
} = getWrapperProps(width, height, layout)

let className = this.props.className
// preact class
if (this.props.class) {
className = this.props.class
}
}, [
width,
height,
layout,
images,
isLoading,
isLoaded,
toggleIsLoaded,
ref,
props,
])

const sizer = getSizer(layout, width, height)

return (
<Type
{...wrapperProps}
style={{
...wStyle,
...style,
backgroundColor,
}}
className={`${wClass}${className ? ` ${className}` : ``}`}
ref={root}
dangerouslySetInnerHTML={{
__html: sizer,
}}
suppressHydrationWarning
/>
)
const sizer = getSizer(layout, width, height)

return (
<Type
{...wrapperProps}
style={{
...wStyle,
...this.props.style,
backgroundColor: this.props.backgroundColor,
}}
className={`${wClass}${className ? ` ${className}` : ``}`}
ref={this.root}
dangerouslySetInnerHTML={{
__html: sizer,
}}
suppressHydrationWarning
/>
)
}
}

export const GatsbyImage: FunctionComponent<GatsbyImageProps> = function GatsbyImage(
props
) {
return <GatsbyImageHydrator {...props} />
if (!props.image) {
if (process.env.NODE_ENV === `development`) {
console.warn(`[gatsby-plugin-image] Missing image prop`)
}
return null
}

if (!global.GATSBY___IMAGE) {
console.warn(
`[gatsby-plugin-image] You're missing out on some cool performance features. Please add "gatsby-plugin-image" to your gatsby-config.js`
)
}
const { className, class: classSafe, backgroundColor, image } = props
const { width, height, layout } = image
const propsKey = JSON.stringify([
width,
height,
layout,
className,
classSafe,
backgroundColor,
])
return <GatsbyImageHydrator key={propsKey} {...props} />
}

GatsbyImage.propTypes = propTypes
Expand Down

0 comments on commit d97a086

Please sign in to comment.