Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions src/lib/animations/index.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,85 @@
export function write(text: string, cb: (v: string) => void, duration = 500) {
export function write(
text: string,
cb: (v: string) => void,
duration = 500,
{ signal, startIndex = 0 }: { signal?: AbortSignal; startIndex?: number } = {}
) {
if (text.length === 0) {
cb('');
return Promise.resolve();
}
if (startIndex >= text.length) {
cb(text);
return Promise.resolve();
}
const step = duration / text.length;
let i = 0;
return new Promise<void>((resolve) => {
let i = startIndex;

return new Promise<void>((resolve, reject) => {
const interval = setInterval(() => {
if (signal?.aborted) {
clearInterval(interval);
return reject(new Error('Aborted'));
}

cb(text.slice(0, ++i));
if (i === text.length) {
clearInterval(interval);
resolve();
}
}, step);

signal?.addEventListener(
'abort',
() => {
clearInterval(interval);
reject(new Error('Aborted'));
},
{ once: true }
);
});
}

export function unwrite(text: string, cb: (v: string) => void, duration = 500) {
export function unwrite(
text: string,
cb: (v: string) => void,
duration = 500,
{ signal, startIndex }: { signal?: AbortSignal; startIndex?: number } = {}
) {
if (text.length === 0) {
cb('');
return Promise.resolve();
}
const step = duration / text.length;
let i = text.length;
return new Promise<void>((resolve) => {
let i = startIndex ?? text.length;

if (i <= 0) {
cb('');
return Promise.resolve();
}

return new Promise<void>((resolve, reject) => {
const interval = setInterval(() => {
if (signal?.aborted) {
clearInterval(interval);
return reject(new Error('Aborted'));
}

cb(text.slice(0, --i));
if (i === 0) {
clearInterval(interval);
resolve();
}
}, step);

signal?.addEventListener(
'abort',
() => {
clearInterval(interval);
reject(new Error('Aborted'));
},
{ once: true }
);
});
}

Expand Down
58 changes: 50 additions & 8 deletions src/routes/(marketing)/(components)/bento/(animations)/auth.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,37 @@
let password = $state('');
let button: HTMLButtonElement;

let controller: AbortController | null = null;

$effect(() => {
inView(
container,
() => {
if (!isMobile()) return;

write('•••••••••••••', (v) => (password = v), 1000).then(() => {
animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 });
});
controller?.abort();
controller = new AbortController();

write('•••••••••••••', (v) => (password = v), 1000, {
signal: controller.signal,
startIndex: password.length
})
.then(() => {
animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 });
})
.catch((err: unknown) => {
if (err instanceof Error && err.message !== 'Aborted') console.error(err);
});

return () => {
unwrite('•••••••••••••', (v) => (password = v));
controller?.abort();
controller = new AbortController();
unwrite('•••••••••••••', (v) => (password = v), 500, {
signal: controller.signal,
startIndex: password.length
}).catch((err: unknown) => {
if (err instanceof Error && err.message !== 'Aborted') console.error(err);
});
};
},
{ amount: 'all' }
Expand All @@ -33,13 +53,35 @@
hover(container, () => {
if (isMobile()) return;

write('•••••••••••••', (v) => (password = v), 1000).then(() => {
animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 });
});
controller?.abort();
controller = new AbortController();

write('•••••••••••••', (v) => (password = v), 1000, {
signal: controller.signal,
startIndex: password.length
})
.then(() => {
animate(button, { scale: [1, 0.95, 1] }, { duration: 0.25 });
})
.catch((err: unknown) => {
if (err instanceof Error && err.message !== 'Aborted') console.error(err);
});

return () => {
unwrite('•••••••••••••', (v) => (password = v));
controller?.abort();
controller = new AbortController();
unwrite('•••••••••••••', (v) => (password = v), 500, {
signal: controller.signal,
startIndex: password.length
}).catch((err: unknown) => {
if (err instanceof Error && err.message !== 'Aborted') console.error(err);
});
};
});

return () => {
controller?.abort();
};
Comment on lines +82 to +84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find and examine the auth.svelte file
fd -t f "auth.svelte" -x cat -n {}

Repository: appwrite/website

Length of output: 8980


🏁 Script executed:

#!/bin/bash
# Search for inView and hover usage in the codebase
rg -n "inView\|hover" src/routes/\(marketing\)/\(components\)/bento/\(animations\)/ -A 3 -B 1

Repository: appwrite/website

Length of output: 42


🌐 Web query:

motion library svelte inView hover return stop cleanup function

💡 Result:

If you mean Motion (the motion package from motion.dev), both inView and hover follow the same pattern:

  • You return a function from the “start” callback to run on leave/unhover (good place to .stop() an animation).
  • The gesture function itself returns a “stop/cancel” function you should call during Svelte teardown (eg, in onDestroy / onMount cleanup).

Svelte example (with proper cleanup)

<script>
  import { onMount } from "svelte";
  import { animate, hover, inView } from "motion";

  let el;

  onMount(() => {
    // hover: return runs on hover end
    const cancelHover = hover(el, (element) => {
      const a = animate(element, { scale: 1.05 }, { duration: 0.15 });
      return () => a.stop(); // hover end
    });

    // inView: return runs when leaving viewport
    const stopInView = inView(el, (element) => {
      const a = animate(element, { opacity: 1, y: 0 }, { duration: 0.25 });
      return () => a.stop(); // left viewport
    });

    // Svelte teardown
    return () => {
      cancelHover();
      stopInView();
    };
  });
</script>

<div bind:this={el} style="opacity:0; transform: translateY(12px);">
  Watch me
</div>

Notes that often trip people up

  • In Motion 12+, the callback signature is (element, eventOrEntry) (element is first). [1]
  • hover(...) returns a function that cancels gesture detection, and your callback may return an “hover end” cleanup. [2]
  • inView(...) returns a function that stops viewport detection, and your callback may return a “leave viewport” cleanup. [3]

Sources: Motion hover docs [2], Motion inView docs [3], Motion upgrade guide (v12 signature change) [1].


Call the stop functions returned by inView and hover in the $effect cleanup to prevent memory leaks.

The motion library's inView() and hover() functions return cancel functions that must be called to remove their underlying event listeners (IntersectionObserver for inView, MouseEvent listeners for hover). Without calling these, the listeners persist even after component unmount, causing memory and resource leaks.

Required fix
     $effect(() => {
-        inView(
+        const stopInView = inView(
             container,
             () => {
                 // ...
             },
             { amount: 'all' }
         );
 
-        hover(container, () => {
+        const stopHover = hover(container, () => {
             // ...
         });
 
         return () => {
             controller?.abort();
+            stopInView();
+            stopHover();
         };
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/`(marketing)/(components)/bento/(animations)/auth.svelte around
lines 82 - 84, The cleanup currently only aborts controller; capture and store
the cancel/stop functions returned from motion.inView(...) and motion.hover(...)
(e.g., const stopInView = inView(...), const stopHover = hover(...)) when you
call them, and then call those stop functions inside the existing return cleanup
alongside controller?.abort() so both the IntersectionObserver and mouse event
listeners are removed on unmount (ensure you call stopInView() and stopHover()
if they are defined).

});
</script>

Expand Down