-
Notifications
You must be signed in to change notification settings - Fork 10.3k
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
fix(gatsby-image): React hydration buster on image
#24811
Conversation
This comment has been minimized.
This comment has been minimized.
aspectRatio
image
This comment has been minimized.
This comment has been minimized.
As discussed below, this should probably be addressed at SSR by adding `<style>` in DOM body considerationsFor fixing the CSS before react has loaded, I have been suggested to use the `<style>` element within the HTML body, instead of the documents head. However, while that is supported by most browsers AFAIK, [it is against spec](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style), so HTML validators will flag such usage, which may be undesirable for a core gatsby package?There is advice like this around on the internet from a while ago that suggests it's ok because of the If we ignore this and just add |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
I mistakenly posted this on the issue instead of the 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. |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
@polarathene is this something we can test in our e2e tests? I would love to have this cause this feels like something that can easily break in refactors |
I have no e2e test experience, and I'm not sure if anyone on the gatsby team has the spare time to provide personal guidance on that? Is the I'm available on the Discord server(same username) if someone knows how to approach writing the tests and can get me up to speed with e2e testing. I am not sure how much of a time commitment that would be for me to figure out on my own.
|
This comment has been minimized.
This comment has been minimized.
Expand for more details of implementationI've added support for Outdated notes on previous implementation (with benchmark)I previously converted the number to a base64 string, but noticed the performance and additional code complexity wasn't worth it vs base36. You can see a comparison benchmark(including against An alternative hash would be |
Using DJB2-XOR for hashing now, it performs well, Details of change to DJB2 hash, includes benchmarksI have looked into DJB2 with the following bench test against short and long strings with some variations. A newer variant over the original uses XOR( Local naive test with
Same results in Chrome however, JSON is much faster, less than Reducing overhead from conversion back to stringThe following would be a way to improve performance if the extra code is worth it(I doubt it): Initialize with a LUT for mapping a 10-bit index to two characters at a time: // Takes the base32(5-bit) charset, equivalent to toString(32)
// Creates a Lookup Table of two pairs(2^10, 10-bit == 1024)
const charset = "0123456789abcdefghijklmnopqrstuv".split('')
const LUT = charset.map(x => charset.map(y => x+y)).flat() Direct lookup seems to have minimal perf overhead vs const n = djb2_xor(long_str)
const mask = 0x3ff
return LUT[ (n >> 30) & mask ] +
LUT[ (n >> 20) & mask ] +
LUT[ (n >> 10) & mask ] +
LUT[ n & mask ] Drawback with the A slightly more performant const shifts = [30,20,10,0]
const n = djb2_xor(long_str)
shifts.map(m => LUT[n >> m & 0x3ff])
.join('')
.replace(/0*/,'')
// or
let hash = ""
shifts.forEach(m => { hash += LUT[ (n >> m) & 0x3ff ] })
hash.replace(/0*/,'') Technical explanation of DJB2 algorithm choices / comparisonI investigated I also did some tests on other variants, original Thus XOR is the way to go. |
9bcaf37
to
1d2e444
Compare
The other notable rendering issue from hydration is on `fixed` images with their width/height values, there are other image properties that get referenced and may pose a hydration issue, I've opted for a blanket empty object initial state instead for `image`, and moved the variable out of the conditional.
Inversing the condition logic so it can avoid rendering the component until mounted(hydrated). Previous logic worked, but would render the `fluid`/`fixed` markup and CSS, despite referencing an empty `image` object, notably for the aspect ratio calculation (`padding-bottom` style), this resulted in an invalid value, causing e2e tests to fail. The initial render of the component prior to mount will assume served HTML/CSS styles can be trusted as matching state for hydration, this isn't always the case so enforce a re-render upon mount.
While not compliant to HTML spec as `<style>` should only be used within `<head>` in the DOM, this approach is used elsewhere such as with Emotion. This fixes the the issue with appropriate CSS for art direction prior to React being available on the client. As each component instance needs to be individually targeted(the `<style>` is not scoped otherwise), a classname unique to the image data is required. This has been handled via a simple and fast hashing algorithm which shouldn't have any collision issues. `uniqueKey` generates a 6 character long(roughly base64) class name, and does so faster across multiple `srcSet`s than the previous `JSON.stringify()` approach did on a single `srcSet`. An alternative simple hashing algorithm, djb2 could be used. This is what styled-components uses as it's faster, but may result in a slightly higher risk of collisions.
Split hash method and post-process of hash into separate functions. Added some helpful comments to explain why the final value is modified. The use of bit shifts with addition instead of multiplying against the prime appears to perform slightly worse in JS, so keep it simple and more readable instead. For reference: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV_hash_parameters http://www.isthe.com/chongo/tech/comp/fnv/ > 32 bit FNV_prime = 2^24 + 2^8 + 0x93 = 16777619
Also avoid generation of `<style />` if only one image available (no art direction used).
Refactors the condition out of the `ResponsiveQueries` component. The style component and class name should additionally only be used for SSR and can be discarded from the client side unless we later have use for it.
With the SSR only render, this feature is no longer detected by Jest when creating snapshots.
The while loop was also iterating the wrong way, whoops. This algorithm works alright with 32-bit integers, and overflows, but JS uses 53-bit floats which leads to inaccurate results. One way to avoid the issue is like so: ``` // Split into two 16-bit values, multiply then combine to a 32-bit value h = (((h >> 16) * fnvPrime) << 16) + ((h & 0xffff) * fnvPrime) ``` However that has worse performance and readability vs the bit shift equivalent of multiplication on the prime. Finally, the returned value also needs to be cast to 32-bit uint. `fnv1a32("hello").toString(16)` now correctly returns `4f9f2cab`.
base64 code and handling can be dropped for base36 instead, natively supported in JS `.toString(radix)`. Numbers are part of the charset, so the underscore prefix is still relevant. Only downside is slightly longer class name, from average 5.33 bits to 6.4 bits, values are now 6-7 in length(+ prefix).
DJB2 performs similar to FNV-1a-32, but the XOR(`^`) variant has a perf boost, `styled-components` adopted this variant for v5 early in 2020.
Instead of guarding the fluid/fixed render logic, just guard at the start of render and return null early. Avoids unnecessary indent and execution of logic that won't be used. Also changes unique key to a const by replacing the if statement for a short-circuit assignment. Potentially failed tests due to looking up array length when tests might have been on a single object where length would be an invalid property.
Not sure how this PR is affecting these, but looks like a whitespace issue is failing the tests..
Inlined logic and split each style into their own `<style>` tag, moving the query to the `media` attribute.
Move `uniqueKey` into constructor. It is intended for SSR, so it should be defined under the assumption a `fluid` or `fixed` object is provided to use it during build. Avoids recomputing pointlessly at render time. Renamed `hasArtDirectionSupport()` to `isUsingArtDirection()` which is clearer in intent.
Previously, the browser would update the `<picture>` element image to match a new media condition. Once the request responded with an image to download, the `onload` event triggered and reports `ref.complete` from the `img` element as true. That then triggers `handleImageLoaded()` which would always set the `imgLoaded` state to true, even it already was. That triggers React to re-render which then notices the `img` src has changed and adapts to the correct image variant data for rendering. The re-render was delayed until a request from the server responds with the new image to begin downloading. This is a notable transition period on slow connections such as 3G. There's no placeholder once the re-render occurs, instead, the image will progressively load. The `handleImageLoaded()` event and state update will happen again on completion triggering one more render, as the re-render modified the DOM, resetting the `onload` state(which AFAIK, isn't specific to any `img` element, but the DOM as a whole). This change listens for the media queries via JS API, and promptly re-renders as soon as it actually should. The cache is properly checked for the variant to know if the transition from placeholder should occur or be skipped, just like a regular gatsby-image instance with no art direction involved. As we're triggering the re-render sooner by setting `imgLoaded` to `false`, we don't have to rely on `imgLoaded` state (with no change from `true`) in `handleImageLoaded()` to get the ball rolling with a re-render now. Instead we can ensure that it only sets `imgLoaded` to `true` as it's intended for, only when the state is `false`.
Adds a package to provide better mocking functionality, no longer raises error about missing methods. Improves the existing usage in `gatsby-image` tests.
Leave `fadeIn` logic to `render()` method, otherwise as soon as it's set to false, it will always be false.
84b812d
to
f39122a
Compare
I'm going to split the PRs out, should be easy enough to review the basic hydration fix, then consider the |
Thanks for your detailed response. In the end, PRs get squashed and merged so individual commits are mostly useful for review time. But when a PR contains 22 individual commits I tend to skip the individual commits in favor of the whole PR because it's just more efficient. Otherwise often you'd be reviewing a commit and then read a followup commit undoing that because reasons. I'm very sorry if you did happen to have 22 clean commits. I wish there were more of you.
Yes, looking at the PR on a whole, I think that should be split out.
Unfortunately github doesn't really do "stacks". So your suggestion to have a chain of PRs might be best. I think two is fine here? One with the mock change stuff and then one followup PR with the concrete changes?
It literally checks if the
I think you created good splits. Would be great if you can create them as PRs.
Sorry about that. The team isn't that large and we have a lot on our plate. I merged the PR that you mentioned, sometimes things just slide off our radar. That and holiday season ... Oh well. Request a review from me in these split PR's and I'll try my best to keep up. Thank you! 💜 |
They're reasonably documented/focused commits, but some are outdated after original submission. I will split the PR apart into clean commits and hopefully we can get them merged with less delay :)
I'm available for any paid part-time/full-time remote work if that'd help 😅
Thanks! I understand that how that is, all good. I created a tracking issue, mostly for myself for the image related PRs and will continue to chase those up.
Will do! Thanks for taking the time to review and merge these, it's very much appreciated. |
@pvdz PR split to the following:
Closing this PR. |
Description
If you use art-direction with differing aspect ratios(fluid) or dimensions(fixed), SSR bakes in the CSS for the first image, but the first load(hydration) expects the initial state to match the CSS, this is not always the case if your breakpoint differs from SSR, React doesn't realize it needs to update the CSS.
The PR provides addresses this by setting
image
variable to{}
for initial state(first frame), so that React will update image state correctly with the next rendered frame(triggered when the component mounts).Related Issues
Fixes #25938
Fixes #24748
Fixes #16888
Fixes #16763
Extra details
Original issue
SSR and Client states differ for what image is used which can change the
aspectRatio
affecting thefluid
image componentbottomPadding
style, causing incorrect rendering.isBrowser && !isHydrated
to bust the hydration stateThis introduces a new bool state value as advised for handling this situation by the official React docs on hydration. I combine it with
isBrowser
to temporarilyadjust theset an empty initialaspectRatio
state a minimal amountimage
state before the next render(triggered upon component mount once hydrated).Unable to resolve for slow connections prior to React being loaded
That would require embedding JS during SSR to run ASAP on the client, or injecting style tags into the HTML
head
covering each images breakpoints(I know how to do this withstyled-components
, but not without it.Marked some discussion below as "outdated" to hide it, it's all addressed in visible content, should help minimize time needed to review.
Main considerations for reviewer:
<style>
be added to the component with opt-out prop? (handles drawbacks, while the main hydration fix will still work)