When loading images with different aspect ratios the wrong ratio is applied on initial page load.
In the following example this is shown by loading a 2x3 image on mobile (<678px) and a 16x9 on desktop (>678px).
This only occurs when on the built version of the website. Dev mode is working as expected.
In this repo you will find a description on how to recreate this, as well as an example build of this issue.
Load this website on a browser with a viewport-width <678px.
The image should be displayed with a 2x3 aspect-ratio.
The image is displayed with a 16x9 aspect-ratio.
System:
OS: macOS 10.15.4
CPU: (8) x64 Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz
Shell: 3.2.57 - /bin/bash
Binaries:
Node: 12.14.1 - /usr/local/bin/node
npm: 6.13.4 - /usr/local/bin/npm
Languages:
Python: 2.7.16 - /usr/bin/python
Browsers:
Chrome: 83.0.4103.61
Firefox: 72.0.2
Safari: 13.1
npmPackages:
gatsby: ^2.22.15 => 2.22.15
gatsby-image: ^2.4.5 => 2.4.5
gatsby-plugin-manifest: ^2.4.9 => 2.4.9
gatsby-plugin-offline: ^3.2.7 => 3.2.7
gatsby-plugin-react-helmet: ^3.3.2 => 3.3.2
gatsby-plugin-sharp: ^2.6.9 => 2.6.9
gatsby-source-filesystem: ^2.3.8 => 2.3.8
gatsby-transformer-sharp: ^2.5.3 => 2.5.3
npmGlobalPackages:
gatsby-cli: 2.11.2
gatsby: 2.20.29
@wardpeet if you've not started on this, I hope you don't mind me taking it on. I believe I know the cause and can resolve it :)
Confirmed that it's a hydration bug.
Thanks @jonathangraf for the issue and reproduction example!
The SSR value for the padding style is not overwritten as React assumes during hydration that it's initial state will match the SSR state, no verification is done. So while React client-side may have the correct values at render time, those are set as the initial state and thus no update to the DOM occurs.
eg, Server sends component style padding of 90%, but at the viewport width on client it should be 60% due to <picture> element media condition. React uses the state of 60%, while the DOM retains the 90% style. If a different style for that element is changed like a color from a button press, that can update the color style, but since the state for padding is still 60% on the client, the 90% on the DOM isn't updated as it's assumed to be 60% too.
My suggested workaround for this is to add another state var(bool), that toggles to true when the component has mounted(as checking client vs server side in this case would not work as described with example above) !isHydratedand combine that with a conditional checking client isBrowser. The initial component render will occur where that is true, so we can set some slightly off value, then assign the actual value for image.aspectRatio after mounting the component, this does introduce an additional render pass however.
Something like this:
let aspectRatio = image.aspectRatio
if (isBrowser && !this.state.isHydrated) { aspectRatio+=0.0001 }
It fixes the immediate issue, if someone knows of a less hacky workaround the hydration issue, please share :)
isHydrated is equivalent advice from the React hydrate docs advice with the isClient example, I just figure that relating it to hydration is more clear for it's purpose compared to existing isBrowser which is used before component is mounted.
There will still be a drawback for slow connections until React has loaded in, I'm not sure how that could be resolved, other than embedding some JS script during SSR to update the aspectRatio/paddingBottom value before React kicks in. That's presumably a minor issue, so I'll just push the fix in a PR.
While this kinda works, it results in a flash of the wrong image until the JS kicks in. If the paddingBottom was determined via media queries instead that would eliminate the issue.
So you would actually have multiple placeholders?
@andrezimpel if you are using art-direction which is what triggers the issue, you should already have multiple placeholder images. It's just that the padding-bottom style which controls the aspect ratio needs to correctly match.
My PR resolves that once JS is available, prior requires something at SSR, which has a solution that we can use(style tags out of the head element), but is against html specs, browsers don't have to support such(but they do afaik), and HTML validation/linting if used in CI might not be happy.
Awaiting review/feedback before the PR can be updated to support that.
Hi, am facing a similar issue on "gatsby-image": "2.4.16".
const source = [
itemMobile.fluid,
{
...item.fluid,
media: `(min-width: 768px)`,
},
]
<Img fluid={source} alt={`some alt`}/>
When I load a page on the desktop and then shrink the screen to mobile, everything renders correctly.
When I initially load the page on the mobile phone, the desktop image is loaded. But when I refresh the screen or navigate to some other page and go back, the mobile image will be loaded.
@AndriiChubariev the PRs I have up do resolve the issue, but I can't do anything more, the core devs need to continue the review process so they can be approved for merging.
Having the same issue, would be really nice if @polarathene's PR was reviewed by the core dev team.
Most helpful comment
Confirmed that it's a hydration bug.
Thanks @jonathangraf for the issue and reproduction example!
The SSR value for the padding style is not overwritten as React assumes during hydration that it's initial state will match the SSR state, no verification is done. So while React client-side may have the correct values at render time, those are set as the initial state and thus no update to the DOM occurs.
eg, Server sends component style padding of 90%, but at the viewport width on client it should be 60% due to
<picture>element media condition. React uses the state of 60%, while the DOM retains the 90% style. If a different style for that element is changed like acolorfrom a button press, that can update thecolorstyle, but since the state for padding is still 60% on the client, the 90% on the DOM isn't updated as it's assumed to be 60% too.My suggested workaround for this is to add another state var(bool), that toggles to true when the component has mounted(as checking client vs server side in this case would not work as described with example above)
!isHydratedand combine that with a conditional checking clientisBrowser. The initial component render will occur where that is true, so we can set some slightly off value, then assign the actual value forimage.aspectRatioafter mounting the component, this does introduce an additional render pass however.Solution
Something like this:
It fixes the immediate issue, if someone knows of a less hacky workaround the hydration issue, please share :)
isHydratedis equivalent advice from the React hydrate docs advice with theisClientexample, I just figure that relating it to hydration is more clear for it's purpose compared to existingisBrowserwhich is used before component is mounted.There will still be a drawback for slow connections until React has loaded in, I'm not sure how that could be resolved, other than embedding some JS script during SSR to update the aspectRatio/paddingBottom value before React kicks in. That's presumably a minor issue, so I'll just push the fix in a PR.