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)
+ })
})