From c66ef971cf3b430058a9e610d92c69f748bffe84 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 25 Jul 2026 10:22:28 -0400 Subject: [PATCH] fix(ui): retire the blurhash on a timer, not transitionend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/src/common/CoverImage.jsx | 14 +++++++++-- ui/src/common/CoverImage.test.jsx | 42 ++++++++++++++++++++----------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/ui/src/common/CoverImage.jsx b/ui/src/common/CoverImage.jsx index e62fbcfcc..d4cd43b91 100644 --- a/ui/src/common/CoverImage.jsx +++ b/ui/src/common/CoverImage.jsx @@ -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)} /> )} diff --git a/ui/src/common/CoverImage.test.jsx b/ui/src/common/CoverImage.test.jsx index 9d244db63..93bd18f64 100644 --- a/ui/src/common/CoverImage.test.jsx +++ b/ui/src/common/CoverImage.test.jsx @@ -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() + vi.useFakeTimers() + try { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container, rerender } = render() - // Blob arrives: the image mounts transparent, with the blurhash still behind it. - useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false }) - rerender() - 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() + 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', () => {