React JS: Image size flickers on reload with object-fit: cover CSS property

Viewed 1189

I am displaying an image in a react app created using CRA. Every time I reload the webpage, the image flickers weirdly as below.

Image initially:

enter image description here

Image after flicker (actual requirement):

enter image description here

Code:

styles.css:

img {
  width: 200px;
  height: 300px;
  object-fit: cover;
}

app.tsx

import React from 'react';
import foo from './foo.jpg';

import './styles.css';

const App = () => <img src={foo} alt="foo" />;

export default App;

package.json

{
  "name": "xyz",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.5.0",
    "@testing-library/user-event": "^7.2.1",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react-scripts": "3.4.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

Notes:

  1. The app is an injected boilerplate without any 3rd party libraries installed nor any other components apart from this image.
  2. The issue above gets fixed if object-fit: cover; property is removed from stylesheet but that's required in order to prevent the image from being stretched/shrink weirdly just like in initial image.
  3. If the issue is not reproducible, please keep developer console open or try changing network to any of the 3G presets. I can reproduce with repetitive reloads easily.
  4. I believe that object-fit: cover property doesn’t get applied to the image initially and takes few milliseconds to kick in.
  5. Notice that the DOM structure doesn’t show up in developer console in initial image.
  6. Any alternate to the CSS property would also be helpful.
1 Answers

Having messed around with this for a good while now, I am pretty much convinced that it is a performance bug with Chromium browsers. Specifically, it seems related to the caching of images, and the optimizations that the browser makes when loading images into an img container with explicit height and width dimensions.

I say this because in my tests, emptying the cache before a reload will make the browser apply object-fit before the first paint happens. I also noticed that regardless of object-fit being present, cached images will be loaded into an img container with defined height and width a split second before img containers with only one or neither of these dimensions. This appears to be where the bug is occuring. The object-fit rule is only being applied during the second paint layer.

Maybe you can try and reproduce this with the following snippet to confirm? I've used a random placeholder image here but you might want to use a local image. If anyone can provide a more detailed explanation I'd be interested to hear it. As mentioned in the comments, this is reproducible with only HTML/CSS and network throttling, although I have found the effect to be much more pronounced in a React context:

const { Fragment } = React;

const App = () => {
  return (
    <Fragment>
    
      <p>Object fit with two explicit dimensions</p>
      <img src="https://source.unsplash.com/RN6ts8IZ4_0/600x400" className="img-fit"></img>
      
      <p>Positioned element with one explicit dimension</p>
      <div className="container">
        <img src="https://source.unsplash.com/RN6ts8IZ4_0/600x400" className="img-position"></img>
      </div>
      
    </Fragment>
  );
};

ReactDOM.render(<App/>, document.body);
.img-fit {
  width: 200px;
  height: 300px;
  object-fit: cover;
}

.container {
  width: 200px;
  height: 300px;
  overflow: hidden;
  position: relative;
}

.img-position {
  height: 100%;
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>

Regardless, the second image in the snippet above is one possible workaround for this issue if it continues to give you grief. You choose the dimension you want to scale to on the parent container by making the img either 100% height or width. It can then be centred (or otherwise adjusted) with a combination of directional offset and transform. This is obviously not as dynamic as object-fit because you have to decide beforehand how to scale the image to get either a cover or contain effect.

Some alternative solutions include:

1. Use a background image instead

I've found that the background CSS property, which object-fit was ultimately derived from, does not suffer from the same bug. Use a div instead of an img tag and place the image on its background:

div {
  width: 200px;
  height: 300px;
  background: url('path/yourimg.jpg') center / cover;
}

2. Preloading (HTML)

Another fix I found was preloading the image, which signals to the browser that it is a critical resource and should be fetched straight away. This would seem to support the idea that the issue is ultimately down to browser optimisation.

Unfortunately this doesn't really conform to React standards since you've only got one HTML page and it could easily get filled up with these links, but it works regardless. Put this in the head:

<link rel="preload" href="path/yourimage.jpg" as="image"/>

3. Preloading (React)

You can simulate a preload in React by waiting for the image's onload event to fire before making it visible to the user. This can be as quick and dirty as using a one-liner in the JSX with relevant classes:

// app.js

const App = () => <img src={foo} onLoad={e => e.target.classList.add('visible')}/>;

// index.css

img {
  width: 200px;
  height: 300px;
  object-fit: cover;
  visibility: hidden;
}

.visible {
  visibility: visible;
};

Or if you are loading a batch of images you can wait for them all to be loaded before displaying the component/batch itself. This comes with some drawbacks with potential layout shift, and may need some optimization to handle large batches, but you get the idea:

import foo from 'path/foo.jpg';
import bar from 'path/bar.jpg';

const App = () => {
    const [loaded, setLoaded] = useState(false);

    useEffect(() => {
        const loadArray = [foo, bar].map(src => {
            return new Promise((res, rej) => {
                const img = new Image();

                img.src = src;
                img.onload = () => res();
                img.onerror = () => rej();
            })
        });

        Promise.allSettled(loadArray).then(() => setLoaded(true));
    }, []);

    return loaded && (
        <>
            <img src={foo} alt="foo"></img>
            <img src={bar} alt="bar"></img>
        </>
    );
};
Related