JonasKruckenberg / imagetools

Load and transform images using a toolbox :toolbox: of custom import directives!
MIT License
939 stars 59 forks source link

Directive `basePixels` hidration mismatch with React and SSR #751

Open HriBB opened 1 month ago

HriBB commented 1 month ago

First of all, thanks for this great package. I was able to generate a fully reponsive picture with only a few lines of code, which is amazing! To the problem

I am trying to use the basePixels directive with React, but it does not work as expected. When rendered on the server, I get the correct 1x and 2x images, but when it renders on the client, it switches to 300w and 600w versions, and I get a hydration mismatch.

Packages

This is my component:

import square2x from '~/image/services.jpg?w=300;600&basePixels=300&format=avif;webp;jpeg&as=picture'

console.log(square2x)

const formats = ['avif', 'webp', 'jpeg']

export default function ViteImageTools() {
  return (
    <main>
      <h1>vite-imagetool</h1>
      <div className="my-6 w-[300px]">
        <picture>
          {formats.map((f) => (
            <source key={f} srcSet={square2x.sources[f]} type={`image/${f}`} />
          ))}
          <img
            src={square2x.img.src}
            alt="Testing vite-imagetools"
            width={square2x.img.w}
            height={square2x.img.h}
          />
        </picture>
      </div>
    </main>
  )
}

This is the output in the linux console (SSR)

{
  sources: {
    avif: '/@imagetools/6b96b93013bb7a72f4ff6f967dbd76974b92bfbc 1x, /@imagetools/16bf4d6638f522afb34cd2b0322ce3629a686963 2x',
    webp: '/@imagetools/a9ce3b8e03e646d59be2173e1e8a5ac7b4757714 1x, /@imagetools/8c6aafbb3319208df40ff436fabfc501426a5684 2x',
    jpeg: '/@imagetools/b14e2bce7ff2b46c1553fd7dfeb18b7d3659eeae 1x, /@imagetools/f9ebb33f5f5802dcf07f93c05a54f3068167bd29 2x'
  },
  img: {
    src: '/@imagetools/f9ebb33f5f5802dcf07f93c05a54f3068167bd29',
    w: 600,
    h: 600
  }
}

And the output in the browser console

{
  "sources": {
    "avif": "/@imagetools/6b96b93013bb7a72f4ff6f967dbd76974b92bfbc 300w, /@imagetools/16bf4d6638f522afb34cd2b0322ce3629a686963 600w",
    "webp": "/@imagetools/a9ce3b8e03e646d59be2173e1e8a5ac7b4757714 300w, /@imagetools/8c6aafbb3319208df40ff436fabfc501426a5684 600w",
    "jpeg": "/@imagetools/b14e2bce7ff2b46c1553fd7dfeb18b7d3659eeae 300w, /@imagetools/f9ebb33f5f5802dcf07f93c05a54f3068167bd29 600w"
  },
  "img": {
    "src": "/@imagetools/f9ebb33f5f5802dcf07f93c05a54f3068167bd29",
    "w": 600,
    "h": 600
  }
}

React complains with

A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:

- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.

It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.

https://react.dev/link/hydration-mismatch

 <RenderedRoute match={{params:{}, ...}} routeContext={{...}}>
      <Layout>
        <html lang="en">
          <head>
          <body>
            <App>
              <Outlet>
                <RenderedRoute match={{params:{}, ...}} routeContext={{outlet:null, ...}}>
                  <ViteImageTool>
                    <main>
                      <h1>
                      <div className="my-6 w-[30...">
                        <picture>
                          <source
+                           srcSet="/@imagetools/6b96b93013bb7a72f4ff6f967dbd76974b92bfbc 300w, /@imagetools/16bf4d663..."
-                           srcSet="/@imagetools/6b96b93013bb7a72f4ff6f967dbd76974b92bfbc 1x, /@imagetools/16bf4d6638f..."
                            type="image/avif"
                          >
                          <source
+                           srcSet="/@imagetools/a9ce3b8e03e646d59be2173e1e8a5ac7b4757714 300w, /@imagetools/8c6aafbb3..."
-                           srcSet="/@imagetools/a9ce3b8e03e646d59be2173e1e8a5ac7b4757714 1x, /@imagetools/8c6aafbb331..."
                            type="image/webp"
                          >
                          <source
+                           srcSet="/@imagetools/b14e2bce7ff2b46c1553fd7dfeb18b7d3659eeae 300w, /@imagetools/f9ebb33f5..."
-                           srcSet="/@imagetools/b14e2bce7ff2b46c1553fd7dfeb18b7d3659eeae 1x, /@imagetools/f9ebb33f5f5..."
                            type="image/jpeg"
                          >

To be able to debug this problem, I forked and installed a local copy of vite-imagetools. I put a console.log(metadatas) into output-format.tsx > pictureFormat function and I see, that second log is missing the pixelDensityDescriptor.

First metadata contains pixelDensityDescriptor:

[
  {
    format: 'avif',
    width: 300,
    height: 300,
    space: 'srgb',
    channels: 3,
    depth: 'uchar',
    density: 300,
    chromaSubsampling: '4:4:4',
    isProgressive: true,
    resolutionUnit: 'inch',
    hasProfile: true,
    hasAlpha: false,
    orientation: 1,
    aspect: 1,
    allowUpscale: false,
    pixelDensityDescriptor: '1x',
    src: '/@imagetools/6b96b93013bb7a72f4ff6f967dbd76974b92bfbc',
    image: undefined
  },
  //...
]

Second data does not (also some other props are missing such as chromaSubsampling and others):

[
  {
    format: 'avif',
    width: 300,
    height: 300,
    space: 'srgb',
    channels: 3,
    depth: 'uchar',
    isProgressive: false,
    pages: 1,
    pagePrimary: 0,
    compression: 'av1',
    hasProfile: false,
    hasAlpha: false,
    src: '/@imagetools/6b96b93013bb7a72f4ff6f967dbd76974b92bfbc',
    image: undefined
  },
  //...
]

Not sure if this is a bug or I am doing something wrong ... I think it might be a caching problem or maybe some weird React/Remix race condition. I can create the reproduction repo, when I get back from vacation in a few days ;)

HriBB commented 1 month ago

I am looking at the source code and I see the problem. Image metadata is different when generating from scratch or reading from the cache:

image

basePixels is implemented inside the resize transform, which only happens when applyTransforms is executed, which of course does not happen when reading from the cache. This should probably be calculated in the output function.

So basePixels approach does not work for me. Now I am trying to use the extendOutputFormats, but the problem is that input parameters are not available inside the output function, only metadatas ...

// vite.config.ts

const customOutput: OutputFormat = (config) => async (metadatas) => {
  // how can we get input params here?
  console.log('customOutput', { config, metadatas })
  return { metadatas, todo: 'square' }
}

export default defineConfig({
  plugins: [
    imagetools({
      extendOutputFormats: (builtins) => ({
        ...builtins,
        square: customOutput,
      }),
    }),
  ],
});

// in some page
import square from '~/image/spacenet.jpg?w=300;600&format=avif;webp;jpeg&as=square'

I was able to get some config with:

import square from '~/image/spacenet.jpg?w=300;600&format=avif;webp;jpeg&as=square:300'

config now contains ['300']:

const customOutput: OutputFormat = (config) => async (metadatas) => {
  console.log(config)
  return { metadatas, todo: 'square' }
}

We could simply pass parameters as a second argument to format function here or here. Then we can pass custom parameters and do whatever we want with them in the output function.

@JonasKruckenberg @benmccann I can make a PR, if you think that this is worth fixing/improving ;)