Skip to content
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

feat(gatsby-image): Image cache logic improvements #26090

Closed

Conversation

polarathene
Copy link
Contributor

Problem

Using a placeholder such as base64 is nice to have for first retrieval of assets. However when they are cached this results in:

  • Brief flicker during hydration due to inlined placeholder.
  • When navigating to a page with an image if no hit within the internal gatsby-image cache:
    • Brief flicker for lazy loading via Intersection Observer.
    • Transition triggered for native lazy loading.

It's a minor UX issue, but it would be nice to smooth these out.

Examples

base64_flash_bug

gimagecachebug

Description

When loading a Gatsby page with an image in the viewport, if the page has been previously visited and the browser has cached the image, there is still a flicker of a placeholder if used(base64/SVG), this can look unsightly/jarring and be unexpected.

This PR tries to resolve that issue as best as possible, favoring a blank(no placeholder) rendered frame over a frame with a placeholder. This is a React rendered frame, in that it can last longer than 16ms, dependent on when the next frame is rendered, often long enough to be visible for a moment that it's noticeable.

Not fully tested, nor is the approach a complete solution(currentSrc technique does not work for Firefox, a variety of other browsers that I've tested with are able to leverage it).

Individual commits provide better details on changes, and try to make for easier review.

Related Issues

Original attempt prior to native image lazyloading in Chrome/Firefox: #12254 (comment)

A user mentions it was a problem for them when providing their Gatsby site for offline usage, they ended up switching to SVG resources: #12254 (comment)

Fixes: #12836

when the actual images load fast, the placeholder is more irritating then helpful.

Fixes: #18858

I noticed that the image flickers when I am changing between pages (eg. between home and page 2).

Previous PR by me introducing imgCached: #12468

A prior PR of mine mentions the issue will exist for images not using Intersection Observer render path: #14158

imgCached will be false in situations that IO isn't used for lazy-load, thus shouldFadeIn won't be disabled, which afaik means we'll probably run into #12254 again?

Fixes: #25942 (recent issue by me looking into the issue)

Related: #22255

  • Unclear how to reproduce "crash", this PR addresses linked PR TODO comment.

Introducing non-IO logic into `handleRef()` in future commits. Guard is to prevent running code when `ref` is null(component unmount).

`isVisible` guard should be safe to move. Will only run the logic once now as it should, once visible. Beneficial to Art Directed images, so that each variant doesn't create a IO listener as they're switched in via another PR that introduces `handleImageVariant()`.
This method will be useful in future commits elsewhere as well, hence is DRY.

It also allows for simplifying logic (at the expense of some more verbosity), dropping the usage of `!!(currentSrc)`, as instead of casting to a bool, we can rely on falsy values in conditional statements instead.

`imageInCache` is used to decide if a single render can be performed instead as `setCachedState()` would be unnecessary, otherwise we make the image visible to begin loading it and can attempt to check if the browser has cached the image, doesn't work for Firefox however.
Also adds a guard to not pointlessly set the `imgLoaded` state in `handleImageLoaded()` to true if it already is (triggers a render).

It can otherwise hide or create problems. Such as a picture element in art directed images changing source, React would render the component again only after new image is downloaded and fires `handleImageLoaded()`, which was too late anyway on slow connections.
This is only a fix if the hydration PR is merged, as the order of `componentDidMount()` and `handleRef()` changes. That impacts when `imageRef` is available.

The loaded event should fire afterwards still in my testing. When this feature was originally added to `componentDidMount()` it was to ensure transition CSS did not need to be applied if the image had already loaded.

As the `handleImageLoaded()` is still called, other logic there should continue to run at that point as expected.

`img.complete` is still more reliable here than `img.currentSrc` as by this point, since such images are already apart of the original DOM, they will likely have a `img.currentSrc` available, even if they've not finished loading, if they have then `img.complete` will be true.

Potentially a cache detection concern after initial page load for critical images, as they'll effectively be like any other image, `img.complete` isn't likely to be useful at this point, where `img.currentSrc` should instead be the more reliable source of information..
Presently, due to v2 of `gatsby-image` avoiding breakage, classname support is for the `img` element of the placeholder, `picture` was introduced afterwards.

If one wants to avoid the placeholder image being briefly visible on page load due to it being inlined into the DOM, a CSS animation with CSS keyframes can set opacity to 0 and after a delay set it to 1, or smoothly transition to reveal the image.

If the image is already cached, this would instead allow for not revealing the placeholder for 300ms or less if React hydrates before then and the animation is appended as a `<style>` element that's only added for SSR.

Cannot apply such animation to the `img` element which has `opacity` as an inline style. `!important` does not work for overriding it with CSS `animation` which ignores `!important` in keyframes, setting the `opacity` value with `!important` would prevent `animation` from applying different values. Thus, this opacity toggle needs to be applied one level higher, `gatsby-image-wrapper` alone is not useful if you want to still have a `backgroundColor` visible, unless you target a specific hierarchy and expect that not to break.
Renders a blank content for initial frame rendered, prevents the otherwise unavoidable frame of placeholder being rendered during a page transition when the image has not been added to the internal cache(which skips the Intersection Observer render path).

Drawback is a slight flicker with initial page loads (between inlined DOM placeholder and hydration). Combined with CSS keyframes would only be noticeable when loading an HTML file with images  for the first time (or longer than the CSS keyframe opacity delay).

Under ever other render path `isVisible` is true, so nothing else is impacted. Trades a better UX for repeated visits instead of first time.
Reworded for better clarity. Added note about the IO render path and future instances/mounts of the image resource.
Found a render bug with Chrome if displaying the `img` element prior to the image request returning a response.

Most notable on fluid type images, if the requested image does not share the same aspect ratio of it's container, it will be distorted (resized to conform to the container aspect ratio) until the response is returned.

Appears to be a bug with Blink implementation of `object-fit: cover`, it's as if that rule is not applied until the image response has returned.

The delay is minimal in testing with the browser "Online" network, so opting to hide the placeholder like `imgLoaded` would, but not reveal the main image straight away does resolve the visual issue nicely when paired with CSS keyframe animation opacity delay.

However, on higher latency networks, at least when simulated with Chromes "Fast 3G", 300ms was not sufficiently long enough for the CSS keyframe animation prior to React kicking in, so placeholder blur was visible for a moment before hiding placeholder again. Not visible for non-cached images, but potentially degrading UX for repeated visits on higher latency connections.

Could use `performance.now()`to know when `gatsby-image` has started since browser load to opt-in/out of `imgCached` behaviour if it exceeded the CSS keyframe animation duration.

Note that prior to this PR, the `object-fit: cover` distortion on fluid images is present when `fadeIn` prop is set to false, provided the container and image differ in aspect ratio.
Required for both `currentSrc` and `complete`, better to check early.
`<picture>` is only used for the Art Direction feature, thus this is an unreliable element to add a class for targeting CSS animation on. For now, the wrapper class will have to be targeted instead.
Prettier rules removed the parenthesis around the OR condition that followed a ternary statement assignment, personally that reduced readability, so extracted to a variable.

To avoid confusion with the similar opposite named variable, it was renamed and better describes it's purpose.
@polarathene polarathene requested a review from a team as a code owner July 29, 2020 08:08
@gatsbot gatsbot bot added the status: triage needed Issue or pull request that need to be triaged and assigned to a reviewer label Jul 29, 2020
@polarathene polarathene marked this pull request as draft July 29, 2020 08:14
@polarathene
Copy link
Contributor Author

Drafting, but would appreciate review/discussion to continue.

  • isCritical is handled for initial page loads, but would likely need to use currentSrc for other scenarios such as page changes where the image is not in the internal imageCache.
  • Discovered a slight render bug with Chrome on fluid images when aspect ratios do not match between image and container(some clipping, which object-fit: cover handles). Initially had imgLoaded state being adjusted with imgCached, removed as it made this issue more visible, have left comments about imgLoaded as-is due to draft state, if it's a browser bug with Chrome, will see if that can be fixed instead. Delaying imgLoaded state update does mean another render frame.
  • Intersection Observer skipping the initial placeholder frame still introduces a flicker, although this isn't really avoidable, it may still be preferable, would prefer others to weigh in on that.

Could include the CSS keyframes animation as a <style> embed, or add to docs as a solution to the hydration flicker of placeholder content:

const isBrowser = typeof window !== `undefined`
// ...
return (
  <>
    { !isBrowser && (
    <style>
      {`
    .gatsby-image-wrapper {
      opacity: 0;
      animation:0s ease 0.3s normal forwards 1 gatsby-placeholder-delay;
    }
    @keyframes gatsby-placeholder-delay {
      from { opacity: 0; }
      to   { opacity: 1; }
    }`}
    </style>
    )}
    <GatsbyImage {props} />
  </>
)

Unfortunately, cannot target the img element properly as it has inline styles, and for CSS keyframes/animation, these cannot be overwritten with !important as this CSS rule ignores it, must target a parent element or not use inline styles so they can be properly modified.

@polarathene polarathene added help wanted Issue with a clear description that the community can help with. topic: media Related to gatsby-plugin-image, or general image/media processing topics type: feature or enhancement Issue that is not a bug and requests the addition of a new feature or enhancement. and removed status: triage needed Issue or pull request that need to be triaged and assigned to a reviewer labels Jul 29, 2020
@polarathene polarathene self-assigned this Jul 29, 2020
@polarathene
Copy link
Contributor Author

polarathene commented Aug 4, 2020

Test observations

Prop: fadeIn={false}, Cache-Control: max-age=0, browser reload (hydration flicker)

  • Chrome and Opera(uses Blink) both show image distortion from object: cover. Only an issue during revalidation when image container aspect ratio differs from image aspect ratio. Non-issue with stale-while-revalidate=0 in supported browsers, cache will be allowed while a revalidation happens.
    fadein_false_maxage0_reloads_revalidate_chrome
    This one shows brief placeholder visibility that will happen if disk-cache(empty currentSrc) is used instead of memory-cache(works well), little laggy due to poor frame rate of gif:
    fadein_false_maxage0_reloads_revalidate_chrome_diskcache200
  • Firefox has a brief blip of base64 placeholder:
    fadein_false_maxage0_reloads_revalidate_firefox
  • Epiphany(uses Webkit) has brief blip of backgroundColor placeholder(due to using Intersection Observer). Safari presumably renders the same:
    fadein_false_maxage0_reloads_revalidate_epiphany

Worth noting:

  • Chrome optimized their reload button to not perform a full revalidation of sub-resources with the server via collaboration with Facebook in 2017, the object: cover issue from an aspect-ratio mismatch on fluid images to their container is due to width/height being 0/undefined afaik until a validation has occurred when the resource is considered stale and needs to revalidate. This can be avoided if run into, provided the Cache-Control response header can be configured, max-age=0 doesn't explicitly have to validate (like must-revalidate directive), but afaik Chrome appears to enforce revalidation and not use a cached resource if it's cache has become stale.
  • Firefox keeps the reload button as a full revalidation of resources, and through collaboration with Facebook implemented the immutable cache response header to avoid revalidation if not necessary (Cache-Control: max-age=600,immutable makes no difference to results shown above).
  • Safari/Webkit may see progress on native img loading attribute support later this year for a future release. Unclear how that may differ in implementation, or behave similarly to Chrome/Blink.

Prop: fadeIn={false}, Cache-Control: max-age=600,immutable, browser reload (hydration flicker)

Just to showcase Chrome working well with memory-cache, other browsers still render the same as above:
fadein_false_maxage600_reloads_revalidate_chrome

Note: The lack of backgroundColor placeholder is due to applying the CSS keyframe opacity toggle on .gatsby-image-wrapper due to issue with targeting opacity on the placeholder and it's inline CSS having priority.

Prop: fadeIn={true}, Cache-Control: max-age=0, browser reload (hydration flicker)

Here Chrome shows that the image revalidation distortion could be avoided from displaying by delaying imgLoaded state from being set as true when browser-cache is detected, and instead setting it once the image has loaded. Briefly displays the backgroundColor placeholder if used, else blank:

fadein_true_maxage0_reloads_revalidate_chrome

This is probably not as nice on Fast3G and Slow3G however, so perhaps should be reverted (now that the issue is better understood as a revalidation issue), these throttled network recordings are with cached images, the delay is from high TTFB/latency to perform the revalidation check(asks server if the image change from cached copy since it became stale):

  • Fast3G:
    fadein_true_maxage0_reloads_revalidate_chrome_fast3G
  • Slow3G:
    fadein_true_maxage0_reloads_revalidate_chrome_slow3G

No difference in the other non-blink browsers available to me, mostly because Firefox isn't going to trigger imgCached, and the Epiphany (webkit) browser I have has poor dev tools for networking, no throttle option afaik. Firefox does play the fadeIn transition with that now enabled and it unable to detect browser-cache via this JS approach.

Prop: fadeIn={true}, Cache-Control: max-age=600,immutable, browser reload (hydration flicker)

Here is Chrome results to show memory-cache rather than revalidation issue that max-age=0 would trigger, this is the desirable outcome that this PR fixes at least for this case, and stale-while-validate=N fixes for the revalidation issue providing the same result:

  • Unthrottled (Online):
    fadein_true_maxage600_reloads_revalidate_chrome
  • Fast3G:
    fadein_true_maxage600_reloads_revalidate_chrome_fast3G
  • Slow3G:
    fadein_true_maxage600_reloads_revalidate_chrome_slow3G

The above is with the imgLoaded: true code added back for cache state, and reverting imgCached hiding the placeholder(which becomes redundant). If using the current code of this PR with that imgLoaded state being deferred until image actually fires the loaded event, then the backgroundColor placeholder is still briefly visible(Slow3G):

fadein_true_maxage600_reloads_revalidate_chrome_slow3G_hideplaceholder

@polarathene
Copy link
Contributor Author

There has been a report of Safari with Intersection Observer for lazy loading always reporting currentSrc as true. This affects imgCached logic in existing iteration of gatsby-image, not just this PR. The user got in touch with me on the Discord server and we had some back and forth to confirm the issue.

I'd still appreciate a few other Safari users to confirm that currentSrc is always true, it should not be with an empty browser cache,only after the image has been downloaded and the browser keeps a copy in memory or disk caches.

That behaviour is the opposite of Firefox which would always report false. And more interestingly conflicts with Epiphany (Webkit browser) which I've been using for Safari comparisons as it's usually able to reproduce the same issues/behaviour.

@eszterkv
Copy link

eszterkv commented Sep 20, 2020

hey @polarathene, I've just tested this on:

I can confirm that currentSrc is always truthy in Safari desktop. Just to be sure, I also checked in Firefox, and, as you say, it's always falsy there.

The effect is that, without the CSS hack described in #20126 (comment), the images just appear suddenly, instead of fading in nicely.

@polarathene
Copy link
Contributor Author

@c0derabbit Awesome thankyou, did you happen to log to console the truthy value? Was it an asset URL? I'm curious if it's the appropriate one on desktop when you load the page without browser cache at different viewport widths or if it's just placeholder filling it with the img fallback URL instead of an appropriate <source>.

Safari 14 was released recently (September 16th 2020) and available on Catalina, this has native image lazy loading afaik. As gatsby-image only uses imgCached for browsers with the fallback Intersection Observer API you wouldn't run into that issue. This PR does bring the imgCached feature to Chrome/blink browsers, and Safari 14 (but that might also remain broken as it uses the same detection logic) although it seems for Safari 14 native image lazy loading is experimental only, not enabled by default.

However with recent updates to gatsby-image, I'm not sure if this PR is compatible anymore as-is. I will contribute a PR to remove the caching feature since the bulk of current users for it would be Safari where it's clearly broken. Thank you for taking the time to go through all that and respond, I appreciate it!

@polarathene
Copy link
Contributor Author

@c0derabbit PR available, I've not tested it, but it should make Safari users of gatsby-image happy once more.

@wardpeet
Copy link
Contributor

wardpeet commented Nov 9, 2020

Closing, this is fixed in our new gatsby-plugin-image component. Thank you for your contribution! <3

@wardpeet wardpeet closed this Nov 9, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Issue with a clear description that the community can help with. topic: media Related to gatsby-plugin-image, or general image/media processing topics type: feature or enhancement Issue that is not a bug and requests the addition of a new feature or enhancement.
Projects
None yet
3 participants