fix(ui): clear stale blurhash pixels and assert the draw path in tests

Clear the canvas before each decode attempt so a hash change that fails
to decode doesn't leave the previous frame's pixels on screen once this
wires into a list that recycles items. Also strengthen the specs to
assert createImageData/putImageData were actually invoked (and with
what), instead of only checking that a <canvas> element exists.
This commit is contained in:
Deluan 2026-07-23 20:57:15 -04:00
parent 6ae855256c
commit 2510b06c4d
2 changed files with 37 additions and 12 deletions

View File

@ -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])

View File

@ -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(
<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" />,
)
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(<BlurHashCanvas hash="!!!not-a-blurhash!!!" />)
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(
<BlurHashCanvas hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj" />,
)
expect(ctxMock.putImageData).toHaveBeenCalledTimes(1)
rerender(<BlurHashCanvas hash="!!!not-a-blurhash!!!" />)
expect(ctxMock.clearRect).toHaveBeenCalledTimes(2)
// No new pixels drawn after the clear, so the stale frame stays gone.
expect(ctxMock.putImageData).toHaveBeenCalledTimes(1)
})
})