diff --git a/ui/src/common/BlurHashCanvas.jsx b/ui/src/common/BlurHashCanvas.jsx index 25a9dd43e..20c3aea48 100644 --- a/ui/src/common/BlurHashCanvas.jsx +++ b/ui/src/common/BlurHashCanvas.jsx @@ -12,17 +12,19 @@ export const BlurHashCanvas = ({ hash, className }) => { if (!hash || !canvasRef.current) { return } + const ctx = canvasRef.current.getContext('2d') + if (!ctx) { + return + } + // Clear first so a hash change that fails to decode never leaves a stale frame. + ctx.clearRect(0, 0, DECODE_SIZE, DECODE_SIZE) try { const pixels = decode(hash, DECODE_SIZE, DECODE_SIZE) - const ctx = canvasRef.current.getContext('2d') - if (!ctx) { - return - } const imageData = ctx.createImageData(DECODE_SIZE, DECODE_SIZE) imageData.data.set(pixels) ctx.putImageData(imageData, 0, 0) } catch { - // A malformed hash simply leaves the canvas transparent. + // A malformed hash simply leaves the canvas blank. } }, [hash]) diff --git a/ui/src/common/BlurHashCanvas.test.jsx b/ui/src/common/BlurHashCanvas.test.jsx index edd567794..b030625ba 100644 --- a/ui/src/common/BlurHashCanvas.test.jsx +++ b/ui/src/common/BlurHashCanvas.test.jsx @@ -3,15 +3,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { BlurHashCanvas } from './BlurHashCanvas' describe('BlurHashCanvas', () => { - // jsdom has no real 2D context; stub it so getContext doesn't hit its noisy "not implemented" path. + // jsdom has no real 2D context; stub it (tracked) so specs can assert the draw path ran. + let ctxMock let getContextSpy beforeEach(() => { + ctxMock = { + clearRect: vi.fn(), + createImageData: vi.fn((w, h) => ({ + data: new Uint8ClampedArray(w * h * 4), + })), + putImageData: vi.fn(), + } getContextSpy = vi .spyOn(HTMLCanvasElement.prototype, 'getContext') - .mockReturnValue({ - createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), - putImageData: () => {}, - }) + .mockReturnValue(ctxMock) }) afterEach(() => { getContextSpy.mockRestore() @@ -22,17 +27,35 @@ describe('BlurHashCanvas', () => { expect(container.querySelector('canvas')).toBeNull() }) - it('renders a canvas for a valid hash', () => { + it('decodes a valid hash and draws non-trivial pixel data', () => { const { container } = render( , ) expect(container.querySelector('canvas')).not.toBeNull() + expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 32) + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + const [imageData] = ctxMock.putImageData.mock.calls[0] + expect(imageData.data.some((byte) => byte !== 0)).toBe(true) }) - it('renders a canvas without throwing on a malformed hash', () => { + it('renders a canvas without throwing on a malformed hash, and draws nothing', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { container } = render() expect(container.querySelector('canvas')).not.toBeNull() + expect(ctxMock.putImageData).not.toHaveBeenCalled() spy.mockRestore() }) + + it('clears the canvas when a hash change fails to decode', () => { + const { rerender } = render( + , + ) + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + + rerender() + + expect(ctxMock.clearRect).toHaveBeenCalledTimes(2) + // No new pixels drawn after the clear, so the stale frame stays gone. + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + }) })