From eaac4ef0e1025a0bffc47c9cb6e8476ff21bef1b Mon Sep 17 00:00:00 2001 From: WhoamiI00 Date: Wed, 12 Aug 2026 18:48:00 +0530 Subject: [PATCH 1/2] fix(bento): dispose homepage animation listeners and timers on unmount The bento animation components register motion `hover()` and `inView()` handlers inside `$effect`/`onMount` but discard the disposer each one returns, and never cancel the in-flight animation. Nothing is torn down when the component is destroyed by a client-side navigation. Two consequences: - Every card leaks its IntersectionObserver. Leaving the homepage left 10 of 12 observers alive, holding a reference to the detached subtree. - `auth.svelte` starts a 1000ms `write()` interval on hover. Clicking the card mid-animation navigates away, but the interval keeps running and its `.then()` still fires `animate(button, ...)` after `bind:this` has reset `button` to null, throwing "You're trying to perform an animation on null". `sites.svelte` similarly leaves a 44s `animate()` running. Capture the disposers, return a teardown from the effect, and stop any in-flight animation. `auth.svelte` also guards the deferred button pulse, since a write that settles exactly as the component unmounts resolves after teardown has already run. Measured on the homepage, hovering the Auth card then clicking it: before after animation ticks after unmount 11 0 IntersectionObservers still live 10 3 The 7 disposed observers are exactly the 7 bento cards; the remaining 3 belong to other components on the page. --- .../bento/(animations)/auth.svelte | 27 +++++++++++++------ .../bento/(animations)/databases.svelte | 9 +++++-- .../bento/(animations)/functions.svelte | 11 ++++++-- .../bento/(animations)/messaging.svelte | 9 +++++-- .../bento/(animations)/realtime.svelte | 9 +++++-- .../bento/(animations)/sites.svelte | 16 +++++++++-- .../bento/(animations)/storage.svelte | 9 +++++-- 7 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/routes/(marketing)/(components)/bento/(animations)/auth.svelte b/src/routes/(marketing)/(components)/bento/(animations)/auth.svelte index 1b3127a30f3..8133e6cfe83 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/auth.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/auth.svelte @@ -16,7 +16,15 @@ let currentAnimation: WriteAnimation | null = null; $effect(() => { - inView( + // The write animation can settle after the component has been destroyed + // (e.g. the user clicks the card mid-animation), by which point + // `bind:this` has already reset `button` to null. + const pulseButton = () => { + if (!button) return; + animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 }); + }; + + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -28,9 +36,7 @@ 1000, password.length ); - currentAnimation.then(() => { - animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 }); - }); + currentAnimation.then(pulseButton); return () => { currentAnimation?.cancel(); currentAnimation = unwrite( @@ -43,14 +49,12 @@ { amount: 'all' } ); - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; currentAnimation?.cancel(); currentAnimation = write('•••••••••••••', (v) => (password = v), 1000, password.length); - currentAnimation.then(() => { - animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 }); - }); + currentAnimation.then(pulseButton); return () => { currentAnimation?.cancel(); currentAnimation = unwrite( @@ -60,6 +64,13 @@ ); }; }); + + return () => { + stopInView(); + stopHover(); + currentAnimation?.cancel(); + currentAnimation = null; + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte b/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte index 526776a59fe..8ab79b4b243 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte @@ -65,7 +65,7 @@ let shouldAnimate = $state(false); $effect(() => { - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; animate( table, @@ -99,7 +99,7 @@ }; }); - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -137,6 +137,11 @@ }, { amount: 'all' } ); + + return () => { + stopHover(); + stopInView(); + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/functions.svelte b/src/routes/(marketing)/(components)/bento/(animations)/functions.svelte index f20f91630ab..13f61447bce 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/functions.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/functions.svelte @@ -25,7 +25,7 @@ $effect(() => { baseWidth = activeCommand.offsetWidth; - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; widthAnim?.stop(); @@ -46,7 +46,7 @@ }; }); - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -70,6 +70,13 @@ }, { amount: 'all' } ); + + return () => { + stopHover(); + stopInView(); + widthAnim?.stop(); + widthAnim = null; + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte b/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte index 5871c056ab7..975481b6195 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte @@ -26,7 +26,7 @@ [notification, { opacity: 1, y: 0, filter: 'blur(0px)' }, { duration: 0.2, at: 0.15 }] ]; - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -39,7 +39,7 @@ { amount: 'all' } ); - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; animate(to); @@ -47,6 +47,11 @@ animate(from); }; }); + + return () => { + stopInView(); + stopHover(); + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte b/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte index 462c26a68ca..25fc97a2e86 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte @@ -34,7 +34,7 @@ [topRightCursor, { scale: 1 }, { duration: 0.25, at: 0.35 }] ]; - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -47,7 +47,7 @@ { amount: 'all' } ); - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; animate(to); @@ -55,6 +55,11 @@ animate(from); }; }); + + return () => { + stopInView(); + stopHover(); + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/sites.svelte b/src/routes/(marketing)/(components)/bento/(animations)/sites.svelte index 4edae6cff39..964f17e89ec 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/sites.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/sites.svelte @@ -46,9 +46,10 @@ ]; let shouldAnimate = $state(false); + let secondsAnimation: ReturnType | null = null; $effect(() => { - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; shouldAnimate = true; @@ -57,6 +58,7 @@ onUpdate: (latest) => (seconds = +latest.toFixed()), duration: 44 }); + secondsAnimation = animation; currentAnimation?.cancel(); currentAnimation = write( @@ -82,7 +84,7 @@ }; }); - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -92,6 +94,7 @@ onUpdate: (latest) => (seconds = +latest.toFixed()), duration: 44 }); + secondsAnimation = animation; currentAnimation?.cancel(); currentAnimation = write( @@ -118,6 +121,15 @@ }, { amount: 'all' } ); + + return () => { + stopHover(); + stopInView(); + secondsAnimation?.stop(); + secondsAnimation = null; + currentAnimation?.cancel(); + currentAnimation = null; + }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte b/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte index 1129572444d..c09f9b276ee 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte @@ -10,7 +10,7 @@ let image: HTMLElement; $effect(() => { - hover(container, () => { + const stopHover = hover(container, () => { if (isMobile()) return; animate(image, { borderRadius: '24px', filter: 'grayscale(25%)' }, { duration: 0.2 }); @@ -27,7 +27,7 @@ }; }); - inView( + const stopInView = inView( container, () => { if (!isMobile()) return; @@ -53,6 +53,11 @@ amount: 'all' } ); + + return () => { + stopHover(); + stopInView(); + }; }); From f2f5c3a6de73c851a329393629c0d0391a35afa3 Mon Sep 17 00:00:00 2001 From: WhoamiI00 Date: Thu, 27 Aug 2026 22:46:58 +0530 Subject: [PATCH 2/2] fix(bento): stop in-flight card animations on teardown `hover()` and `inView()` return cleanups that detach the pointer listener and the observer, but they do not touch an animation those callbacks have already started. Leaving the page mid-transition left that animation running against a card no longer in the document. `auth`, `functions` and `sites` already held their controls and stopped them; `databases`, `messaging`, `realtime` and `storage` discarded the return value of `animate()` and only detached the handlers. They now keep the latest controls and stop them alongside, which is all that is needed since a new animation on the same element supersedes the previous one. `stop()` halts at the current state rather than `cancel()`, which would snap the card back to the initial state if the effect re-ran. --- .../bento/(animations)/databases.svelte | 15 +++++++++++---- .../bento/(animations)/messaging.svelte | 15 +++++++++++---- .../bento/(animations)/realtime.svelte | 15 +++++++++++---- .../bento/(animations)/storage.svelte | 19 +++++++++++++++---- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte b/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte index 8ab79b4b243..fd8ecfbb11f 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/databases.svelte @@ -64,10 +64,16 @@ let table: HTMLElement; let shouldAnimate = $state(false); + // Motion's cleanups detach the pointer listener and the observer, but an animation + // they already started keeps running against the card after it leaves the DOM. Only + // one can be in flight per element — a new one supersedes the last — so holding the + // latest controls is enough to stop the work on teardown. + let animation: ReturnType | undefined; + $effect(() => { const stopHover = hover(container, () => { if (isMobile()) return; - animate( + animation = animate( table, { x: [12, -5], @@ -82,7 +88,7 @@ shouldAnimate = true; return () => { - animate( + animation = animate( table, { x: [-5, 12], @@ -104,7 +110,7 @@ () => { if (!isMobile()) return; - animate( + animation = animate( table, { x: [12, -5], @@ -119,7 +125,7 @@ shouldAnimate = true; return () => { - animate( + animation = animate( table, { x: [-5, 12], @@ -141,6 +147,7 @@ return () => { stopHover(); stopInView(); + animation?.stop(); }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte b/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte index 975481b6195..6defa0fe8e9 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/messaging.svelte @@ -15,6 +15,12 @@ let device: HTMLElement; let notification: HTMLElement; + // Motion's cleanups detach the pointer listener and the observer, but a sequence they + // already started keeps running against the card after it leaves the DOM. Only one + // can be in flight here — a new one supersedes the last — so holding the latest + // controls is enough to stop the work on teardown. + let animation: ReturnType | undefined; + onMount(() => { const from: AnimationSequence = [ [notification, { opacity: 0, y: -30, filter: 'blur(4px)' }, { duration: 0.2 }], @@ -30,10 +36,10 @@ container, () => { if (!isMobile()) return; - animate(to); + animation = animate(to); return () => { - animate(from); + animation = animate(from); }; }, { amount: 'all' } @@ -41,16 +47,17 @@ const stopHover = hover(container, () => { if (isMobile()) return; - animate(to); + animation = animate(to); return () => { - animate(from); + animation = animate(from); }; }); return () => { stopInView(); stopHover(); + animation?.stop(); }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte b/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte index 25fc97a2e86..fc8a059b6f2 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/realtime.svelte @@ -13,6 +13,12 @@ let topRightCursor: HTMLElement; let topRightPiece: HTMLElement; + // Motion's cleanups detach the pointer listener and the observer, but a sequence they + // already started keeps running against the card after it leaves the DOM. Only one + // can be in flight here — a new one supersedes the last — so holding the latest + // controls is enough to stop the work on teardown. + let animation: ReturnType | undefined; + onMount(() => { const from: AnimationSequence = [ [ @@ -38,10 +44,10 @@ container, () => { if (!isMobile()) return; - animate(to); + animation = animate(to); return () => { - animate(from); + animation = animate(from); }; }, { amount: 'all' } @@ -49,16 +55,17 @@ const stopHover = hover(container, () => { if (isMobile()) return; - animate(to); + animation = animate(to); return () => { - animate(from); + animation = animate(from); }; }); return () => { stopInView(); stopHover(); + animation?.stop(); }; }); diff --git a/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte b/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte index c09f9b276ee..c4f6b07bfbd 100644 --- a/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte +++ b/src/routes/(marketing)/(components)/bento/(animations)/storage.svelte @@ -9,14 +9,24 @@ let container: HTMLElement; let image: HTMLElement; + // Motion's cleanups detach the pointer listener and the observer, but an animation + // they already started keeps running against the card after it leaves the DOM. Only + // one can be in flight per element — a new one supersedes the last — so holding the + // latest controls is enough to stop the work on teardown. + let animation: ReturnType | undefined; + $effect(() => { const stopHover = hover(container, () => { if (isMobile()) return; - animate(image, { borderRadius: '24px', filter: 'grayscale(25%)' }, { duration: 0.2 }); + animation = animate( + image, + { borderRadius: '24px', filter: 'grayscale(25%)' }, + { duration: 0.2 } + ); return () => { - animate( + animation = animate( image, { borderRadius: '4px', @@ -32,7 +42,7 @@ () => { if (!isMobile()) return; - animate( + animation = animate( image, { borderRadius: '24px', @@ -42,7 +52,7 @@ ); return () => { - animate( + animation = animate( image, { borderRadius: '4px', filter: 'grayscale(100%)' }, { duration: 0.2 } @@ -57,6 +67,7 @@ return () => { stopHover(); stopInView(); + animation?.stop(); }; });