The celebration introduced in #01M5XZDTV4R3EE44RA8P9BGRG4 never drew anything.
The merge redirect carried ?merged=1, static/app.js fired, the flag was
stripped from the address bar, the 25 KB bundle was fetched (200) — and the page
showed no confetti, with nothing in the console.
Cause
canvas-confetti’s default export is constructed as
confettiCannon(null, { useWorker: true, resize: true }). So every call to
window.confetti(...) tries to run the animation in a Web Worker built from a
blob: URL. This page’s CSP is default-src 'self' with no worker-src, so that
worker is blocked — but the library does not fall back to the main thread. Its
fire() does:
if (worker && !initialized) { worker.init(canvas); } // transferControlToOffscreen
initialized = true;
if (worker) { canvas.__confetti_initialized = true; }
...
if (worker) { return worker.fire(options, size, done); }
So the canvas is created at full size, transferred to a worker that will never
run, and left permanently empty. The failure is silent: the only trace is a
console.warn("🎊 Could not load worker") on some browsers, and on Firefox the
worker constructor does not even throw.
Reproduction
A headless Firefox on a scratch instance, after a real Merge click, reading the canvas:
{"canvas":true,"w":1360,"h":682,"pixels":null,"err":"InvalidStateError: ...",
"markedTransferred":true}
__confetti_initialized is only ever set on the worker path, and getContext
throws because the canvas was transferred away. Counting non-transparent pixels
is the only check that distinguishes “drew” from “created and never drawn” —
counting <canvas> elements calls this a success.
Fix
Create our own instance with useWorker: false, so the burst is drawn on the main
thread: 120 particles for ~2s is nothing, and the CSP stays as it is. Widening it
with worker-src 'self' blob: to keep the offscreen path would trade a real
boundary for a party trick, and it is the wrong direction for a page whose whole
markdown input is untrusted.
Acceptance
- Clicking Merge on a request lands on the request page and a burst is visibly drawn, measured as non-zero painted pixels in a real browser, with no canvas handed to a worker.
prefers-reduced-motion: reducestill yields the page and no animation.- The CSP is unchanged, and no page without
?merged=1fetches the bundle.