Correctness phase of the frontend audit. Nothing here is visible on a happy
path; it is what the app does when something goes wrong, when the window
changes, or when the component goes away.
Teardown (new src/lib/pixi/lifecycle.ts):
- Nothing was ever released: no app.destroy(), no ticker.remove(), no
killTweensOf, no onDestroy. ~480 GSAP tweens (most repeat:-1), a ticker
callback, and a dozen DOM listeners lived forever.
- 112 of those tweens are created inside an onComplete, so they don't exist
when a call-site sweep would run. gsap.context() handles exactly this:
GSAP records each animation's context on creation and restores it while
invoking that animation's callbacks (see _callback in gsap-core), so
late-created pulses land in the same context. One ctx.kill() gets all of
them. kill(), not revert(), so GSAP doesn't write properties back onto
objects about to be destroyed.
- All DOM listeners now go through life.listen(), which registers removal at
the same time. Previously 6 canvas listeners were never removed, each
closing over the whole ~713-object scene graph.
- The unmount race: cleanup used to be registered on the LAST line of an
async onMount, after the asset waterfall, so unmounting mid-load cleaned up
nothing. The lifecycle is now created before any await, with life.disposed
guards after each one.
- Hover tweens re-enter the context per event and use overwrite:'auto' so
rapid sweeps don't stack conflicting tweens.
Failure paths:
- onMount had no try/catch and no WebGL detection. Any init or asset failure
left the spinner up forever, the stats toggle at opacity:0, and Escape dead
(attachKeyboard never ran). Now caught, and it hands off to +page.svelte to
reveal the text version.
- There was no <noscript> anywhere, and SeoContent is clipped to 1x1px with
html/body overflow:hidden — so JS-disabled visitors got a pure black page.
A <noscript><style> block now un-hides it with no JS involved.
Service worker (moved static/ -> src/, so SvelteKit builds it):
- It can now use $service-worker, so precache comes from the real build
manifest instead of a hand-maintained 5-entry list that omitted all JS/CSS,
and the cache name embeds `version` so old caches actually get pruned.
- cache.addAll was atomic: one 404 failed the whole install silently. Now
per-asset with a warning on partial failure.
- The catch returned undefined for non-image assets, which makes
respondWith throw a TypeError — a soft offline failure became a hard one.
Every path now resolves to a Response.
- Unhashed assets (icons, headshot, resume PDF) were cache-first with no
expiry, so replacing the resume at the same URL never reached a returning
visitor. Those are network-first now; only hashed build output is
cache-first.
- Navigation responses were cached without a status check, so a 404/500 body
could become the offline fallback.
- Dropped skipWaiting() from install and added a real update prompt +
controllerchange reload. The SKIP_WAITING handler was previously dead code
and the "check for updates periodically" comment described an interval that
did not exist.
Tooltip:
- Wrote Svelte state on every canvas mousemove even with nothing hovered.
Now gated on hover and coalesced to one write per frame; raw coords are
still tracked cheaply so the first hover isn't positioned from stale data.
- Animated left/top (layout + paint per frame on an element with two
color-mix shadows); now translate3d.
- Listener moved to window, so coordinates no longer freeze when a drag
leaves the canvas.
- Flip logic measured a hardcoded 280x120; now measures the real element
(actual height is 113, so tall tooltips mis-flipped near the bottom edge).
- Dropped role="tooltip" + aria-live="polite", which announced the full
label and description on every hover. Nothing referenced it via
aria-describedby, so the role was inert. Now aria-hidden.
Resize:
- Star positions, gradient radius, vignette size and dust wrap bounds were
all baked at first paint, so enlarging the window left an unlit band. The
background is now rebuilt on a rAF-coalesced resize, under its own
sub-lifecycle. The view is shifted by half the delta rather than re-centred,
so the user's current view stays anchored.
- isTouchDevice used 'ontouchstart', true on touch-capable laptops and thus
permanently killing mouse hover. Now matchMedia('(hover: hover)').
Known gap: node radii still don't change when the 768px breakpoint is
crossed mid-session; re-creating all 57 nodes would reset interaction state.
Verified in the production build: resize 1278x1220 -> 1734x821 rebuilds with
zero errors, tooltip positions via transform and stays on screen, fallback
renders as a readable resume page. svelte-check clean, lint clean, 14/14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 lines
2.3 KiB
TypeScript
75 lines
2.3 KiB
TypeScript
// Single owner for every resource the tree allocates, so one dispose() call
|
|
// releases all of it.
|
|
//
|
|
// Why this exists: the PixiJS layer creates ~480 infinite GSAP tweens, a ticker
|
|
// callback, and a dozen DOM listeners. None of them were ever released, and 112
|
|
// of the tweens are created inside an `onComplete` — so they don't exist yet at
|
|
// the moment a naive `killTweensOf` sweep would run.
|
|
//
|
|
// `gsap.context()` solves exactly that: GSAP records each animation's context on
|
|
// creation, and restores that context while invoking the animation's callbacks
|
|
// (see `_callback` in gsap-core). So a tween created inside a tracked tween's
|
|
// onComplete is tracked by the same context, and ctx.kill() gets all of them.
|
|
import { gsap } from 'gsap';
|
|
|
|
export function createLifecycle() {
|
|
const disposers: Array<() => void> = [];
|
|
const gsapCtx = gsap.context(() => {});
|
|
let isDisposed = false;
|
|
|
|
return {
|
|
/** True once dispose() has run — guards async work that resolves late. */
|
|
get disposed() {
|
|
return isDisposed;
|
|
},
|
|
|
|
/**
|
|
* Run `fn` with GSAP's active context set, so every tween it creates is
|
|
* tracked for teardown. Returns whatever `fn` returns.
|
|
*/
|
|
animate<T>(fn: () => T): T {
|
|
return gsapCtx.add(fn) as T;
|
|
},
|
|
|
|
/** Register an arbitrary teardown step. */
|
|
add(fn: () => void) {
|
|
disposers.push(fn);
|
|
},
|
|
|
|
/** Add a DOM listener and register its removal in one call. */
|
|
listen<T extends EventTarget>(
|
|
target: T,
|
|
type: string,
|
|
handler: (event: never) => void,
|
|
options?: AddEventListenerOptions
|
|
) {
|
|
target.addEventListener(type, handler as EventListener, options);
|
|
disposers.push(() => target.removeEventListener(type, handler as EventListener, options));
|
|
},
|
|
|
|
/**
|
|
* Release everything, newest first. `kill()` rather than `revert()`: we do
|
|
* not want GSAP writing properties back onto Pixi objects that are about
|
|
* to be destroyed.
|
|
*/
|
|
dispose() {
|
|
if (isDisposed) return;
|
|
isDisposed = true;
|
|
|
|
gsapCtx.kill();
|
|
|
|
for (let i = disposers.length - 1; i >= 0; i--) {
|
|
try {
|
|
disposers[i]();
|
|
} catch (err) {
|
|
// One failing teardown step must not strand the rest.
|
|
console.warn('Teardown step failed:', err);
|
|
}
|
|
}
|
|
disposers.length = 0;
|
|
}
|
|
};
|
|
}
|
|
|
|
export type Lifecycle = ReturnType<typeof createLifecycle>;
|