diff --git a/e2e-tests/contentful/cypress/integration/rich-text.js b/e2e-tests/contentful/cypress/integration/rich-text.js index 9ed1c2a2f50cb..f548bd80fe301 100644 --- a/e2e-tests/contentful/cypress/integration/rich-text.js +++ b/e2e-tests/contentful/cypress/integration/rich-text.js @@ -14,6 +14,7 @@ function testWithGatsbyPluginImage(elem) { const cleanHtml = html .replace(base64ImageExp, `data:image/redacted;base64,redacted`) .replace(styleAttrExp, ``) + .replace(/data-gatsby-image-ssr=\"\"/gm, "") // Create a DOM element with the redacted base64 data cy.document().then(document => { diff --git a/e2e-tests/contentful/package.json b/e2e-tests/contentful/package.json index 47f1d489baf6a..aaf2a8d67e2f9 100644 --- a/e2e-tests/contentful/package.json +++ b/e2e-tests/contentful/package.json @@ -5,22 +5,30 @@ "author": "Kyle Mathews ", "dependencies": { "@contentful/rich-text-types": "^14.1.2", - "cypress": "^6.8.0", - "cypress-image-snapshot": "^4.0.1", - "gatsby": "^3.1.1", - "gatsby-image": "^3.3.0", - "gatsby-plugin-image": "^1.3.1", - "gatsby-plugin-sharp": "^3.1.2", - "gatsby-source-contentful": "^5.1.1", - "gatsby-transformer-remark": "^4.0.0", - "gatsby-transformer-sharp": "^3.3.0", - "gatsby-transformer-sqip": "3.3.1", + "gatsby": "next", + "gatsby-image": "next", + "gatsby-plugin-image": "next", + "gatsby-plugin-sharp": "next", + "gatsby-source-contentful": "next", + "gatsby-transformer-remark": "next", + "gatsby-transformer-sharp": "next", + "gatsby-transformer-sqip": "next", "modern-normalize": "^1.0.0", "prop-types": "^15.7.2", "react": "^17.0.2", "react-dom": "^17.0.2", "slugify": "^1.5.0" }, + "devDependencies": { + "@cypress/snapshot": "^2.1.7", + "cross-env": "^7.0.3", + "cypress": "^9.5.4", + "cypress-image-snapshot": "^4.0.1", + "gatsby-cypress": "latest", + "prettier": "^2.6.2", + "srcset": "^5.0.0", + "start-server-and-test": "^1.7.1" + }, "keywords": [ "gatsby" ], @@ -35,17 +43,8 @@ "cy:open": "cypress open", "cy:run": "node ../../scripts/cypress-run-with-conditional-record-flag.js --browser chrome" }, - "devDependencies": { - "@cypress/snapshot": "^2.1.7", - "cross-env": "^7.0.3", - "gatsby-cypress": "^1.3.0", - "is-ci": "^3.0.0", - "prettier": "2.2.1", - "srcset": "^5.0.0", - "start-server-and-test": "^1.7.1" - }, "repository": { "type": "git", "url": "https://github.com/gatsbyjs/gatsby-starter-default" } -} +} \ No newline at end of file diff --git a/e2e-tests/contentful/snapshots.js b/e2e-tests/contentful/snapshots.js index 3cb165766826f..85def40e4cab3 100644 --- a/e2e-tests/contentful/snapshots.js +++ b/e2e-tests/contentful/snapshots.js @@ -1,5 +1,5 @@ module.exports = { - "__version": "6.9.1", + "__version": "9.5.4", "content-reference": { "content-reference-many-2nd-level-loop": { "1": "
\n

Content Reference: Many (2nd level loop)

\n

[ContentfulNumber]\n 42

\n

[ContentfulText]\n The quick brown fox jumps over the lazy dog.

\n

[ContentfulReference]\n Content Reference: One (Loop A -> B)\n : [\n Content Reference: One (Loop B -> A)\n ]

\n
" @@ -28,7 +28,7 @@ module.exports = { }, "rich-text": { "rich-text: All Features": { - "1": "
\n

Rich Text: All Features

\n

The European languages

\n

are members of the same family. Their separate existence is a myth. For:

\n \n

Europe uses the same vocabulary.

\n
\n
\"\"\n\n
\n
\n \n \n \"\"\n\n \n \n
\n

\n
\n

The languages only differ in:

\n
    \n
  1. \n

    their grammar

    \n
  2. \n
  3. \n

    their pronunciation

    \n
  4. \n
  5. \n

    their most common words

    \n
  6. \n
  7. \n

    [Inline-ContentfulText]\n Text: Short\n :\n The quick brown fox jumps over the lazy dog.

    \n
  8. \n
\n

Everyone realizes why a new common language would be desirable: one could\n refuse to pay expensive translators.

\n

{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"delectus aut autem\",\n \"completed\": false\n }

\n

To achieve this, it would be necessary to have uniform grammar,\n pronunciation and more common words.

\n

[ContentfulLocation] Lat:\n 52.51627\n , Long:\n 13.3777

\n
\n

If several languages coalesce, the grammar of the resulting language is\n more simple and regular than that of the individual languages.

\n
\n

The new common language will be more simple and regular than the existing\n European languages. It will be as simple as Occidental; in fact, it will be\n

\n
\n
" + "1": "
\n

Rich Text: All Features

\n

The European languages

\n

are members of the same family. Their separate existence is a myth. For:

\n \n

Europe uses the same vocabulary.

\n
\n
\"\"\n\n
\n
\n \n \n \"\"\n\n \n \n
\n

\n
\n

The languages only differ in:

\n
    \n
  1. \n

    their grammar

    \n
  2. \n
  3. \n

    their pronunciation

    \n
  4. \n
  5. \n

    their most common words

    \n
  6. \n
  7. \n

    [Inline-ContentfulText]\n Text: Short\n :\n The quick brown fox jumps over the lazy dog.

    \n
  8. \n
\n

Everyone realizes why a new common language would be desirable: one could\n refuse to pay expensive translators.

\n

{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"delectus aut autem\",\n \"completed\": false\n }

\n

To achieve this, it would be necessary to have uniform grammar,\n pronunciation and more common words.

\n

[ContentfulLocation] Lat:\n 52.51627\n , Long:\n 13.3777

\n
\n

If several languages coalesce, the grammar of the resulting language is\n more simple and regular than that of the individual languages.

\n
\n

The new common language will be more simple and regular than the existing\n European languages. It will be as simple as Occidental; in fact, it will be\n

\n
\n
" }, "rich-text: Basic": { "1": "
\n

Rich Text: Basic

\n

The European languages

\n

are members of the same family. Their separate existence is a myth. For:

\n \n

Europe uses the same vocabulary.

\n
\n

The languages only differ in:

\n
    \n
  1. \n

    their grammar

    \n
  2. \n
  3. \n

    their pronunciation

    \n
  4. \n
  5. \n

    their most common words

    \n
  6. \n
\n

Everyone realizes why a new common language would be desirable: one could\n refuse to pay expensive translators.

\n

{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"delectus aut autem\",\n \"completed\": false\n }

\n

To achieve this, it would be necessary to have uniform grammar,\n pronunciation and more common words.

\n
\n

If several languages coalesce, the grammar of the resulting language is\n more simple and regular than that of the individual languages.

\n
\n

The new common language will be more simple and regular than the existing\n European languages. It will be as simple as Occidental; in fact, it will be\n

\n
\n
" @@ -37,7 +37,7 @@ module.exports = { "1": "
\n

Rich Text: Embedded Entry

\n

Embedded Entry

\n

[ContentfulText]\n The quick brown fox jumps over the lazy dog.

\n

\n

\n
\n
" }, "rich-text: Embedded Asset": { - "1": "
\n

Rich Text: Embedded asset

\n

Embedded Asset

\n
\n
\"\"\n\n
\n
\n \n \n \"\"\n\n \n \n
\n

\n

\n

\n
\n
" + "1": "
\n

Rich Text: Embedded asset

\n

Embedded Asset

\n
\n
\"\"\n\n
\n
\n \n \n \"\"\n\n \n \n
\n

\n

\n

\n
\n
" }, "rich-text: Embedded Entry With Deep Reference Loop": { "1": "
\n

Rich Text: Embedded entry with deep reference loop

\n

Embedded entry with deep reference loop

\n

[ContentfulReference]\n Content Reference: Many (2nd level loop)\n : [\n Number: Integer, Text: Short, Content Reference: One (Loop A ->\n B)\n ]

\n

\n

\n
\n
" diff --git a/e2e-tests/production-runtime/cypress/integration/gatsby-plugin-image.js b/e2e-tests/production-runtime/cypress/integration/gatsby-plugin-image.js new file mode 100644 index 0000000000000..06415697e49b6 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/gatsby-plugin-image.js @@ -0,0 +1,190 @@ +const MutationObserver = + window.MutationObserver || window.WebKitMutationObserver + +// helper function to observe DOM changes +function observeDOM(obj, options, callback) { + if (!obj || obj.nodeType !== window.Node.ELEMENT_NODE) { + throw new Error("can not observe this element") + } + + const obs = new MutationObserver(callback) + + obs.observe(obj, { childList: true, subtree: true, ...options }) + + return () => { + obs.disconnect() + } +} + +describe(`gatsby-plugin-image`, () => { + it(`doesn't recycle image nodes when not necessary`, () => { + const mutationStub = cy.stub() + let cleanup + + cy.visit(`/gatsby-plugin-image-page-1/`) + + // wait for the image to load + cy.get("[data-main-image]").should("be.visible") + + // start watching mutations in the image-wrapper + cy.get("body").then($element => { + cleanup = observeDOM($element[0], {}, mutations => { + const normalizedMutations = [] + mutations.forEach(mutation => { + if ( + mutation.type === "childList" && + mutation.target.classList.contains("gatsby-image-wrapper") + ) { + normalizedMutations.push({ + type: mutation.type, + addedNodes: !!mutation.addedNodes.length, + removedNodes: !!mutation.removedNodes.length, + }) + } + }) + + if (normalizedMutations.length) { + mutationStub(normalizedMutations) + } + }) + }) + + cy.window() + .then(win => win.___navigate(`/gatsby-plugin-image-page-2/`)) + .waitForRouteChange() + + // wait for the image to load + cy.get("[data-main-image]").should("be.visible") + cy.wait(500) + + cy.then(() => { + cleanup() + expect(mutationStub).to.be.calledWith([ + { + type: "childList", + addedNodes: true, + removedNodes: false, + }, + ]) + }) + }) + + it(`rerenders when image src changed`, () => { + const mutationStub = cy.stub() + let cleanup + + cy.visit(`/gatsby-plugin-image-page-1/`) + + // wait for the image to load + cy.get("[data-main-image]").should("be.visible") + + // start watching mutations in the image-wrapper + cy.get("#image-wrapper").then($element => { + cleanup = observeDOM($element[0], {}, mutations => { + const normalizedMutations = [] + mutationStub( + mutations.map(mutation => { + normalizedMutations.push({ + addedNodes: mutation.addedNodes, + removedNodes: mutation.removedNodes, + }) + + return { + type: mutation.type, + addedNodes: !!mutation.addedNodes.length, + removedNodes: !!mutation.removedNodes.length, + } + }) + ) + + Cypress.log({ + name: "MutationObserver", + message: `${normalizedMutations.length} mutations`, + consoleProps: () => { + return { + mutations: normalizedMutations, + } + }, + }) + }) + }) + + cy.get("#click").click() + + cy.wait(500) + + cy.get("[data-main-image]", { + timeout: 5000, + }).should("be.visible") + + cy.then(() => { + cleanup() + expect(mutationStub).to.be.calledOnce + expect(mutationStub).to.be.calledWith([ + { + type: "childList", + addedNodes: true, + removedNodes: true, + }, + ]) + }) + }) + + it(`rerenders when background color changes`, () => { + const mutationStub = cy.stub() + let cleanup + + cy.visit(`/gatsby-plugin-image-page-2/`) + + // wait for the image to load + cy.get("[data-main-image]").should("be.visible") + + // start watching mutations in the image-wrapper + cy.get("#image-wrapper").then($element => { + cleanup = observeDOM( + $element[0], + { + attributes: true, + attributeFilter: ["style"], + }, + mutations => { + mutationStub( + mutations.map(mutation => ({ + type: mutation.type, + attributeName: mutation.attributeName, + })) + ) + } + ) + }) + + cy.get("[data-gatsby-image-wrapper]").should( + "have.css", + "background-color", + "rgb(102, 51, 153)" + ) + + cy.get("#click").click() + + cy.wait(500) + + // wait for the image to load + cy.get("[data-main-image]").should("be.visible") + + cy.get("[data-gatsby-image-wrapper]").should( + "have.css", + "background-color", + "rgb(255, 0, 0)" + ) + cy.then(() => { + cleanup() + expect(mutationStub).to.be.calledOnce + expect(mutationStub).to.be.calledWith([ + { + type: "attributes", + attributeName: "style", + }, + ]) + }) + }) +}) diff --git a/e2e-tests/production-runtime/cypress/integration/lifecycle-methods.js b/e2e-tests/production-runtime/cypress/integration/lifecycle-methods.js index 236423245daae..b9c1c0688f395 100644 --- a/e2e-tests/production-runtime/cypress/integration/lifecycle-methods.js +++ b/e2e-tests/production-runtime/cypress/integration/lifecycle-methods.js @@ -1,6 +1,4 @@ -// TODO: In https://github.com/gatsbyjs/gatsby/pull/35226 the skip needs to be removed - -describe.skip(`Production build tests`, () => { +describe(`Production build tests`, () => { it(`should remount when navigating to different template`, () => { cy.visit(`/`).waitForRouteChange() diff --git a/e2e-tests/production-runtime/src/pages/gatsby-plugin-image-page-1.js b/e2e-tests/production-runtime/src/pages/gatsby-plugin-image-page-1.js new file mode 100644 index 0000000000000..9d3dbb28e8962 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/gatsby-plugin-image-page-1.js @@ -0,0 +1,45 @@ +import { graphql } from "gatsby" +import React from "react" + +import { GatsbyImage } from "gatsby-plugin-image" + +const PluginImage = ({ data }) => { + const images = data.allMyRemoteFile.nodes + const [nodeIndex, updateNode] = React.useState(0) + + return ( +
+

Image Testing

+ +
+ +
+ + +
+ ) +} + +export const pageQuery = graphql` + { + allMyRemoteFile { + nodes { + id + fixed: gatsbyImage(layout: FIXED, width: 100, placeholder: NONE) + } + } + } +` + +export default PluginImage diff --git a/e2e-tests/production-runtime/src/pages/gatsby-plugin-image-page-2.js b/e2e-tests/production-runtime/src/pages/gatsby-plugin-image-page-2.js new file mode 100644 index 0000000000000..631da8f9f68e8 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/gatsby-plugin-image-page-2.js @@ -0,0 +1,44 @@ +import { graphql } from "gatsby" +import React from "react" + +import { GatsbyImage } from "gatsby-plugin-image" + +const PluginImage = ({ data }) => { + const [bg, updateBg] = React.useState("rebeccapurple") + + return ( +
+

Image Testing

+ +
+ +
+ + +
+ ) +} + +export const pageQuery = graphql` + { + allMyRemoteFile { + nodes { + id + fixed: gatsbyImage(layout: FIXED, width: 100, placeholder: NONE) + } + } + } +` + +export default PluginImage diff --git a/packages/gatsby-plugin-image/gatsby-browser.js b/packages/gatsby-plugin-image/gatsby-browser.js deleted file mode 100644 index 7ca0ef15141ce..0000000000000 --- a/packages/gatsby-plugin-image/gatsby-browser.js +++ /dev/null @@ -1,6 +0,0 @@ -import React from "react" -import { LaterHydrator } from "." - -export function wrapRootElement({ element }) { - return {element} -} diff --git a/packages/gatsby-plugin-image/package.json b/packages/gatsby-plugin-image/package.json index b293504c1762f..994ac6051fb96 100644 --- a/packages/gatsby-plugin-image/package.json +++ b/packages/gatsby-plugin-image/package.json @@ -6,9 +6,9 @@ "build:gatsby-node": "tsc --jsx react --downlevelIteration true --skipLibCheck true --esModuleInterop true --outDir dist/ src/gatsby-node.ts src/babel-plugin-parse-static-images.ts src/resolver-utils.ts src/types.d.ts -d --declarationDir dist/src", "build:gatsby-ssr": "microbundle -i src/gatsby-ssr.tsx -f cjs -o ./[name].js --no-pkg-main --jsx React.createElement --no-compress --external=common-tags,react --no-sourcemap", "build:server": "microbundle -f cjs,es --jsx React.createElement --define SERVER=true", - "build:browser": "microbundle -i src/index.browser.ts -f cjs,modern,es --jsx React.createElement -o dist/gatsby-image.browser --define SERVER=false", + "build:browser": "microbundle -i src/index.browser.ts -f cjs,modern --jsx React.createElement -o dist/gatsby-image.browser --define SERVER=false", "prepare": "yarn build", - "watch": "run-p watch:*", + "watch": "npm-run-all -s clean -p watch:*", "watch:gatsby-node": "yarn build:gatsby-node --watch", "watch:gatsby-ssr": "yarn build:gatsby-ssr watch", "watch:server": "yarn build:server --no-compress watch", @@ -27,8 +27,7 @@ "esmodule": "dist/gatsby-image.modern.js", "browser": { "./dist/gatsby-image.js": "./dist/gatsby-image.browser.js", - "./dist/gatsby-image.module.js": "./dist/gatsby-image.browser.module.js", - "./dist/gatsby-image.modern.js": "./dist/gatsby-image.browser.modern.js" + "./dist/gatsby-image.module.js": "./dist/gatsby-image.browser.modern.js" }, "files": [ "dist/*", diff --git a/packages/gatsby-plugin-image/src/components/__tests__/gatsby-image.browser.tsx b/packages/gatsby-plugin-image/src/components/__tests__/gatsby-image.browser.tsx index ac74a05b8fe42..154d8617a5b75 100644 --- a/packages/gatsby-plugin-image/src/components/__tests__/gatsby-image.browser.tsx +++ b/packages/gatsby-plugin-image/src/components/__tests__/gatsby-image.browser.tsx @@ -1,7 +1,6 @@ /** * @jest-environment jsdom */ - import React from "react" import { render, waitFor } from "@testing-library/react" import * as hooks from "../hooks" @@ -15,7 +14,20 @@ jest.mock( strs.join(``) ) -// test +let count = 0 +function generateImage(): IGatsbyImageData { + return { + width: 100, + height: 100, + layout: `fullWidth`, + images: { + fallback: { src: `some-src-fallback-${count++}.jpg`, sizes: `192x192` }, + }, + placeholder: { sources: [] }, + + backgroundColor: `red`, + } +} describe(`GatsbyImage browser`, () => { let beforeHydrationContent: HTMLDivElement @@ -27,18 +39,9 @@ describe(`GatsbyImage browser`, () => { console.error = jest.fn() global.SERVER = true global.GATSBY___IMAGE = true - global.HAS_REACT_18 = false GatsbyImage = require(`../gatsby-image.browser`).GatsbyImage - image = { - width: 100, - height: 100, - layout: `fullWidth`, - images: { fallback: { src: `some-src-fallback.jpg`, sizes: `192x192` } }, - placeholder: { sources: [] }, - - backgroundColor: `red`, - } + image = generateImage() beforeHydrationContent = document.createElement(`div`) beforeHydrationContent.innerHTML = ` @@ -81,7 +84,6 @@ describe(`GatsbyImage browser`, () => { jest.clearAllMocks() global.SERVER = undefined global.GATSBY___IMAGE = undefined - global.HAS_REACT_18 = undefined process.env.NODE_ENV = `test` }) @@ -141,13 +143,18 @@ describe(`GatsbyImage browser`, () => { { container: beforeHydrationContent, hydrate: true } ) - const placeholder = await waitFor(() => - container.querySelector(`[data-placeholder-image=""]`) + const placeholder = await waitFor( + () => + container.querySelector(`[data-placeholder-image=""]`) as HTMLElement ) - const mainImage = container.querySelector(`[data-main-image=""]`) + const mainImage = container.querySelector( + `[data-main-image=""]` + ) as HTMLElement expect(placeholder).toBeDefined() expect(mainImage).toBeDefined() + expect(placeholder.style.opacity).toBe(`1`) + expect(mainImage.style.opacity).toBe(`0`) }) it(`relies on native lazy loading when the SSR element exists and that the browser supports native lazy loading`, async () => { @@ -157,11 +164,10 @@ describe(`GatsbyImage browser`, () => { // In this scenario, // hasSSRHtml is true and resolved through "beforeHydrationContent" and hydrate: true ;(hooks as any).hasNativeLazyLoadSupport = (): boolean => true - ;(hooks as any).storeImageloaded = jest.fn() const { container } = render( { img?.dispatchEvent(new Event(`load`)) - expect(onStartLoadSpy).toBeCalledWith({ wasCached: false }) + expect(onStartLoadSpy).toBeCalledWith({ wasCached: true }) expect(onLoadSpy).toBeCalled() - expect(hooks.storeImageloaded).toBeCalledWith( - `{"fallback":{"src":"some-src-fallback.jpg","sizes":"192x192"}}` - ) - }) - - it(`relies on intersection observer when the SSR element is not resolved`, async () => { - ;(hooks as any).hasNativeLazyLoadSupport = (): boolean => true - const onStartLoadSpy = jest.fn() - let GatsbyImage - jest.isolateModules(() => { - GatsbyImage = require(`../gatsby-image.browser`).GatsbyImage - }) - - const { container } = render( - - ) - - await waitFor(() => container.querySelector(`[data-main-image=""]`)) - - expect(onStartLoadSpy).toBeCalledWith({ wasCached: false }) - }) - - it(`relies on intersection observer when browser does not support lazy loading`, async () => { - ;(hooks as any).hasNativeLazyLoadSupport = (): boolean => false - const onStartLoadSpy = jest.fn() - let GatsbyImage - jest.isolateModules(() => { - GatsbyImage = require(`../gatsby-image.browser`).GatsbyImage - }) - - const { container } = render( - , - { container: beforeHydrationContent, hydrate: true } - ) - - await waitFor(() => container.querySelector(`[data-main-image=""]`)) - - expect(onStartLoadSpy).toBeCalledWith({ wasCached: false }) }) }) diff --git a/packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx b/packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx index 625c39fd01c12..2cbbd230975c9 100644 --- a/packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx +++ b/packages/gatsby-plugin-image/src/components/gatsby-image.browser.tsx @@ -1,55 +1,34 @@ -/* global HAS_REACT_18 */ -/* eslint-disable no-unused-expressions */ -import React, { - Component, - ElementType, - createRef, - MutableRefObject, - FunctionComponent, - ImgHTMLAttributes, - RefObject, - CSSProperties, +import { + createElement, + memo, + useMemo, + useEffect, + useLayoutEffect, + useRef, } from "react" import { getWrapperProps, - hasNativeLazyLoadSupport, - storeImageloaded, - hasImageLoaded, gatsbyImageIsInstalled, + hasNativeLazyLoadSupport, } 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 type { Root } from "react-dom/client" - -let reactRender -if (HAS_REACT_18) { - const reactDomClient = require(`react-dom/client`) - reactRender = ( - Component: React.ReactChild | Iterable, - el: ReactDOM.Container, - root: Root - ): Root => { - if (!root) { - root = reactDomClient.createRoot(el) - } - - root.render(Component) +import type { + FC, + ElementType, + FunctionComponent, + ImgHTMLAttributes, + CSSProperties, + ReactEventHandler, +} from "react" +import type { renderImageToString } from "./lazy-hydrate" +import type { PlaceholderProps } from "./placeholder" +import type { MainImageProps } from "./main-image" +import type { Layout } from "../image-utils" - return root - } -} else { - const reactDomClient = require(`react-dom`) - reactRender = ( - Component: React.ReactChild | Iterable, - el: ReactDOM.Container - ): void => { - reactDomClient.render(Component, el) - } -} +const imageCache = new Set() +let renderImageToStringPromise +let renderImage: typeof renderImageToString | undefined // eslint-disable-next-line @typescript-eslint/naming-convention export interface GatsbyImageProps @@ -67,9 +46,9 @@ export interface GatsbyImageProps backgroundColor?: string objectFit?: CSSProperties["objectFit"] objectPosition?: CSSProperties["objectPosition"] - onLoad?: () => void - onError?: () => void - onStartLoad?: (props: { wasCached?: boolean }) => void + onLoad?: (props: { wasCached: boolean }) => void + onError?: ReactEventHandler + onStartLoad?: (props: { wasCached: boolean }) => void } export interface IGatsbyImageData { @@ -81,232 +60,177 @@ export interface IGatsbyImageData { placeholder?: Pick } -class GatsbyImageHydrator extends Component< - GatsbyImageProps, - { isLoading: boolean; isLoaded: boolean } -> { - root: RefObject = createRef< - HTMLImageElement | undefined - >() - hydrated: MutableRefObject = { current: false } - forceRender: MutableRefObject = { - // In dev we use render not hydrate, to avoid hydration warnings - current: process.env.NODE_ENV === `development`, +const GatsbyImageHydrator: FC = function GatsbyImageHydrator({ + as = `div`, + image, + style, + backgroundColor, + className, + class: preactClass, + onStartLoad, + onLoad, + onError, + ...props +}) { + const { width, height, layout } = image + const { + style: wStyle, + className: wClass, + ...wrapperProps + } = getWrapperProps(width, height, layout) + const root = useRef() + const cacheKey = useMemo(() => JSON.stringify(image.images), [image.images]) + + // Preact uses class instead of className so we need to check for both + if (preactClass) { + className = preactClass } - lazyHydrator: () => void | null = null - ref = createRef() - unobserveRef: Unobserver - reactRootRef: MutableRefObject = createRef() - constructor(props) { - super(props) + const sizer = getSizer(layout, width, height) - this.state = { - isLoading: hasNativeLazyLoadSupport(), - isLoaded: false, - } - } + useEffect(() => { + if (!renderImageToStringPromise) { + renderImageToStringPromise = import(`./lazy-hydrate`).then( + ({ renderImageToString, swapPlaceholderImage }) => { + renderImage = renderImageToString - _lazyHydrate(props, state): Promise { - 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 }) => { - const cacheKey = JSON.stringify(this.props.image.images) - this.lazyHydrator = lazyHydrate( - { - image: props.image.images, - isLoading: state.isLoading || hasImageLoaded(cacheKey), - isLoaded: state.isLoaded || hasImageLoaded(cacheKey), - toggleIsLoaded: () => { - props.onLoad?.() - - this.setState({ - isLoaded: true, - }) - }, - ref: this.ref, - ...props, - }, - this.root, - this.hydrated, - this.forceRender, - this.reactRootRef + return { + renderImageToString, + swapPlaceholderImage, + } + } ) - }) - } + } - /** - * 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), + // The plugin image component is a bit special where if it's server-side rendered, we add extra script tags to support lazy-loading without + // In this case we stop hydration but fire the correct events. + const ssrImage = root.current.querySelector( + `[data-gatsby-image-ssr]` + ) as HTMLImageElement + if (ssrImage && hasNativeLazyLoadSupport()) { + if (ssrImage.complete) { + // Trigger onStartload and onLoad events + onStartLoad?.({ + wasCached: true, + }) + onLoad?.({ + wasCached: true, + }) + + // remove ssr key for state updates but add delay to not fight with native code snippt of gatsby-ssr + setTimeout(() => { + ssrImage.removeAttribute(`data-gatsby-image-ssr`) + }, 0) + } else { + document.addEventListener(`load`, function onLoadListener() { + document.removeEventListener(`load`, onLoadListener) + + onStartLoad?.({ + wasCached: true, }) - this.setState({ - isLoading: true, - isLoaded: useCache && hasImageLoaded(cacheKey), + onLoad?.({ + wasCached: true, }) - } - }) - - if (this.root.current) { - this.unobserveRef = intersectionObserver(this.root) + // remove ssr key for state updates but add delay to not fight with native code snippt of gatsby-ssr + setTimeout(() => { + ssrImage.removeAttribute(`data-gatsby-image-ssr`) + }, 0) + }) } - }) - } - shouldComponentUpdate(nextProps, nextState): boolean { - let hasChanged = false - if (!this.state.isLoading && nextState.isLoading && !nextState.isLoaded) { - // Props have changed between SSR and hydration, so we need to force render instead of hydrate - this.forceRender.current = true - } - // 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() + imageCache.add(cacheKey) - // // on unmount, make sure we cleanup - if (this.hydrated.current && this.lazyHydrator) { - this.reactRootRef.current = reactRender( - null, - this.root.current, - this.reactRootRef.current - ) - } - } - - this.setState( - { - isLoading: false, - isLoaded: false, - }, - () => { - this._setupIntersectionObserver(false) - } - ) - - hasChanged = true + return } - if (this.root.current && !hasChanged) { - this._lazyHydrate(nextProps, nextState) + if (renderImage && imageCache.has(cacheKey)) { + return } - return false - } - - 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() && - ssrElement && - gatsbyImageIsInstalled() - ) { - this.props.onStartLoad?.({ wasCached: false }) - - // 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 { - // 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) - - _this.props.onLoad?.() - storeImageloaded(cacheKey) + let animationFrame + let cleanupCallback + renderImageToStringPromise.then( + ({ renderImageToString, swapPlaceholderImage }) => { + root.current.innerHTML = renderImageToString({ + isLoading: true, + isLoaded: imageCache.has(cacheKey), + image, + ...props, + }) + + if (!imageCache.has(cacheKey)) { + animationFrame = requestAnimationFrame(() => { + if (root.current) { + cleanupCallback = swapPlaceholderImage( + root.current, + cacheKey, + imageCache, + style, + onStartLoad, + onLoad, + onError + ) + } }) } - - return } + ) - // Fallback to custom lazy loading (intersection observer) - this._setupIntersectionObserver(true) - } - } - - componentWillUnmount(): void { - // Cleanup when onmount happens - if (this.unobserveRef) { - // unregister intersectionObserver - this.unobserveRef() - - // on unmount, make sure we cleanup - if (this.hydrated.current && this.lazyHydrator) { - this.lazyHydrator() + // eslint-disable-next-line consistent-return + return (): void => { + if (animationFrame) { + cancelAnimationFrame(animationFrame) + } + if (cleanupCallback) { + cleanupCallback() } } + }, [image]) + + // useLayoutEffect is ran before React commits to the DOM. This allows us to make sure our HTML is using our cached image version + useLayoutEffect(() => { + if (imageCache.has(cacheKey) && renderImage) { + root.current.innerHTML = renderImage({ + isLoading: imageCache.has(cacheKey), + isLoaded: imageCache.has(cacheKey), + image, + ...props, + }) - 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 + // Trigger onStartload and onLoad events + onStartLoad?.({ + wasCached: true, + }) + onLoad?.({ + wasCached: true, + }) } - - const sizer = getSizer(layout, width, height) - - return ( - - ) - } + }, [image]) + + // By keeping all props equal React will keep the component in the DOM + return createElement(as, { + ...wrapperProps, + style: { + ...wStyle, + ...style, + backgroundColor, + }, + className: `${wClass}${className ? ` ${className}` : ``}`, + ref: root, + dangerouslySetInnerHTML: { + __html: sizer, + }, + suppressHydrationWarning: true, + }) } -export const GatsbyImage: FunctionComponent = +export const GatsbyImage: FunctionComponent = memo( function GatsbyImage(props) { if (!props.image) { if (process.env.NODE_ENV === `development`) { console.warn(`[gatsby-plugin-image] Missing image prop`) } + return null } @@ -315,19 +239,10 @@ export const GatsbyImage: FunctionComponent = `[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 + + return createElement(GatsbyImageHydrator, props) } +) GatsbyImage.propTypes = propTypes - GatsbyImage.displayName = `GatsbyImage` diff --git a/packages/gatsby-plugin-image/src/components/gatsby-image.server.tsx b/packages/gatsby-plugin-image/src/components/gatsby-image.server.tsx index 40d6cab111edf..27695c390c8c4 100644 --- a/packages/gatsby-plugin-image/src/components/gatsby-image.server.tsx +++ b/packages/gatsby-plugin-image/src/components/gatsby-image.server.tsx @@ -1,29 +1,17 @@ -import React, { - ElementType, - FunctionComponent, - CSSProperties, - WeakValidationMap, -} from "react" -import { GatsbyImageProps, IGatsbyImageData } from "./gatsby-image.browser" +import React from "react" import { getWrapperProps, getMainProps, getPlaceholderProps } from "./hooks" import { Placeholder } from "./placeholder" import { MainImage, MainImageProps } from "./main-image" import { LayoutWrapper } from "./layout-wrapper" import PropTypes from "prop-types" +import type { FunctionComponent, WeakValidationMap } from "react" +import type { GatsbyImageProps, IGatsbyImageData } from "./gatsby-image.browser" const removeNewLines = (str: string): string => str.replace(/\n/g, ``) -export const GatsbyImageHydrator: FunctionComponent<{ - as?: ElementType - style?: CSSProperties - className?: string -}> = function GatsbyImageHydrator({ as: Type = `div`, children, ...props }) { - return {children} -} - export const GatsbyImage: FunctionComponent = function GatsbyImage({ - as, + as = `div`, className, class: preactClass, style, @@ -40,9 +28,11 @@ export const GatsbyImage: FunctionComponent = console.warn(`[gatsby-plugin-image] Missing image prop`) return null } + if (preactClass) { className = preactClass } + imgStyle = { objectFit, objectPosition, @@ -87,49 +77,48 @@ export const GatsbyImage: FunctionComponent = }) } - return ( - - - + }, + className: `${wClass}${className ? ` ${className}` : ``}`, + }, + + - )} - // When eager is set we want to start the isLoading state on true (we want to load the img without react) - {...getMainProps( - loading === `eager`, - false, - cleanedImages, - loading, - undefined, - undefined, - undefined, - imgStyle - )} - /> - - + )} + // When eager is set we want to start the isLoading state on true (we want to load the img without react) + {...getMainProps( + loading === `eager`, + false, + cleanedImages, + loading, + imgStyle + )} + /> + ) } @@ -144,8 +133,10 @@ export const altValidator: PropTypes.Validator = ( `The "alt" prop is required in ${componentName}. If the image is purely presentational then pass an empty string: e.g. alt="". Learn more: https://a11y-style-guide.com/style-guide/section-media.html` ) } + return PropTypes.string(props, propName, componentName, ...rest) } + export const propTypes = { image: PropTypes.object.isRequired, alt: altValidator, diff --git a/packages/gatsby-plugin-image/src/components/hooks.ts b/packages/gatsby-plugin-image/src/components/hooks.ts index 3e766d85a8c84..f40eb64fce27b 100644 --- a/packages/gatsby-plugin-image/src/components/hooks.ts +++ b/packages/gatsby-plugin-image/src/components/hooks.ts @@ -1,28 +1,16 @@ -/* eslint-disable no-unused-expressions */ -import { - useState, - CSSProperties, - useEffect, - HTMLAttributes, - ImgHTMLAttributes, - ReactEventHandler, - SetStateAction, - Dispatch, - RefObject, -} from "react" -import { Node } from "gatsby" -import { PlaceholderProps } from "./placeholder" -import { MainImageProps } from "./main-image" +/* global GATSBY___IMAGE */ +import { generateImageData, EVERY_BREAKPOINT } from "../image-utils" +import type { CSSProperties, HTMLAttributes, ImgHTMLAttributes } from "react" +import type { Node } from "gatsby" +import type { PlaceholderProps } from "./placeholder" +import type { MainImageProps } from "./main-image" import type { IGatsbyImageData } from "./gatsby-image.browser" -import { +import type { IGatsbyImageHelperArgs, - generateImageData, Layout, - EVERY_BREAKPOINT, IImage, ImageFormat, } from "../image-utils" -const imageCache = new Set() // Native lazy-loading support: https://addyosmani.com/blog/lazy-loading/ export const hasNativeLazyLoadSupport = (): boolean => @@ -33,15 +21,6 @@ export function gatsbyImageIsInstalled(): boolean { return typeof GATSBY___IMAGE !== `undefined` && GATSBY___IMAGE } -export function storeImageloaded(cacheKey?: string): void { - if (cacheKey) { - imageCache.add(cacheKey) - } -} - -export function hasImageLoaded(cacheKey: string): boolean { - return imageCache.has(cacheKey) -} export type IGatsbyImageDataParent = T & { gatsbyImageData: IGatsbyImageData } @@ -113,18 +92,6 @@ export function getWrapperProps( } } -export async function applyPolyfill( - ref: RefObject -): Promise { - if (!(`objectFitPolyfill` in window)) { - await import( - // @ts-ignore typescript can't find the module for some reason ¯\_(ツ)_/¯ - /* webpackChunkName: "gatsby-plugin-image-objectfit-polyfill" */ `objectFitPolyfill` - ) - } - ;(window as any).objectFitPolyfill(ref.current) -} - export interface IUrlBuilderArgs { width: number height: number @@ -234,44 +201,8 @@ export function getMainProps( isLoaded: boolean, images: IGatsbyImageData["images"], loading?: "eager" | "lazy", - toggleLoaded?: (loaded: boolean) => void, - cacheKey?: string, - ref?: RefObject, style: CSSProperties = {} ): Partial { - const onLoad: ReactEventHandler = function (e) { - if (isLoaded) { - return - } - - storeImageloaded(cacheKey) - - const target = e.currentTarget - const img = new Image() - img.src = target.currentSrc - - if (img.decode) { - // Decode the image through javascript to support our transition - img - .decode() - .catch(() => { - // ignore error, we just go forward - }) - .then(() => { - toggleLoaded(true) - }) - } else { - toggleLoaded(true) - } - } - - // Polyfill "object-fit" if unsupported (mostly IE) - if (ref?.current && !(`objectFit` in document.documentElement.style)) { - ref.current.dataset.objectFit = style.objectFit ?? `cover` - ref.current.dataset.objectPosition = `${style.objectPosition ?? `50% 50%`}` - applyPolyfill(ref) - } - // fallback when it's not configured in gatsby-config. if (!gatsbyImageIsInstalled()) { style = { @@ -296,8 +227,6 @@ export function getMainProps( ...style, opacity: isLoaded ? 1 : 0, }, - onLoad, - ref, } return result @@ -375,58 +304,6 @@ export function getPlaceholderProps( return result } -export function useImageLoaded( - cacheKey: string, - loading: "lazy" | "eager", - ref: any -): { - isLoaded: boolean - isLoading: boolean - toggleLoaded: Dispatch> -} { - const [isLoaded, toggleLoaded] = useState(false) - const [isLoading, toggleIsLoading] = useState(loading === `eager`) - - const rAF = - typeof window !== `undefined` && `requestAnimationFrame` in window - ? requestAnimationFrame - : function (cb: TimerHandler): number { - return setTimeout(cb, 16) - } - const cRAF = - typeof window !== `undefined` && `cancelAnimationFrame` in window - ? cancelAnimationFrame - : clearTimeout - - useEffect(() => { - let interval: number - // @see https://stackoverflow.com/questions/44074747/componentdidmount-called-before-ref-callback/50019873#50019873 - function toggleIfRefExists(): void { - if (ref.current) { - if (loading === `eager` && ref.current.complete) { - storeImageloaded(cacheKey) - toggleLoaded(true) - } else { - toggleIsLoading(true) - } - } else { - interval = rAF(toggleIfRefExists) - } - } - toggleIfRefExists() - - return (): void => { - cRAF(interval) - } - }, []) - - return { - isLoading, - isLoaded, - toggleLoaded, - } -} - export interface IArtDirectedImage { media: string image: IGatsbyImageData diff --git a/packages/gatsby-plugin-image/src/components/intersection-observer.ts b/packages/gatsby-plugin-image/src/components/intersection-observer.ts index 9a632db59a72a..dc52ba790a8a2 100644 --- a/packages/gatsby-plugin-image/src/components/intersection-observer.ts +++ b/packages/gatsby-plugin-image/src/components/intersection-observer.ts @@ -1,6 +1,3 @@ -/* eslint-disable no-unused-expressions */ -import { RefObject } from "react" - let intersectionObserver: IntersectionObserver export type Unobserver = () => void @@ -20,7 +17,7 @@ const SLOW_CONNECTION_THRESHOLD = `2500px` export function createIntersectionObserver( callback: () => void -): (element: RefObject) => Unobserver { +): (element: HTMLElement) => Unobserver { const connectionType = connection?.effectiveType // if we don't support intersectionObserver we don't lazy load (Sorry IE 11). @@ -52,19 +49,15 @@ export function createIntersectionObserver( ) } - return function observe( - element: RefObject - ): Unobserver { - if (element.current) { - // Store a reference to the callback mapped to the element being watched - ioEntryMap.set(element.current, callback) - intersectionObserver.observe(element.current) - } + return function observe(element: HTMLElement): Unobserver { + // Store a reference to the callback mapped to the element being watched + ioEntryMap.set(element, callback) + intersectionObserver.observe(element) return function unobserve(): void { - if (intersectionObserver && element.current) { - ioEntryMap.delete(element.current) - intersectionObserver.unobserve(element.current) + if (intersectionObserver && element) { + ioEntryMap.delete(element) + intersectionObserver.unobserve(element) } } } diff --git a/packages/gatsby-plugin-image/src/components/later-hydrator.tsx b/packages/gatsby-plugin-image/src/components/later-hydrator.tsx deleted file mode 100644 index 85b70825ad4ea..0000000000000 --- a/packages/gatsby-plugin-image/src/components/later-hydrator.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import * as React from "react" -export function LaterHydrator({ - children, -}: React.PropsWithChildren>): React.ReactNode { - React.useEffect(() => { - // eslint-disable-next-line no-unused-expressions - import(`./lazy-hydrate`) - }, []) - - return children -} diff --git a/packages/gatsby-plugin-image/src/components/layout-wrapper.tsx b/packages/gatsby-plugin-image/src/components/layout-wrapper.tsx index 0634d223a6dfe..b0155f95f6199 100644 --- a/packages/gatsby-plugin-image/src/components/layout-wrapper.tsx +++ b/packages/gatsby-plugin-image/src/components/layout-wrapper.tsx @@ -1,6 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// - import React, { Fragment, FunctionComponent } from "react" import terserMacro from "../../macros/terser.macro" import { Layout } from "../image-utils" @@ -50,15 +47,17 @@ export function getSizer( width: number, height: number ): string { - let sizer: string | null = null + let sizer = `` if (layout === `fullWidth`) { sizer = `` } + if (layout === `constrained`) { sizer = `
` } + return sizer } @@ -72,6 +71,7 @@ const Sizer: FunctionComponent = function Sizer({
) } + if (layout === `constrained`) { return (
@@ -100,10 +100,7 @@ export const LayoutWrapper: FunctionComponent = {children} - { - // eslint-disable-next-line no-undef - SERVER && - } + {SERVER ? : null} ) } diff --git a/packages/gatsby-plugin-image/src/components/lazy-hydrate.tsx b/packages/gatsby-plugin-image/src/components/lazy-hydrate.tsx index 41caf32644481..40d6f7166296e 100644 --- a/packages/gatsby-plugin-image/src/components/lazy-hydrate.tsx +++ b/packages/gatsby-plugin-image/src/components/lazy-hydrate.tsx @@ -1,72 +1,188 @@ -/* global HAS_REACT_18 */ -import React, { MutableRefObject } from "react" -import { GatsbyImageProps } from "./gatsby-image.browser" +import React from "react" +import { renderToStaticMarkup } from "react-dom/server" import { LayoutWrapper } from "./layout-wrapper" import { Placeholder } from "./placeholder" -import { MainImageProps, MainImage } from "./main-image" -import { getMainProps, getPlaceholderProps } from "./hooks" -import { ReactElement } from "react" -import type { Root } from "react-dom/client" +import { MainImage } from "./main-image" +import { + hasNativeLazyLoadSupport, + getMainProps, + getPlaceholderProps, +} from "./hooks" +import { createIntersectionObserver } from "./intersection-observer" +import type { MainImageProps } from "./main-image" +import type { GatsbyImageProps } from "./gatsby-image.browser" type LazyHydrateProps = Omit & { isLoading: boolean - isLoaded: boolean // alwaystype SetStateAction = S | ((prevState: S) => S); - toggleIsLoaded: (toggle: boolean) => void - ref: MutableRefObject + isLoaded: boolean } -let reactRender -let reactHydrate -if (HAS_REACT_18) { - const reactDomClient = require(`react-dom/client`) - reactRender = ( - Component: React.ReactChild | Iterable, - el: ReactDOM.Container, - root: Root - ): Root => { - if (!root) { - root = reactDomClient.createRoot(el) +async function applyPolyfill(element: HTMLImageElement): Promise { + if (!(`objectFitPolyfill` in window)) { + await import( + // @ts-ignore typescript can't find the module for some reason ¯\_(ツ)_/¯ + /* webpackChunkName: "gatsby-plugin-image-objectfit-polyfill" */ `objectFitPolyfill` + ) + } + ;(window as any).objectFitPolyfill(element) +} + +function toggleLoaded( + mainImage: HTMLElement, + placeholderImage: HTMLElement +): void { + mainImage.style.opacity = `1` + + if (placeholderImage) { + placeholderImage.style.opacity = `0` + } +} + +function startLoading( + element: HTMLElement, + cacheKey: string, + imageCache: Set, + onStartLoad: GatsbyImageProps["onStartLoad"], + onLoad: GatsbyImageProps["onLoad"], + onError: GatsbyImageProps["onError"] +): () => void { + const mainImage = element.querySelector( + `[data-main-image]` + ) as HTMLImageElement + const placeholderImage = element.querySelector( + `[data-placeholder-image]` + ) + const isCached = imageCache.has(cacheKey) + + function onImageLoaded(e): void { + // eslint-disable-next-line @babel/no-invalid-this + this.removeEventListener(`load`, onImageLoaded) + + const target = e.currentTarget + const img = new Image() + img.src = target.currentSrc + + if (img.decode) { + // Decode the image through javascript to support our transition + img + .decode() + .then(() => { + // eslint-disable-next-line @babel/no-invalid-this + toggleLoaded(this, placeholderImage) + onLoad?.({ + wasCached: isCached, + }) + }) + .catch(e => { + // eslint-disable-next-line @babel/no-invalid-this + toggleLoaded(this, placeholderImage) + onError?.(e) + }) + } else { + // eslint-disable-next-line @babel/no-invalid-this + toggleLoaded(this, placeholderImage) + onLoad?.({ + wasCached: isCached, + }) } + } + + mainImage.addEventListener(`load`, onImageLoaded) + + onStartLoad?.({ + wasCached: isCached, + }) + Array.from(mainImage.parentElement.children).forEach(child => { + const src = child.getAttribute(`data-src`) + const srcSet = child.getAttribute(`data-srcset`) + if (src) { + child.removeAttribute(`data-src`) + child.setAttribute(`src`, src) + } + if (srcSet) { + child.removeAttribute(`data-srcset`) + child.setAttribute(`srcset`, srcSet) + } + }) - root.render(Component) + imageCache.add(cacheKey) - return root + // Load times not always fires - mostly when it's a 304 + // We check if the image is already completed and if so we trigger onload. + if (mainImage.complete) { + onImageLoaded.call(mainImage, { + currentTarget: mainImage, + }) } - reactHydrate = ( - Component: React.ReactChild | Iterable, - el: ReactDOM.Container - ): Root => reactDomClient.hydrateRoot(el, Component) -} else { - const reactDomClient = require(`react-dom`) - reactRender = ( - Component: React.ReactChild | Iterable, - el: ReactDOM.Container - ): void => { - reactDomClient.render(Component, el) + + return (): void => { + if (mainImage) { + mainImage.removeEventListener(`load`, onImageLoaded) + } } - reactHydrate = reactDomClient.hydrate } -export function lazyHydrate( - { - image, - loading, - isLoading, - isLoaded, - toggleIsLoaded, - ref, - imgClassName, - imgStyle = {}, - objectPosition, - backgroundColor, - objectFit = `cover`, - ...props - }: LazyHydrateProps, - root: MutableRefObject, - hydrated: MutableRefObject, - forceHydrate: MutableRefObject, - reactRootRef: MutableRefObject -): (() => void) | null { +export function swapPlaceholderImage( + element: HTMLElement, + cacheKey: string, + imageCache: Set, + style: React.CSSProperties, + onStartLoad: GatsbyImageProps["onStartLoad"], + onLoad: GatsbyImageProps["onLoad"], + onError: GatsbyImageProps["onError"] +): () => void { + if (!hasNativeLazyLoadSupport()) { + let cleanup + const io = createIntersectionObserver(() => { + cleanup = startLoading( + element, + cacheKey, + imageCache, + onStartLoad, + onLoad, + onError + ) + }) + const unobserve = io(element) + + // Polyfill "object-fit" if unsupported (mostly IE) + if (!(`objectFit` in document.documentElement.style)) { + element.dataset.objectFit = style.objectFit ?? `cover` + element.dataset.objectPosition = `${style.objectPosition ?? `50% 50%`}` + applyPolyfill(element as HTMLImageElement) + } + + return (): void => { + if (cleanup) { + cleanup() + } + + unobserve() + } + } + + return startLoading( + element, + cacheKey, + imageCache, + onStartLoad, + onLoad, + onError + ) +} + +export function renderImageToString({ + image, + loading = `lazy`, + isLoading, + isLoaded, + imgClassName, + imgStyle = {}, + objectPosition, + backgroundColor, + objectFit = `cover`, + ...props +}: LazyHydrateProps): string { const { width, height, @@ -76,8 +192,6 @@ export function lazyHydrate( backgroundColor: wrapperBackgroundColor, } = image - const cacheKey = JSON.stringify(images) - imgStyle = { objectFit, objectPosition, @@ -85,7 +199,7 @@ export function lazyHydrate( ...imgStyle, } - const component = ( + return renderToStaticMarkup( )} + {...(props as Omit< + MainImageProps, + "images" | "fallback" | "onLoad" | "onError" + >)} width={width} height={height} className={imgClassName} - {...getMainProps( - isLoading, - isLoaded, - images, - loading, - toggleIsLoaded, - cacheKey, - ref, - imgStyle - )} + {...getMainProps(isLoading, isLoaded, images, loading, imgStyle)} /> ) - - if (root.current) { - // Force render to mitigate "Expected server HTML to contain a matching" in develop - if (hydrated.current || forceHydrate.current || HAS_REACT_18) { - reactRootRef.current = reactRender( - component, - root.current, - reactRootRef.current - ) - } else { - reactHydrate(component, root.current) - } - hydrated.current = true - } - - return (): void => { - if (root.current) { - reactRender( - null as unknown as ReactElement, - root.current, - reactRootRef.current - ) - } - } } diff --git a/packages/gatsby-plugin-image/src/components/main-image.tsx b/packages/gatsby-plugin-image/src/components/main-image.tsx index dc40776873e3b..398e2fe36702f 100644 --- a/packages/gatsby-plugin-image/src/components/main-image.tsx +++ b/packages/gatsby-plugin-image/src/components/main-image.tsx @@ -1,20 +1,18 @@ -import React, { forwardRef } from "react" +import React from "react" import { Picture, PictureProps } from "./picture" export type MainImageProps = PictureProps -export const MainImage = forwardRef( - function MainImage(props, ref) { - return ( - <> - - - - ) - } -) +export const MainImage: React.FC = function MainImage(props) { + return ( + <> + + + + ) +} MainImage.displayName = `MainImage` MainImage.propTypes = Picture.propTypes diff --git a/packages/gatsby-plugin-image/src/components/picture.tsx b/packages/gatsby-plugin-image/src/components/picture.tsx index 3118d3135c20c..874ff7cdd2bbc 100644 --- a/packages/gatsby-plugin-image/src/components/picture.tsx +++ b/packages/gatsby-plugin-image/src/components/picture.tsx @@ -1,10 +1,4 @@ -/* eslint-disable filenames/match-regex */ -import React, { - FunctionComponent, - ImgHTMLAttributes, - forwardRef, - LegacyRef, -} from "react" +import React, { FunctionComponent, ImgHTMLAttributes } from "react" import * as PropTypes from "prop-types" export interface IResponsiveImageProps { @@ -30,7 +24,6 @@ type ImageProps = ImgHTMLAttributes & { src: string alt: string shouldLoad: boolean - innerRef: LegacyRef } export type PictureProps = ImgHTMLAttributes & { @@ -46,7 +39,6 @@ const Image: FunctionComponent = function Image({ loading, alt = ``, shouldLoad, - innerRef, ...props }) { return ( @@ -59,48 +51,41 @@ const Image: FunctionComponent = function Image({ srcSet={shouldLoad ? srcSet : undefined} data-srcset={!shouldLoad ? srcSet : undefined} alt={alt} - ref={innerRef} /> ) } -export const Picture = forwardRef( - function Picture( - { fallback, sources = [], shouldLoad = true, ...props }, - ref - ) { - const sizes = props.sizes || fallback?.sizes - const fallbackImage = ( - - ) - - if (!sources.length) { - return fallbackImage - } +export const Picture: React.FC = function Picture({ + fallback, + sources = [], + shouldLoad = true, + ...props +}) { + const sizes = props.sizes || fallback?.sizes + const fallbackImage = ( + + ) - return ( - - {sources.map(({ media, srcSet, type }) => ( - - ))} - {fallbackImage} - - ) + if (!sources.length) { + return fallbackImage } -) + + return ( + + {sources.map(({ media, srcSet, type }) => ( + + ))} + {fallbackImage} + + ) +} Image.propTypes = { src: PropTypes.string.isRequired, diff --git a/packages/gatsby-plugin-image/src/components/placeholder.tsx b/packages/gatsby-plugin-image/src/components/placeholder.tsx index ef99e5ba78368..cfc754f823524 100644 --- a/packages/gatsby-plugin-image/src/components/placeholder.tsx +++ b/packages/gatsby-plugin-image/src/components/placeholder.tsx @@ -33,6 +33,7 @@ Placeholder.propTypes = { if (!props[propName]) { return null } + return new Error( `Invalid prop \`${propName}\` supplied to \`${componentName}\`. Validation failed.` ) diff --git a/packages/gatsby-plugin-image/src/gatsby-node.ts b/packages/gatsby-plugin-image/src/gatsby-node.ts index 9de986c24fb45..f76e3c9c1360d 100644 --- a/packages/gatsby-plugin-image/src/gatsby-node.ts +++ b/packages/gatsby-plugin-image/src/gatsby-node.ts @@ -1,11 +1,10 @@ -import { GatsbyNode } from "gatsby" +import type { GatsbyNode } from "gatsby" import { getCacheDir } from "./node-apis/node-utils" import { ImageFormatType, ImageLayoutType, ImagePlaceholderType, } from "./resolver-utils" -import { major } from "semver" export * from "./node-apis/preprocess-source" @@ -52,9 +51,6 @@ export const onCreateWebpackConfig: GatsbyNode["onCreateWebpackConfig"] = ({ plugins.define({ // eslint-disable-next-line @typescript-eslint/naming-convention GATSBY___IMAGE: true, - HAS_REACT_18: JSON.stringify( - major(require(`react-dom/package.json`).version) >= 18 - ), }), ], }) diff --git a/packages/gatsby-plugin-image/src/gatsby-ssr.tsx b/packages/gatsby-plugin-image/src/gatsby-ssr.tsx index dc142652afbc2..fe68219d337b9 100644 --- a/packages/gatsby-plugin-image/src/gatsby-ssr.tsx +++ b/packages/gatsby-plugin-image/src/gatsby-ssr.tsx @@ -71,19 +71,18 @@ export function onRenderBody({ setHeadComponents }: RenderBodyArgs): void { const hasNativeLazyLoadSupport = typeof HTMLImageElement !== "undefined" && "loading" in HTMLImageElement.prototype; if (hasNativeLazyLoadSupport) { document.body.addEventListener('load', function gatsbyImageNativeLoader(e) { + const target = e.target; + // if image is not tagged with Main Image we bail - if (typeof e.target.dataset["mainImage"] === 'undefined') { + if (typeof target.dataset["mainImage"] === 'undefined') { return } // if a main image does not have a ssr tag, we know it's not the first run anymore - if (typeof e.target.dataset["gatsbyImageSsr"] === 'undefined') { + if (typeof target.dataset["gatsbyImageSsr"] === 'undefined') { return; } - - const target = e.target; - let imageWrapper = null; let parentElement = target; while (imageWrapper === null && parentElement) { diff --git a/packages/gatsby-plugin-image/src/global.d.ts b/packages/gatsby-plugin-image/src/global.d.ts deleted file mode 100644 index a76544f65b970..0000000000000 --- a/packages/gatsby-plugin-image/src/global.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export {} - -declare global { - declare var SERVER: boolean | undefined - declare var GATSBY___IMAGE: boolean | undefined - declare var HAS_REACT_18: boolean | undefined -} diff --git a/packages/gatsby-plugin-image/src/global.ts b/packages/gatsby-plugin-image/src/global.ts new file mode 100644 index 0000000000000..7feec82bb744d --- /dev/null +++ b/packages/gatsby-plugin-image/src/global.ts @@ -0,0 +1,7 @@ +export {} + +declare global { + export const SERVER: boolean | undefined + // eslint-disable-next-line @typescript-eslint/naming-convention + export const GATSBY___IMAGE: boolean | undefined +} diff --git a/packages/gatsby-plugin-image/src/image-utils.ts b/packages/gatsby-plugin-image/src/image-utils.ts index 7f058b17198e7..221b3d88e43eb 100644 --- a/packages/gatsby-plugin-image/src/image-utils.ts +++ b/packages/gatsby-plugin-image/src/image-utils.ts @@ -1,7 +1,5 @@ -/* eslint-disable no-unused-expressions */ -import { stripIndent } from "common-tags" import camelCase from "camelcase" -import { IGatsbyImageData } from "." +import type { IGatsbyImageData } from "./index" const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] export const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] @@ -451,8 +449,8 @@ export function fixedImageSizes({ // print out this message with the necessary information before we overwrite it for sizing if (isTopSizeOverriden) { const fixedDimension = imgDimensions.width < width ? `width` : `height` - reporter.warn(stripIndent` - The requested ${fixedDimension} "${ + reporter.warn(` +The requested ${fixedDimension} "${ fixedDimension === `width` ? width : height }px" for the image ${filename} was larger than the actual image ${fixedDimension} of ${ imgDimensions[fixedDimension] diff --git a/packages/gatsby-plugin-image/src/index.browser.ts b/packages/gatsby-plugin-image/src/index.browser.ts index f804c2a7e74db..daff94cc0524e 100644 --- a/packages/gatsby-plugin-image/src/index.browser.ts +++ b/packages/gatsby-plugin-image/src/index.browser.ts @@ -1,3 +1,4 @@ +import "./global" export { GatsbyImage, GatsbyImageProps, @@ -6,7 +7,6 @@ export { export { Placeholder } from "./components/placeholder" export { MainImage } from "./components/main-image" export { StaticImage } from "./components/static-image" -export { LaterHydrator } from "./components/later-hydrator" export { getImage, getSrc, diff --git a/packages/gatsby-plugin-image/src/index.ts b/packages/gatsby-plugin-image/src/index.ts index 5961811df1506..de68d6b5ad7d3 100644 --- a/packages/gatsby-plugin-image/src/index.ts +++ b/packages/gatsby-plugin-image/src/index.ts @@ -1,3 +1,4 @@ +import "./global" export { GatsbyImage } from "./components/gatsby-image.server" export { GatsbyImageProps, diff --git a/packages/gatsby-plugin-image/src/resolver-utils.ts b/packages/gatsby-plugin-image/src/resolver-utils.ts index b21a3ab991ff2..76686fd7c8523 100644 --- a/packages/gatsby-plugin-image/src/resolver-utils.ts +++ b/packages/gatsby-plugin-image/src/resolver-utils.ts @@ -1,11 +1,11 @@ -import { GraphQLFieldResolver } from "gatsby/graphql" -import { +import { stripIndent } from "common-tags" +import type { GraphQLFieldResolver } from "gatsby/graphql" +import type { EnumTypeComposerAsObjectDefinition, ObjectTypeComposerFieldConfigAsObjectDefinition, ObjectTypeComposerArgumentConfigMapDefinition, } from "graphql-compose" -import { stripIndent } from "common-tags" -import { ISharpGatsbyImageArgs, IImageSizeArgs } from "./image-utils" +import type { ISharpGatsbyImageArgs, IImageSizeArgs } from "./image-utils" export const ImageFormatType: EnumTypeComposerAsObjectDefinition = { name: `GatsbyImageFormat`, diff --git a/packages/gatsby-plugin-image/tsconfig.json b/packages/gatsby-plugin-image/tsconfig.json index 9f13f0cb51a9e..adcd64efa9520 100644 --- a/packages/gatsby-plugin-image/tsconfig.json +++ b/packages/gatsby-plugin-image/tsconfig.json @@ -14,5 +14,5 @@ "moduleResolution": "node" // "jsxFactory": "createElement" }, - "files": ["./src/global.d.ts", "./src/index.ts", "./src/index.browser.ts"] + "files": ["./src/global.ts", "./src/index.ts", "./src/index.browser.ts"] }