diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx index 94c45e011..21ac5e07b 100644 --- a/ui/src/common/Artwork.jsx +++ b/ui/src/common/Artwork.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' import clsx from 'clsx' import { makeStyles } from '@material-ui/core/styles' @@ -8,7 +8,7 @@ import { useImageUrl } from './useImageUrl' import { ThumbHashCanvas } from './ThumbHashCanvas' // Drives both the CSS transition and the timer that retires the placeholder, so they cannot drift. -const fadeMs = 500 +const fadeMs = 150 const useStyles = makeStyles({ // className supplies the size and shape; overflow:hidden clips the fills to it. @@ -51,11 +51,21 @@ export const Artwork = ({ const [decoded, setDecoded] = useState(false) const [faded, setFaded] = useState(false) + const frame = useRef(0) useEffect(() => { setDecoded(false) setFaded(false) + return () => cancelAnimationFrame(frame.current) }, [url]) + // Two frames: a blob decodes from memory, so onLoad can beat the first paint of opacity 0, + // leaving the transition no start value and popping some tiles in while others fade. + const handleLoad = () => { + frame.current = requestAnimationFrame(() => { + frame.current = requestAnimationFrame(() => setDecoded(true)) + }) + } + // Timer, not transitionend: under prefers-reduced-motion there is no transition to end. useEffect(() => { if (!decoded || faded) return undefined @@ -99,8 +109,7 @@ export const Artwork = ({ decoded && classes.imgVisible, )} style={{ objectFit: effectiveFit }} - // Fading on decode, not on mount, keeps the image from ramping up before it can paint. - onLoad={() => setDecoded(true)} + onLoad={handleLoad} /> )} diff --git a/ui/src/common/Artwork.test.jsx b/ui/src/common/Artwork.test.jsx index 9240e8a9e..18b3c916f 100644 --- a/ui/src/common/Artwork.test.jsx +++ b/ui/src/common/Artwork.test.jsx @@ -140,8 +140,13 @@ describe('Artwork', () => { fireEvent.transitionEnd(img) }) expect(container.querySelector('canvas')).not.toBeNull() + // Two frames pass before the fade starts, so opacity 0 gets painted first. act(() => { - vi.advanceTimersByTime(500) + vi.advanceTimersByTime(32) + }) + expect(container.querySelector('canvas')).not.toBeNull() + act(() => { + vi.advanceTimersByTime(150) }) expect(container.querySelector('canvas')).toBeNull() } finally {