Stop every scene re-rasterising its sprite bank on resize

Performancekamo-marketing
Shipped
September 6, 2026 at 3:07 PM UTC
Author
Kamo
Commit
b0e94d5

An audit of all eighteen scenes found the same defect in twelve of them, and it is the shape that degrades a tab over a minute or two and then stalls it in garbage collection: `onResize` rebuilt the whole sprite bank on every call, though the sprites depend only on `size.dpr`. Canvas backing stores live outside the JS heap and are reclaimed only when their wrapper is collected, so this is real churn — Halloween was 10.4 MB and 100 `createRadialGradient` calls per call, Easter 706 KB across 16 canvases, Big Game 7.1 MB. Fixed in two places, because it was two separate faults. THE KIT, which is where most of it came from. `resize()` now returns before calling `onResize` when width, height and dpr are all unchanged. `resize` is deliberately wired to BOTH a ResizeObserver on the canvas and the window's resize event, so every scene was paying for its sprite bank twice on one genuine change, and paying again for every no-op notification. That was the kit's bug, not the scenes'. THE SCENES, for what the kit cannot remove. A genuine height change still reaches `onResize`, and on iOS the URL bar retracting during a scroll changes the viewport height repeatedly through a single gesture. Each scene now keys its rasterisation on what it actually depends on and leaves the width/height half running. Two scenes already did this and were the pattern the rest followed. The keys are not uniform, deliberately. Halloween keys on raster scale AND fog band count, because the count is a quality decision that can move without the scale. Cyber Monday's single all-or-nothing guard became three — sprites on dpr, the scan sliver on dpr and band height, the board on the viewport — because its board IS the viewport and freezing it on a height change would leave it stretched. Thanksgiving turned out not to have the defect at all (its leaves are re-shaded Path2D fills, not sprites, for reasons its header argues) but was rebuilding its colour tables unconditionally, and those are keyed on the palette rather than on dpr because a string is not rasterised and the palette is what they depend on. AND THE EASTER EGG-IDENTITY BUG, which was the more serious finding Egg identity was hashed from the measured rect — `qx = round(x/8)`, `qy = round((y + scrollY)/4)` — on the documented assumption that a ledge's page position is stable. It is not stable on this site. globals.css drives `.reveal` with `animation-timeline: view()` and `reveal-up` interpolates `translateY(28px)` to nothing; the shelf selector's own elements are the animated ones, some nested, so up to 56px of compounded travel. `getBoundingClientRect()` includes a live transform and `useShelves` samples elements precisely while they are still moving. Against a 4px quantum that is fourteen identities for one card as it scrolls in, none of which match. The list pinned at MAX_EGGS, its sort ran every pass instead of never, nothing was culled because every record was on-screen, and eggs near a applying two 9-point `ctx.clip()` regions per frame. It was also the exact visual failure the file's design comment says it prevents: the field boils. `Shelf` has gained a stable `id` — a WeakMap-backed handle on the element, constant while that element is in the document and immune to any transform. Easter keys eggs off it. Identity now comes from the element and geometry from the live rect, which is the split that was intended; `mint` also refreshes x/pageY, without which a stable id would have frozen an egg at its mid-animation position, a bug the churn had been hiding. tsc clean, all 14 guards green, 187 routes still prerendered.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing