fix(ui): retire the blurhash on a timer, not transitionend

Under prefers-reduced-motion the img rule sets transition:none, so
toggling opacity fires no transitionend and the handler that unmounts
the blurhash never ran. The placeholder stayed mounted for the life of
the component — visible in the letterbox bars wherever the cover is
rendered with fit="contain", and a live canvas per tile everywhere else.

One duration constant now drives both the CSS transition and the timer,
so they cannot drift.
This commit is contained in:
Deluan 2026-07-25 10:22:28 -04:00
parent d4381c696e
commit c66ef971cf
2 changed files with 40 additions and 16 deletions

View File

@ -7,6 +7,9 @@ import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl'
import { BlurHashCanvas } from './BlurHashCanvas'
// Drives both the CSS transition and the timer that retires the blurhash, so they cannot drift.
const fadeMs = 500
const useStyles = makeStyles({
// className supplies the size and shape; overflow:hidden clips the fills to a rounded shape.
root: {
@ -23,7 +26,7 @@ const useStyles = makeStyles({
},
img: {
opacity: 0,
transition: 'opacity 500ms ease-out',
transition: `opacity ${fadeMs}ms ease-out`,
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
},
imgVisible: { opacity: 1 },
@ -60,6 +63,14 @@ export const CoverImage = ({
setFaded(false)
}, [url])
// Retire the blurhash on a timer rather than transitionend: under prefers-reduced-motion the
// transition is none, so the event never fires and the placeholder would stay up forever.
useEffect(() => {
if (!decoded || faded) return undefined
const timer = setTimeout(() => setFaded(true), fadeMs)
return () => clearTimeout(timer)
}, [decoded, faded])
if (!record) return null
const instant = cachedOnMount.current
@ -90,7 +101,6 @@ export const CoverImage = ({
style={{ objectFit: fit }}
// Fading on decode, not on mount, keeps the image from ramping up before it can paint.
onLoad={() => setDecoded(true)}
onTransitionEnd={() => setFaded(true)}
/>
)}
</div>

View File

@ -1,4 +1,4 @@
import { render, fireEvent } from '@testing-library/react'
import { render, fireEvent, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('./useImageUrl', () => ({ useImageUrl: vi.fn() }))
@ -54,21 +54,35 @@ describe('CoverImage', () => {
})
it('keeps the blurhash under the image until the fade ends', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render(<CoverImage record={withArt} />)
vi.useFakeTimers()
try {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render(<CoverImage record={withArt} />)
// Blob arrives: the image mounts transparent, with the blurhash still behind it.
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
rerender(<CoverImage record={withArt} />)
const img = container.querySelector('img')
expect(img).not.toBeNull()
expect(container.querySelector('canvas')).not.toBeNull()
// Blob arrives: the image mounts transparent, with the blurhash still behind it.
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
rerender(<CoverImage record={withArt} />)
const img = container.querySelector('img')
expect(img).not.toBeNull()
expect(container.querySelector('canvas')).not.toBeNull()
// Decoding starts the cross-fade; the blurhash only goes away once it finishes.
fireEvent.load(img)
expect(container.querySelector('canvas')).not.toBeNull()
fireEvent.transitionEnd(img)
expect(container.querySelector('canvas')).toBeNull()
// Decoding starts the cross-fade; the blurhash only goes away once it finishes. The clock
// drives it, not transitionend, which never fires under prefers-reduced-motion.
act(() => {
fireEvent.load(img)
})
expect(container.querySelector('canvas')).not.toBeNull()
act(() => {
fireEvent.transitionEnd(img)
})
expect(container.querySelector('canvas')).not.toBeNull()
act(() => {
vi.advanceTimersByTime(500)
})
expect(container.querySelector('canvas')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('does not fade an image that was already cached on mount', () => {