Skip to content

Comments

fix: resolve race condition in auth bento password animation#2759

Open
myusubov wants to merge 5 commits intoappwrite:mainfrom
myusubov:fix-2758-auth-bento-animation-race-condition
Open

fix: resolve race condition in auth bento password animation#2759
myusubov wants to merge 5 commits intoappwrite:mainfrom
myusubov:fix-2758-auth-bento-animation-race-condition

Conversation

@myusubov
Copy link

@myusubov myusubov commented Feb 21, 2026

What does this PR do?

Fixes a race condition in the Auth bento card password animation.

When hovering in and out rapidly on desktop, the write and unwrite
animation utilities both ran simultaneously, causing the password dots
to flicker and jump erratically.

Changes:

  • Added AbortSignal support to write and unwrite in src/lib/animations/index.ts
    so animations can be cleanly cancelled mid-run.
  • Added optional startIndex parameter so animations resume from the
    current string length instead of jumping to the start/end.
  • Updated auth.svelte to use a shared AbortController that cancels
    the previous animation before starting a new one on hover in/out.

Test Plan

  1. Go to the homepage on desktop
  2. Hover over the Auth bento card
  3. Rapidly move the cursor in and out multiple times
  4. Verify the password dots animate smoothly without flickering or jumping

Related PRs and Issues

Resolves #2758

Have you read the Contributing Guidelines on issues?

Yes

Summary by CodeRabbit

  • New Features

    • Typing animations are now interruptible and can resume from a specific point, making transitions between interactions (hover, mobile) smoother.
  • Bug Fixes

    • Stops overlapping or lingering animations during rapid interactions, ignores canceled animations, and reliably cleans up animations when components unmount.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 21, 2026

Walkthrough

The PR adds AbortSignal support and a startIndex option to the animations module’s write and unwrite functions. Both now accept an options object ({ signal?, startIndex? }), initialize their index from startIndex, listen for abort events to clear intervals, and reject with 'Aborted' when signaled. A Svelte component was updated to create and manage an AbortController per interaction, pass its signal and startIndex to write/unwrite calls, abort any existing controller before starting a new sequence, handle aborted rejections, and abort on component unmount.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: resolve race condition in auth bento password animation' clearly summarizes the main change - fixing a race condition in the Auth bento password animation.
Linked Issues check ✅ Passed The PR implements all required changes from issue #2758: adds AbortSignal support to write/unwrite functions, introduces startIndex parameter for smooth animation resumption, and implements abort controller in auth.svelte to prevent race conditions.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the race condition in the Auth bento password animation as specified in issue #2758; no out-of-scope modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/routes/(marketing)/(components)/bento/(animations)/auth.svelte (1)

19-73: ⚠️ Potential issue | 🟡 Minor

controller is never aborted when the component is destroyed

The $effect body does not return a cleanup function, so controller?.abort() is never called on component teardown. Any in-flight setInterval from write/unwrite will keep firing and attempt to mutate password on a destroyed component.

🛡️ Proposed fix — return a teardown from `$effect`
     $effect(() => {
         inView( ... );
         hover( ... );
+
+        return () => {
+            controller?.abort();
+        };
     });
🤖 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 19 - 73, The $effect currently doesn't return a teardown, so the
AbortController and the inView/hover listeners are never cleaned up; update the
$effect to return a cleanup function that (1) calls controller?.abort() to stop
any in-flight write/unwrite, (2) invokes any cleanup functions returned by
inView(...) and hover(...) (store their return values when you call them), and
(3) reset controller to a fresh AbortController if needed—refer to the existing
symbols controller, $effect, inView, hover, write, unwrite, and password to
locate and wire up this teardown logic.
🧹 Nitpick comments (1)
src/lib/animations/index.ts (1)

28-31: Abort event listener is never removed on normal completion

The 'abort' listener on lines 28–31 (and the mirror on lines 62–65) persists after the animation resolves. In this codebase the signal is never reused, so the stale listener is harmless, but { once: true } is the standard way to self-remove it.

♻️ Proposed change — apply `{ once: true }` to both listeners
-        signal?.addEventListener('abort', () => {
+        signal?.addEventListener('abort', () => {
             clearInterval(interval);
             reject(new Error('Aborted'));
-        });
+        }, { once: true });

Apply the same change to the identical listener in unwrite (lines 62–65).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/animations/index.ts` around lines 28 - 31, The abort event listeners
added via signal?.addEventListener('abort', ...) in the write and unwrite
functions should be made self-removing by passing the options object { once:
true } so the handler is removed after it fires; update the listener calls in
the write function (the listener that clears interval and rejects with
Error('Aborted')) and the mirror listener in unwrite to include { once: true }
as the third argument.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/animations/index.ts`:
- Around line 7-26: In the write function, guard against startIndex >=
text.length to avoid the ++i pre-increment skipping the terminating condition
and causing an infinite interval; before creating the Promise (after the
existing empty-text early return) check if startIndex >= text.length (or clamp
startIndex to text.length-1) and immediately invoke cb(text) and return a
resolved Promise so no interval is started; reference the write function, the
startIndex/i variables and the ++i termination check when locating where to add
the guard.
- Around line 41-60: In unwrite, i is initialized with startIndex ?? text.length
which treats 0 as valid and causes a pre-decrement to -1; change the i
initialization in the unwrite implementation so it is clamped/guarded (e.g. if
typeof startIndex === 'number' and startIndex > 0 use that value, otherwise
default to text.length or clamp into [1, text.length]) to prevent i from ever
being 0 or negative before the pre-decrement; update the i assignment in the
unwrite function in src/lib/animations/index.ts accordingly so the interval
callback never sees negative slice indices or an infinite loop.

---

Outside diff comments:
In `@src/routes/`(marketing)/(components)/bento/(animations)/auth.svelte:
- Around line 19-73: The $effect currently doesn't return a teardown, so the
AbortController and the inView/hover listeners are never cleaned up; update the
$effect to return a cleanup function that (1) calls controller?.abort() to stop
any in-flight write/unwrite, (2) invokes any cleanup functions returned by
inView(...) and hover(...) (store their return values when you call them), and
(3) reset controller to a fresh AbortController if needed—refer to the existing
symbols controller, $effect, inView, hover, write, unwrite, and password to
locate and wire up this teardown logic.

---

Nitpick comments:
In `@src/lib/animations/index.ts`:
- Around line 28-31: The abort event listeners added via
signal?.addEventListener('abort', ...) in the write and unwrite functions should
be made self-removing by passing the options object { once: true } so the
handler is removed after it fires; update the listener calls in the write
function (the listener that clears interval and rejects with Error('Aborted'))
and the mirror listener in unwrite to include { once: true } as the third
argument.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/routes/(marketing)/(components)/bento/(animations)/auth.svelte (1)

22-72: Duplicate animation setup logic between inView and hover handlers

The bodies of the inView callback (lines 22–35) and its cleanup (lines 37–44) are byte-for-byte identical to the hover callback (lines 50–62) and its cleanup (lines 64–71), differing only in the guard (!isMobile() vs isMobile()). Extracting shared helpers would reduce surface area for future divergence.

♻️ Example refactor
+    function startWrite() {
+        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) => { if (err !== 'Aborted') console.error(err); });
+    }
+
+    function startUnwrite() {
+        controller?.abort();
+        controller = new AbortController();
+        unwrite('•••••••••••••', (v) => (password = v), 500, {
+            signal: controller.signal,
+            startIndex: password.length
+        }).catch((err) => { if (err !== 'Aborted') console.error(err); });
+    }

     $effect(() => {
         inView(container, () => {
             if (!isMobile()) return;
-            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(() => {});
-            return () => {
-                controller?.abort();
-                controller = new AbortController();
-                unwrite('•••••••••••••', (v) => (password = v), 500, {
-                    signal: controller.signal,
-                    startIndex: password.length
-                }).catch(() => {});
-            };
+            startWrite();
+            return startUnwrite;
         }, { amount: 'all' });

         hover(container, () => {
             if (isMobile()) return;
-            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(() => {});
-            return () => {
-                controller?.abort();
-                controller = new AbortController();
-                unwrite('•••••••••••••', (v) => (password = v), 500, {
-                    signal: controller.signal,
-                    startIndex: password.length
-                }).catch(() => {});
-            };
+            startWrite();
+            return startUnwrite;
         });

         return () => { controller?.abort(); };
     });
🤖 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 22 - 72, Extract the duplicated animation logic used in the inView
callback and the hover callback into a shared helper function (e.g.,
runMaskedPasswordAnimation) that accepts the guard check (or a predicate like
shouldRun = () => !isMobile()/isMobile()), the AbortController reference
(controller), element refs (button, container), and state setter (password);
move the common sequence (controller?.abort(), controller = new
AbortController(), write(...).then(animate...).catch(), and cleanup that aborts
and calls unwrite(...).catch()) into this helper, then call that helper from
both the inView handler and the hover handler, preserving the different guard
conditions and startIndex usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/routes/`(marketing)/(components)/bento/(animations)/auth.svelte:
- Line 35: Replace the silent .catch(() => {}) handlers on the write/unwrite
promise chains with logic that only swallows the expected cancellation error
(the string 'Aborted') and rethrows any other rejection; update all four .catch
call-sites associated with the write/unwrite calls (the promises that follow the
(v) => (password = v) and similar callbacks) so that runtime errors are not
silently lost but cancellations are suppressed.

---

Nitpick comments:
In `@src/routes/`(marketing)/(components)/bento/(animations)/auth.svelte:
- Around line 22-72: Extract the duplicated animation logic used in the inView
callback and the hover callback into a shared helper function (e.g.,
runMaskedPasswordAnimation) that accepts the guard check (or a predicate like
shouldRun = () => !isMobile()/isMobile()), the AbortController reference
(controller), element refs (button, container), and state setter (password);
move the common sequence (controller?.abort(), controller = new
AbortController(), write(...).then(animate...).catch(), and cleanup that aborts
and calls unwrite(...).catch()) into this helper, then call that helper from
both the inView handler and the hover handler, preserving the different guard
conditions and startIndex usage.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/routes/(marketing)/(components)/bento/(animations)/auth.svelte (1)

19-80: Consider extracting shared animation logic to reduce duplication.

The inView and hover handlers share identical write/unwrite calls, signal wiring, and error handling — only the isMobile() guard differs. A small helper (e.g., startWriteAnimation / startUnwriteAnimation) would consolidate the four near-identical call sites.

🤖 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 19 - 80, Extract the repeated animation setup into a small helper (e.g.,
startWriteAnimation and stopWriteAnimation) that accepts the controller
reference, text, callbacks to set password, the button element for animate, and
the signal options; move the shared logic from both inView and hover (the
controller?.abort(); controller = new AbortController(); call to
write(...).then(...).catch(...), and the teardown that aborts, reassigns
controller and calls unwrite(...).catch(...)) into these helpers and call them
from the inView and hover handlers while keeping the differing isMobile()
guards; reference existing symbols controller, write, unwrite, animate,
isMobile, container, button and ensure error handling still checks for Error &&
err.message !== 'Aborted'.
src/lib/animations/index.ts (1)

18-40: Already-aborted signal: one tick leaks before detection.

If write is called with a signal that is already aborted, the addEventListener('abort', …) on line 32 is a no-op (event already dispatched). The abort is only detected on the first interval tick (line 20), so one cb() invocation with ++i still fires before rejection.

A pre-flight guard eliminates the leak:

Suggested improvement
     let i = startIndex;
 
     return new Promise<void>((resolve, reject) => {
+        if (signal?.aborted) {
+            return reject(new Error('Aborted'));
+        }
+
         const interval = setInterval(() => {

Same pattern applies to unwrite (line 61).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/animations/index.ts` around lines 18 - 40, The write function
currently sets up an interval and only checks signal.aborted inside the first
tick, allowing one cb(text.slice(0, ++i)) to run when the provided AbortSignal
is already aborted; add a pre-flight guard in write that checks if
signal?.aborted and immediately reject(new Error('Aborted')) (and avoid starting
the interval) before creating interval/adding the listener, and mirror the same
pre-flight check in unwrite; reference the write/unwrite functions, the
signal/abort check, interval, cb and i so you can place the guard before
setInterval and before attaching the 'abort' listener.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/routes/`(marketing)/(components)/bento/(animations)/auth.svelte:
- Around line 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).

---

Nitpick comments:
In `@src/lib/animations/index.ts`:
- Around line 18-40: The write function currently sets up an interval and only
checks signal.aborted inside the first tick, allowing one cb(text.slice(0, ++i))
to run when the provided AbortSignal is already aborted; add a pre-flight guard
in write that checks if signal?.aborted and immediately reject(new
Error('Aborted')) (and avoid starting the interval) before creating
interval/adding the listener, and mirror the same pre-flight check in unwrite;
reference the write/unwrite functions, the signal/abort check, interval, cb and
i so you can place the guard before setInterval and before attaching the 'abort'
listener.

In `@src/routes/`(marketing)/(components)/bento/(animations)/auth.svelte:
- Around line 19-80: Extract the repeated animation setup into a small helper
(e.g., startWriteAnimation and stopWriteAnimation) that accepts the controller
reference, text, callbacks to set password, the button element for animate, and
the signal options; move the shared logic from both inView and hover (the
controller?.abort(); controller = new AbortController(); call to
write(...).then(...).catch(...), and the teardown that aborts, reassigns
controller and calls unwrite(...).catch(...)) into these helpers and call them
from the inView and hover handlers while keeping the differing isMobile()
guards; reference existing symbols controller, write, unwrite, animate,
isMobile, container, button and ensure error handling still checks for Error &&
err.message !== 'Aborted'.

Comment on lines +82 to +84
return () => {
controller?.abort();
};
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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Auth bento card password animation flickers on rapid hover

1 participant