The burst from #01M5XZDTV4R3EE44RA8P9BGRG4 never drew. The redirect carried
?merged=1, app.js fired, the address bar was stripped, the bundle was fetched
200 — and the page was empty, with nothing in the console.
Cause
canvas-confetti’s default export is built as
confettiCannon(null, { useWorker: true, resize: true }), so every
window.confetti(...) call tries a Web Worker created from a blob: URL. This
page’s CSP is default-src 'self' with no worker-src, so the worker is blocked —
and the library does not fall back. It transfers the canvas with
transferControlToOffscreen before posting to that worker, so the canvas is
created at full size, handed away, and left permanently empty. On Firefox the
worker constructor does not even throw, so there is nothing to see.
Fix
Draw on the main thread, with our own instance:
const burst = window.confetti.create(null, { resize: true, useWorker: false });
120 particles for ~2s is nothing, and the CSP stays exactly as it is. Adding
worker-src 'self' blob: to keep the offscreen path would trade a security
boundary for a celebration, on a surface whose markdown input is untrusted.
Verification, in a real browser
A headless Firefox on a scratch instance, after a real click on the Merge button, reading the canvas back:
| build | canvas | pixels | transferred |
|---|---|---|---|
| before | 1360×682 | null, InvalidStateError |
true |
| after | 1360×682 | 4148 painted | false |
Screenshot of the same run shows the particles over the request page. This is the
check the earlier change lacked: counting <canvas> elements said “works”,
because the element exists either way. Only painted pixels distinguish the two.
server/ui_test.go gains the guard it can hold without a browser — that app.js
creates a useWorker: false instance — with the reason written down, since the
symptom is invisible from the server side.
docs/ui-plan.md records why the default export is unusable here.
Checks
gofmt, go vet ./..., go test ./..., vole workflow check, and
vole workflow run verify (7 steps, -race) all pass.
Closes #01M5Z00VJ6215NFZ8F3KJ48FXA