gatsbyjs / gatsby

The best React-based framework with performance, scalability and security built in.
https://www.gatsbyjs.com
MIT License
55.2k stars 10.33k forks source link

[gatsby-image] Placeholder flicker/transition when image is cached by browser #25942

Closed polarathene closed 3 years ago

polarathene commented 4 years ago

Description

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

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

Examples

base64_flash_bug

https://i.imgur.com/rRq7BGW.mp4

Related

https://github.com/timhagn/gatsby-background-image/issues/92 https://github.com/gatsbyjs/gatsby/issues/18858 https://github.com/gatsbyjs/gatsby/issues/12254#issuecomment-471278211

https://github.com/gatsbyjs/gatsby/issues/12836

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

https://github.com/gatsbyjs/gatsby/pull/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?


Discussion

I can put together a PR resolving these as best as possible if desirable.


Hydration flicker

Can be resolved by adding keyframe CSS in a <style> element. This allows for having the placeholder opacity at 0, and toggling it to 1 via animation rule.

Drawback is it adds to page weight (although compression may optimize that away), the more images there are each adding that same element and CSS. Unless we place this in the HTML base within the <head> instead, then it can be toggled via className.

A delay would need to be around 200-300ms minimum(based on 200ms until gatsby-image code executed from performance.now() and 10-100ms for a cached response to update state to render), so a flicker would still be visible, it would just be similar to an externally linked <img> flashing in over whatever the background was, instead of a potentially large upscaled 20px base64 pixelated looking image before the cached image renders. The backgroundColor placeholder, especially with an appropriate colour can reduce that "flash" / flicker appearance.

Presently CSS can be used to add a blur filter and reduce the low quality pixelated flicker impact, but still may not be desirable. Likewise, in this case a user could provide the keyframe CSS externally, so long as they have a reliable className to target(not presently supported).

Internally, this does not stop the fade transition. There are two cases to handle for this:

The keyframe CSS can be used after hydration as well and removed once imgLoaded fires. Doing so would momentarily defer the placeholder visibility, if a cached image is not quickly retrievable. On throttled Fast 3G if the server needs to be queried if content has changed due to Cache-Control header expiring, the latency imposes ~500ms(Chrome to local gatsby serve or Caddy server) and 2sec for Slow 3G to return a 304(~128 bytes response). The higher the latency, the less likely the user would witness this flickering issue, so this approach is still valid.

TL;DR

The performance.now() fallback is probably too much of an assumption to include, users would need to wait until v3 with gatsby-image modularized into a composable component to implement this approach if acceptable for them.

imgCached is still worthwhile even if it fails sometimes (would not break anything).

I am not expecting the animation CSS with keyframe in <script> being part of the gatsby-image component by default would be welcomed, especially since it should be possible for a user to configure for, like they would an improved blur-up via CSS filter. The className to target however would be required.


Intersection Observer

Without the CSS keyframes approach, I don't see this feature being possible to skip the initial placeholder rendered frame with page transitions. That frame in my measurements was lasting ~40ms regardless of network throttled speed, when the resource was cached.

Hiding the placeholder via the CSS animation opacity toggle works, but effectively transfers the flicker issue to those loading the resource over network, however, this would only be visible AFAIK during initial page loads where hydration is involved, making it far less likely to be encountered.

[blank] -> placeholder while retrieving JS -> hydration -> [blank] -> placeholder while retrieving image -> image


Native Lazy Loading

Unlike Intersection Observer instances, these have isVisible as true and only hide visibility of the image element until it's loaded. Using imgCached here works well too.

polarathene commented 4 years ago

Caching observations regarding imgcurrentSrc and imgCached use

A browser may avoid waiting on a response for <img> if a cached copy can be displayed in the meantime

When max-age expires and the browser(Chrome in this case) makes a request to check if resource has been modified, on Slow 3G throttled network, TTFB is 2 seconds long, despite this, img.currentSrc was valid prior and the image was rendered before the response(TTFB) was returned, where the handleImageLoaded() method is triggered or img.complete===true. The browser chose to serve the local cached copy without waiting.

A browser may cache an image, but does not guarantee img.currentSrc will be set depending on cache layer

When switching quickly between image variants for an art directed image (with my active PR), sometimes the img.currentSrc is empty, while max-age would not have expired. It pulls it from disk-cache has no TTFB delay due to no network request made("200 OK" response, not "304 not modified"), seems to be reliable for memory-cache or when making a network request though. If cache is disabled, img.currentSrc will be empty as expected and the proper image will not transition from placeholder until handleImageLoaded() fires.

img.currentSrc may indicate a locally cached image, but the rendered cached image may be replaced if the response differs

Final test was to wait for max-age to expire and swap the image for a different image large enough to take a while on Slow 3G to retrieve. img.currentSrc returned the URL, and as expected showed the cached resource prior to waiting on an image response. Once the first byte of the response arrived, the new image I had replaced on disk began to load in..

img.currentSrc is a valid solution when the resource data at a given URI is immutable

This does mean it's technically incorrect in this situation.. especially since imgCached being set would remove the placeholder and immediately reveal the img element, showing the progressive image download(either pixelated, slowly improving clarity as more pixels arrive, or the top to bottom scanlines). That would normally be a problem, but since image URIs are meant to be unique, the browser should never run into this situation where it has a different image than the one it's previously cached for that URI.

Therefore, this approach is still deemed valid, but should probably include a developer note for any maintainers to be aware of this behaviour and expectation/assumption being made with resource URIs provided to gatsby-image.

polarathene commented 4 years ago

For isCritical images, those exist in the DOM prior to React, thus img.currentSrc is always set for these, img.complete appears to be reliable instead and has been used in componentDidMount() which worked until my hydration PR changed the call order with handleRef().


For images that are not using isCritical, img.complete also seems valid for when the memory-cache is used, but not when performing a network request(due to TTFB delay, whereas img.currentSrc is valid), or disk-cache which also doesn't work for img.currentSrc either. Network throttle doesn't appear to influence that, nor the DOM load event relative to the image resource timing.


Notes about `Cache-Control` response header (not likely of value to most) Additional observation, if `max-age` has expired, `img.currentSrc` can be valid when refreshing the page, but when transitioning from another(referencing the basic starter project with image on 1st page and no image on 2nd page), `img.currentSrc` is no longer set.. When memory-cache or disk-cache are used, their behaviours are unchanged and still behave as described earlier. For this observation, the response headers were slightly different. Additional fields were in the response header, `Accept-Ranges: bytes`, `Content-Length: `, `Content-Type: image/jpeg`, `Last-Modified: `, everything else looks the same though and both are a `304 Not Modified` :shrug: --- Sometimes these are 200-OK responses, and always if visiting the page/url via new tab if the cached resource has become stale, seems soft reload/refresh does something different, where I guess if the URL hasn't changed for the active page, I get the better caching behavior. A hard reload adds to request headers `Cache-Control: no-cache` and `Pragma: no-cache`, which can affect soft reloads as until cached resource becomes stale again, those request headers are shown for the resource still, instead of memory-cache stating it's only showing provisional headers. Chrome also offers a `Empty cache and hard reload` option, which I assume is equivalent to checking "disable cache" in network tab and doing a hard reload, where content will download regardless. Similar to experiences with the "Performance" profiling tab in dev tools, where I noticed some behavior changing, I had found the browser was using cached response headers with soft reloads that should have been different due to revalidation when stale, perhaps because the resource was a 304, it did not update the response headers and those were cached with the resource. --- Noticed that the addition of `no-cache` request headers in a hard reload only affects "Online"(none) network throttling, which seems to be re-downloading content again, whereas throttled network will utilize memory/disk caches and doesn't add `no-cache` to the request headers.. Another difference with hard vs soft reload is the image is always returning a 200-OK response, including once stale and validating for an update, additionally, I have tested with a replacement image of different dimensions in the static file location, in soft refreshes where validation request is sent, the image resizes to fit the width/height of the image wrapper that has the original image placeholder base64, then once the validation response is returned, it will show the image with it's proper proportions cropped with object-fit, memory/disk caches skip the distorted step and hard reload doesn't have the distorted render either during revalidation. The brief distorted image did happen to render on hard reload "Slow 3G" when the image actually had changed, provided a cached resource was available. Possibly related to not returning a `304 Not Modified` with hard reloads, despite request headers having the `ETag` and `Last-Modified` equivalent cache checks `If-Not-Match` and `If-Modified-Since` values. Another update, while hard reload with "Slow 3G" resulted in no temporary distorted image like a soft/normal reload, which also seems to mean no `img.currentSrc`, hard reload with "Fast 3G" does... The inconsistency here for testing caching/image loading is a bit of an annoyance.. Loading a fresh tab(without other tab instances for the site which appears to interfere) and using "Fast 3G" or "Slow 3G" doesn't trigger the distorted image when revalidating a stale cache. --- Used `Cache-Control` of `no-cache` and hard reload "Slow 3G" did not ever display the distorted TTFB duration image like soft reload does for all speeds, and hard reload "Fast 3G" was showing the distorted image, "Online" for hard reload does not and always downloads the image as if it were `no-store`. "Online" for soft reload was showing the distorted image too despite TTFB and content download being <2ms combined. Created a custom network throttle that only ups latency to around 5 seconds. Soft reload reveals the distorted image until the pending request returns a response(TTFB delay), while hard reload skipped the distorted image showing the base64 placeholder until response returns. These were both testing reloads after cache was stale for `public, max-age=20`, although the behaviour appears the same with `must-revalidate, max-age=20`. The hard reload case is what matches the new tab behaviour as well. Image distortion is conforming to the image components width/height attributes(fixed image type), with `object-fit: cover` only applying once the image response has finished. Distorted is shown if the cached response is used prior to revalidation response on a stale cached image. --- Noted that soft reloads with the increased latency didn't show a distorted image if other assets such as js and document caches had expired. Then reduced latency and that brought the behaviour back up again, seems consistent between soft/hard reloads then. For all I know this distorted image stage is some optimization for lazy loaded images and could be ignoring cache-control expectations when cache is stale and should revalidate. --- https://engineering.fb.com/web/this-browser-tweak-saved-60-of-requests-to-facebook/ > The browser’s reload button exists to allow the user to get an updated version of the current page. In order to meet this goal, when you reload, browsers revalidate the page that you are currently on, even if that page hasn’t expired yet. However, they also go a step further and revalidate all sub-resources on the page — things like images and JavaScript files. Although Chrome made changes to that behaviour (and soft/hard reload appears to have been available for a long time prior), so I'm just going to assume reloads aren't particularly reliable way to test cache-control/revalidation.. The article also links to Chrome and Firefox announcements from back in 2017 related to the FB cache revalidation optimizations. Then there's also the mention of browser caches not being reliable/consistent here: https://github.com/web-platform-tests/wpt/tree/master/fetch/http-cache Some inconsistencies across implementations can be noted here: https://cache-tests.fyi/

Chrome, Opera(Blink based), Epiphany(webkit based, similar to Safari afaik) all seem to behave similar with the currentSrc attribute having something to indicate the image is cached, but Firefox does not mimic that and always appears to be false(at this point in time when queried). So currentSrc won't work with firefox.

mafiusu commented 4 years ago

I've the same problem showing a Logo on the top left side. Every time I click on a page the Logo flickers. This is uncommon. I used useStaticQuery with childImageSharp.fixed.

polarathene commented 4 years ago

This is uncommon.

What is uncommon? The flickering of the low quality placeholder is reproducible, just need to refresh/reload or visit the page via url. It should only happen once when you visit the page, then the rest of the session it should be in the gatsby-image internal cache, which should prevent that visual appearing until the next visit of the site.

If you want to avoid it, since the logo is probably quite small, just avoid the base64 placeholder, if you look up the graphql sharp API that mentions using childImageSharp fragment, there is another one with _nobase64, or on the image object data you pass to gatsby-image fixed prop, you can set the base64 field to ""(empty string) that should work too I think.

For larger images where you want the placeholder while the real picture downloads, it's more of an issue.

mafiusu commented 4 years ago

@polarathene thank you for the answer, it helped. It was the blur up effect that I found uncommon but that's because I'm new to Gatsby. I should have read the documentation further. It says:

If you don’t want to use the blur-up effect, choose the fragment with noBase64 at the end.

github-actions[bot] commented 4 years ago

Hiya!

This issue has gone quiet. Spooky quiet. 👻

We get a lot of issues, so we currently close issues after 60 days of inactivity. It’s been at least 20 days since the last update here. If we missed this issue or if you want to keep it open, please reply here. You can also add the label "not stale" to keep this issue open! As a friendly reminder: the best way to see this issue, or any other, fixed is to open a Pull Request. Check out gatsby.dev/contribute for more information about opening PRs, triaging issues, and contributing!

Thanks for being a part of the Gatsby community! 💪💜

polarathene commented 4 years ago

Not stale.

github-actions[bot] commented 4 years ago

Hiya!

This issue has gone quiet. Spooky quiet. 👻

We get a lot of issues, so we currently close issues after 60 days of inactivity. It’s been at least 20 days since the last update here. If we missed this issue or if you want to keep it open, please reply here. As a friendly reminder: the best way to see this issue, or any other, fixed is to open a Pull Request. Check out gatsby.dev/contribute for more information about opening PRs, triaging issues, and contributing!

Thanks for being a part of the Gatsby community! 💪💜

LekoArts commented 3 years ago

The old gatsby-image is deprecated now, if you still see this same behavior in gatsby-plugin-image please open a new issue with a minimal reproduction. Thanks!