Skip to content

⚡ Bolt: Throttle scroll event listeners#102

Open
anyulled wants to merge 1 commit intomainfrom
bolt/throttle-scroll-listeners-16782550511564095932
Open

⚡ Bolt: Throttle scroll event listeners#102
anyulled wants to merge 1 commit intomainfrom
bolt/throttle-scroll-listeners-16782550511564095932

Conversation

@anyulled
Copy link
Owner

@anyulled anyulled commented Mar 17, 2026

💡 What: Throttled scroll event listeners using requestAnimationFrame and added { passive: true } options in Layout, DynamicHeaderWrapper, and BackToTop components.
🎯 Why: Scroll events fire at a very high rate (often 60+ times per second). Updating React state synchronously inside these handlers can lead to main-thread blocking, layout thrashing, and "janky" scrolling.
📊 Impact: Significantly reduces main-thread blocking during scrolling. By deferring state updates to the next animation frame, the browser can maintain a smooth 60fps frame rate while scrolling.
🔬 Measurement: Verify that the header shrinks on scroll and the "Back to top" button appears correctly without lag. Test suites have been updated to mock requestAnimationFrame and ensure functionality remains intact.


PR created automatically by Jules for task 16782550511564095932 started by @anyulled

Summary by CodeRabbit

  • Documentation

    • Added guidance on avoiding unthrottled scroll event listeners and best practices for scroll performance optimization.
  • Performance

    • Improved scroll event handling across layout components with throttling and optimized event listener configuration for better scrolling responsiveness.

- Added `requestAnimationFrame` to throttle scroll event handlers in `Layout`, `DynamicHeaderWrapper`, and `BackToTop` components.
- Added `{ passive: true }` option to the event listeners to avoid blocking the main thread.
- Updated related unit tests to mock `requestAnimationFrame`.
- Added a journal entry to `.jules/bolt.md` detailing the optimization.

Co-authored-by: anyulled <100741+anyulled@users.noreply.github.com>
@google-labs-jules
Copy link
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@vercel
Copy link

vercel bot commented Mar 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
devbcn Ready Ready Preview, Comment Mar 17, 2026 9:20am

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the application's scrolling performance and responsiveness. By introducing requestAnimationFrame-based throttling and passive event listeners to scroll handlers in critical components, it addresses potential main-thread blocking and UI jank. These changes ensure a smoother user experience, particularly during rapid scrolling, by optimizing how the browser processes scroll events and updates the UI.

Highlights

  • Performance Optimization: Implemented scroll event throttling using requestAnimationFrame in key UI components to prevent main-thread blocking.
  • Event Listener Configuration: Added the { passive: true } option to scroll event listeners for improved performance and responsiveness.
  • Component Updates: Applied these performance optimizations to the Layout, DynamicHeaderWrapper, and BackToTop components.
  • Testing Enhancements: Updated relevant test suites to correctly mock requestAnimationFrame for accurate scroll event simulation.
Changelog
  • .jules/bolt.md
    • Documented a new learning and action item regarding the anti-pattern of unthrottled scroll event listeners, advocating for requestAnimationFrame throttling and { passive: true }.
Activity
  • The pull request was automatically created by Jules for task 16782550511564095932, initiated by @anyulled.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@qodo-code-review
Copy link

Review Summary by Qodo

Throttle scroll listeners with requestAnimationFrame

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Throttle scroll event listeners using requestAnimationFrame in three components
• Add { passive: true } option to prevent main-thread blocking
• Implement state-based throttling mechanism to limit update frequency
• Update unit tests to mock requestAnimationFrame for proper testing
Diagram
flowchart LR
  A["Scroll Events<br/>60+ per second"] -->|"Throttle with<br/>requestAnimationFrame"| B["Deferred State<br/>Updates"]
  B -->|"Passive Listeners<br/>{ passive: true }"| C["Smooth 60fps<br/>Scrolling"]
  D["Layout<br/>DynamicHeaderWrapper<br/>BackToTop"] -->|"Apply Pattern"| E["Optimized<br/>Components"]
Loading

Grey Divider

File Changes

1. .jules/bolt.md 📝 Documentation +5/-0

Document scroll optimization learning

• Added journal entry documenting scroll event listener anti-pattern discovery
• Documented learning about unthrottled scroll events blocking main thread
• Recorded solution using { passive: true } and requestAnimationFrame throttling

.jules/bolt.md


2. components/elements/BackToTop.tsx ✨ Enhancement +10/-2

Throttle scroll listener with requestAnimationFrame

• Introduced state object with isTicking flag for throttling mechanism
• Wrapped setHasScrolled call inside requestAnimationFrame callback
• Added { passive: true } option to scroll event listener
• Prevents state updates on every scroll event, deferring to next animation frame

components/elements/BackToTop.tsx


3. components/layout/DynamicHeaderWrapper.tsx ✨ Enhancement +12/-4

Throttle scroll listener with requestAnimationFrame

• Added state object with isTicking flag for throttling
• Moved scroll check logic inside requestAnimationFrame callback
• Added { passive: true } option to scroll event listener
• Prevents redundant state updates by deferring to animation frame

components/layout/DynamicHeaderWrapper.tsx


View more (3)
4. components/layout/Layout.tsx ✨ Enhancement +12/-4

Throttle scroll listener with requestAnimationFrame

• Added state object with isTicking flag for throttling mechanism
• Wrapped scroll check and state update inside requestAnimationFrame callback
• Added { passive: true } option to scroll event listener
• Reduces main-thread blocking during frequent scroll events

components/layout/Layout.tsx


5. __tests__/components/layout/DynamicHeaderWrapper.test.tsx 🧪 Tests +7/-0

Mock requestAnimationFrame in scroll test

• Added mock for window.requestAnimationFrame in scroll event test
• Mock implementation immediately invokes callback to simulate animation frame
• Restored mock after test to prevent side effects
• Ensures test properly validates throttled scroll behavior

tests/components/layout/DynamicHeaderWrapper.test.tsx


6. __tests__/components/layout/Layout.test.tsx 🧪 Tests +7/-0

Mock requestAnimationFrame in scroll test

• Added mock for window.requestAnimationFrame in scroll event test
• Mock implementation immediately invokes callback for synchronous testing
• Restored mock after test execution
• Validates throttled scroll state updates work correctly

tests/components/layout/Layout.test.tsx


Grey Divider

Qodo Logo

@qodo-code-review
Copy link

qodo-code-review bot commented Mar 17, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Remediation recommended

1. AOS init on scroll 🐞 Bug ➹ Performance
Description
Layout calls AOS.init() inside a useEffect that depends on scroll, so every scroll-threshold
toggle re-initializes AOS and re-binds the scroll listener. This adds avoidable work during
scrolling and can negate the intended performance win from throttling.
Code

components/layout/Layout.tsx[R80-100]

  useEffect(() => {
    AOS.init();
+    const state = { isTicking: false };
+
    const handleScroll = (): void => {
-      const scrollCheck: boolean = window.scrollY > 100;
-      if (scrollCheck !== scroll) {
-        setScroll(scrollCheck);
+      if (!state.isTicking) {
+        window.requestAnimationFrame(() => {
+          const scrollCheck: boolean = window.scrollY > 100;
+          if (scrollCheck !== scroll) {
+            setScroll(scrollCheck);
+          }
+          state.isTicking = false;
+        });
+        state.isTicking = true;
      }
    };

-    document.addEventListener("scroll", handleScroll);
+    document.addEventListener("scroll", handleScroll, { passive: true });

    return () => {
      document.removeEventListener("scroll", handleScroll);
Evidence
Layout’s effect is keyed on scroll, but performs one-time initialization work (AOS.init()) and
listener setup; this guarantees repeated AOS initialization and listener churn whenever scroll
flips. A nearby layout component (ClientLayout) shows the intended pattern: initializing AOS once
with an empty dependency array.

components/layout/Layout.tsx[80-103]
components/layout/ClientLayout.tsx[8-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`components/layout/Layout.tsx` re-runs `AOS.init()` and tears down/re-adds the scroll listener whenever `scroll` changes because the effect depends on `scroll`. This adds unnecessary work during user scrolling.

### Issue Context
`ClientLayout` already initializes AOS once with `[]`, suggesting initialization is intended to be one-time per mount.

### Fix Focus Areas
- components/layout/Layout.tsx[80-103]
- components/layout/ClientLayout.tsx[8-12]

### Implementation outline
- Move `AOS.init()` into its own `useEffect(() =&gt; { ... }, [])`.
- Move the scroll listener into a separate `useEffect(() =&gt; { ... }, [])`.
- Inside the RAF callback, update state using a functional updater to avoid capturing `scroll` from a stale closure, e.g. `setScroll(prev =&gt; (prev === scrollCheck ? prev : scrollCheck))`.
- (Optional hardening) Store the RAF id in a ref and cancel it in cleanup.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Listener churn on toggle 🐞 Bug ➹ Performance
Description
DynamicHeaderWrapper’s scroll listener effect depends on scroll, so it removes and re-adds the
scroll event listener every time the boolean threshold state flips. This also recreates the
per-effect throttle state (state.isTicking), adding avoidable overhead during scrolling.
Code

components/layout/DynamicHeaderWrapper.tsx[R18-36]

  React.useEffect(() => {
+    const state = { isTicking: false };
+
    const handleScroll = (): void => {
-      const scrollCheck: boolean = window.scrollY > 100;
-      if (scrollCheck !== scroll) {
-        setScroll(scrollCheck);
+      if (!state.isTicking) {
+        window.requestAnimationFrame(() => {
+          const scrollCheck: boolean = window.scrollY > 100;
+          if (scrollCheck !== scroll) {
+            setScroll(scrollCheck);
+          }
+          state.isTicking = false;
+        });
+        state.isTicking = true;
      }
    };
-    document.addEventListener("scroll", handleScroll);
+    document.addEventListener("scroll", handleScroll, { passive: true });
    return () => {
      document.removeEventListener("scroll", handleScroll);
    };
Evidence
Because the effect dependency array includes scroll, each setScroll(...) transition triggers an
effect cleanup/setup cycle, which tears down and re-registers the document scroll handler and resets
the local throttle state object.

components/layout/DynamicHeaderWrapper.tsx[18-37]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`DynamicHeaderWrapper` reattaches its `scroll` listener and recreates throttle state on every `scroll` boolean change because the effect depends on `scroll`.

### Issue Context
The RAF throttling pattern works best when the handler and its throttle state remain stable across renders.

### Fix Focus Areas
- components/layout/DynamicHeaderWrapper.tsx[18-37]

### Implementation outline
- Change the effect dependency array to `[]`.
- Replace `if (scrollCheck !== scroll) setScroll(scrollCheck)` with a functional update to avoid stale closure usage: `setScroll(prev =&gt; (prev === scrollCheck ? prev : scrollCheck))`.
- Move `isTicking` to a `useRef(false)` so it persists across renders without forcing effect re-runs.
- (Optional hardening) Track/cancel any pending RAF in cleanup.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@coderabbitai
Copy link

coderabbitai bot commented Mar 17, 2026

📝 Walkthrough

Walkthrough

This PR implements scroll event throttling using requestAnimationFrame (RAF) across multiple layout components, adding a local state flag to prevent rapid updates. Tests are updated with RAF mocks, and documentation captures anti-pattern guidance on unthrottled scroll listeners.

Changes

Cohort / File(s) Summary
Documentation
.jules/bolt.md
Added entry documenting the scroll event listener anti-pattern with recommendations for using passive: true and RAF-based throttling.
Test Fixtures
__tests__/components/layout/DynamicHeaderWrapper.test.tsx, __tests__/components/layout/Layout.test.tsx
Added requestAnimationFrame mocks to scroll tests to force RAF callback execution and verify header state toggling.
Scroll Throttling Implementation
components/layout/Layout.tsx, components/layout/DynamicHeaderWrapper.tsx, components/elements/BackToTop.tsx
Implemented RAF-based throttling with local state.isTicking guard, added passive: true to scroll listeners, and wrapped scroll logic inside RAF callbacks to defer updates to animation frames.

Poem

🐰 Hop, hop, scroll, scroll—
No thrashing frames, just flowing roll!
RAF guards keep the ticking in check,
Smooth scrolling flows, what a tech!

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically identifies the main change: throttling scroll event listeners. It directly relates to the changeset which implements requestAnimationFrame throttling in multiple scroll handlers.

✏️ 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
  • Commit unit tests in branch bolt/throttle-scroll-listeners-16782550511564095932
📝 Coding Plan
  • Generate coding plan for human review comments

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

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request is a great performance enhancement, throttling scroll event listeners using requestAnimationFrame and adding { passive: true }. The changes are well-implemented across the Layout, DynamicHeaderWrapper, and BackToTop components, and the test updates are solid. I've identified a couple of minor improvements in DynamicHeaderWrapper.tsx and Layout.tsx to optimize the useEffect hooks and prevent unnecessary re-attachment of event listeners, which will make this change even more efficient.

Comment on lines +24 to +27
const scrollCheck: boolean = window.scrollY > 100;
if (scrollCheck !== scroll) {
setScroll(scrollCheck);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The useEffect hook for this component has scroll in its dependency array (on line 37), causing the event listener to be re-attached every time the scroll state changes. This is inefficient and is caused by the dependency on scroll within this if condition.

To improve this, you can remove the conditional check and call setScroll directly. React is already optimized to avoid re-renders if the state value is the same. This change will allow you to remove scroll from the useEffect dependency array (changing it to []), ensuring the listener is attached only once.

The implementation in components/elements/BackToTop.tsx is a good example of this pattern.

Suggested change
const scrollCheck: boolean = window.scrollY > 100;
if (scrollCheck !== scroll) {
setScroll(scrollCheck);
}
setScroll(window.scrollY > 100);

Comment on lines +87 to +90
const scrollCheck: boolean = window.scrollY > 100;
if (scrollCheck !== scroll) {
setScroll(scrollCheck);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Similar to DynamicHeaderWrapper.tsx, the useEffect hook has scroll in its dependency array (on line 102), which is inefficient as it causes the event listener to be re-attached on every scroll state change. This is caused by the if (scrollCheck !== scroll) check.

To optimize this, you can remove the if check and let React handle state update optimizations. This will allow you to use an empty dependency array [] for the useEffect, ensuring the listener is attached only once. After applying the suggestion below, please also change the dependency array on line 102 from [scroll] to [].

Suggested change
const scrollCheck: boolean = window.scrollY > 100;
if (scrollCheck !== scroll) {
setScroll(scrollCheck);
}
setScroll(window.scrollY > 100);

Copy link

@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.

Caution

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

⚠️ Outside diff range comments (1)
components/layout/Layout.tsx (1)

80-102: ⚠️ Potential issue | 🟠 Major

AOS.init() is unintentionally re-running on scroll state flips.

The effect depends on [scroll] (line 102) but calls setScroll() inside the scroll handler (lines 88–89), creating a dependency cycle. Each time the scroll threshold flips, the effect re-executes, reinitializing AOS and rebinding the listener. Split into two effects: one for AOS.init() with an empty dependency array (runs once), and one for the scroll listener with an empty dependency array using functional state update to avoid closure over stale scroll values.

💡 Proposed fix
-  useEffect(() => {
-    AOS.init();
-    const state = { isTicking: false };
-
-    const handleScroll = (): void => {
-      if (!state.isTicking) {
-        window.requestAnimationFrame(() => {
-          const scrollCheck: boolean = window.scrollY > 100;
-          if (scrollCheck !== scroll) {
-            setScroll(scrollCheck);
-          }
-          state.isTicking = false;
-        });
-        state.isTicking = true;
-      }
-    };
-
-    document.addEventListener("scroll", handleScroll, { passive: true });
-
-    return () => {
-      document.removeEventListener("scroll", handleScroll);
-    };
-  }, [scroll]);
+  useEffect(() => {
+    AOS.init();
+  }, []);
+
+  useEffect(() => {
+    const state = { isTicking: false };
+    let rafId: number | null = null;
+
+    const handleScroll = (): void => {
+      if (state.isTicking) return;
+      state.isTicking = true;
+      rafId = window.requestAnimationFrame(() => {
+        const scrollCheck: boolean = window.scrollY > 100;
+        setScroll((prev) => (prev === scrollCheck ? prev : scrollCheck));
+        state.isTicking = false;
+      });
+    };
+
+    document.addEventListener("scroll", handleScroll, { passive: true });
+    return () => {
+      if (rafId !== null) window.cancelAnimationFrame(rafId);
+      document.removeEventListener("scroll", handleScroll);
+    };
+  }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/layout/Layout.tsx` around lines 80 - 102, The effect currently
calls AOS.init() and registers the scroll listener but lists [scroll] as a
dependency, causing AOS.init() and the listener to re-run when setScroll
toggles; split into two effects: one effect that calls AOS.init() (call AOS.init
in an effect with an empty dependency array so it runs once) and a separate
effect that registers document.addEventListener("scroll", handleScroll) with an
empty dependency array, refactor handleScroll to use a functional update to
setScroll(prev => newValue) or read window.scrollY inside the RAF and compare
against prev via the functional setter to avoid closing over stale scroll
values, and ensure you still remove the listener in the cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@components/layout/Layout.tsx`:
- Around line 80-102: The effect currently calls AOS.init() and registers the
scroll listener but lists [scroll] as a dependency, causing AOS.init() and the
listener to re-run when setScroll toggles; split into two effects: one effect
that calls AOS.init() (call AOS.init in an effect with an empty dependency array
so it runs once) and a separate effect that registers
document.addEventListener("scroll", handleScroll) with an empty dependency
array, refactor handleScroll to use a functional update to setScroll(prev =>
newValue) or read window.scrollY inside the RAF and compare against prev via the
functional setter to avoid closing over stale scroll values, and ensure you
still remove the listener in the cleanup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 038794b0-5a09-4a3e-883c-5f6425a9641d

📥 Commits

Reviewing files that changed from the base of the PR and between 9b20b6d and a9bb444.

📒 Files selected for processing (6)
  • .jules/bolt.md
  • __tests__/components/layout/DynamicHeaderWrapper.test.tsx
  • __tests__/components/layout/Layout.test.tsx
  • components/elements/BackToTop.tsx
  • components/layout/DynamicHeaderWrapper.tsx
  • components/layout/Layout.tsx

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant