Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Draw the celebration on the main thread, not in a blocked worker

6b7d6a1897dca7a8f42669a02c3f6981192ccd22 · Keenan Nemetz · 13d ago · parent 0f8054ad

Draw the celebration on the main thread, not in a blocked worker

The burst never painted. canvas-confetti's default export is built with
useWorker: true, and it builds that worker from a blob: URL, which this page's
CSP (default-src 'self', no worker-src) blocks. The library does not fall back:
it transfers its canvas to the worker with transferControlToOffscreen and then
posts to it, so the canvas is created, is full-size, is permanently empty, and
reports no error. reload, shared link and bookmark behaviour, and the address-bar
strip, all worked — which is why the flag looked handled and the burst looked
absent.

Creating our own instance with useWorker: false draws on the main thread, where
120 particles for ~2s cost nothing, and needs no CSP change. Widening the policy
with worker-src blob: to keep the offscreen path would trade a real security
boundary for a party trick.

Verified by clicking Merge in a real Firefox against a scratch instance: the
canvas has 4148 non-transparent pixels and no transferred context. The same
probe against the old build reports getContext failing with InvalidStateError.

server/web/static/app.js +11−5

@@ -342,9 +342,15 @@ const copyText = (value, done) => {
342 342 // gets one burst of confetti. The library is a separate 25 KB asset and is fetched
343 343 // only on the redirect that carries ?merged=1: no other page pays for it, and a
344 344 // reload, bookmark or shared link is an ordinary visit because the flag is dropped
345 // from the address as soon as it has been read. The library honours
346 // prefers-reduced-motion itself (disableForReducedMotion), and reset() first means a
347 // page restored from the back/forward cache cannot stack a second canvas.
345 // from the address as soon as it has been read.
346 //
347 // The burst is drawn by an instance created with useWorker:false, not by the
348 // library's default export. The default export opts into a Web Worker built from
349 // a blob: URL, and this page's CSP (default-src 'self', no worker-src) blocks that
350 // worker — but the library hands its canvas to the worker anyway, transferring it
351 // with transferControlToOffscreen, and then draws nothing: the canvas is present
352 // and permanently empty, with no error to see. An instance that draws on the main
353 // thread costs 120 particles for ~2s, which is nothing, and needs no CSP change.
348 354 (() => {
349 355 const params = new URLSearchParams(window.location.search);
350 356 if (!params.has('merged')) return;
@@ -357,8 +363,8 @@ const copyText = (value, done) => {
357 363 script.src = '/static/confetti.browser.js';
358 364 script.onload = () => {
359 365 if (typeof window.confetti !== 'function') return;
360 window.confetti.reset();
361 window.confetti({
366 const burst = window.confetti.create(null, { resize: true, useWorker: false });
367 burst({
362 368 particleCount: 120,
363 369 spread: 70,
364 370 origin: { x: 0.5, y: 0.6 },