Skip to content

⚡ Bolt: optimize scroll event listeners for smoother scrolling#106

Open
anyulled wants to merge 3 commits intomainfrom
bolt-optimize-scroll-listeners-18273862933436870828
Open

⚡ Bolt: optimize scroll event listeners for smoother scrolling#106
anyulled wants to merge 3 commits intomainfrom
bolt-optimize-scroll-listeners-18273862933436870828

Conversation

@anyulled
Copy link
Owner

@anyulled anyulled commented Mar 19, 2026

💡 What: Added { passive: true } and requestAnimationFrame throttling to scroll event listeners in Layout.tsx, DynamicHeaderWrapper.tsx, and BackToTop.tsx.
🎯 Why: Unthrottled scroll handlers that aren't marked passive run synchronously on the main thread for every minute scroll tick, causing high CPU usage, unnecessary consecutive renders, and browser scroll jank (because the browser waits for the JS execution to check for preventDefault).
📊 Impact: Reduces main thread blocking during scrolling and eliminates excess consecutive state updates by batching updates to the display refresh rate (e.g., 60fps). Greatly improves scrolling frame rate and smoothness.
🔬 Measurement: Verify by scrolling heavily on the page; observe smoother scrolling and fewer setScroll re-renders in DevTools Performance Profiler.


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

Summary by CodeRabbit

  • Tests

    • Enhanced scroll event test coverage across multiple components to better simulate real browser scrolling behavior.
  • Performance

    • Optimized scroll event handling to reduce unnecessary state updates during scrolling, improving performance.

- Add `{ passive: true }` to global `scroll` event listeners to prevent scroll blocking.
- Throttle state updates inside scroll handlers using `requestAnimationFrame` to run at most once per frame.
- Tested and verified no visual regression on scrolling.

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 19, 2026

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

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

@coderabbitai
Copy link

coderabbitai bot commented Mar 19, 2026

Warning

Rate limit exceeded

@anyulled has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 20 minutes and 42 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4bf77c9f-e207-4d11-bf18-10a27f651705

📥 Commits

Reviewing files that changed from the base of the PR and between 2e41957 and 0994696.

📒 Files selected for processing (3)
  • __tests__/components/layout/DynamicHeaderWrapper.test.tsx
  • __tests__/components/layout/Layout.test.tsx
  • __tests__/snapshots/elements/BackToTop.test.tsx
📝 Walkthrough

Walkthrough

Scroll-event handling is optimized across multiple layout components by implementing throttling via requestAnimationFrame with an isTicking flag, registering listeners as passive, and ensuring effects run once on mount. Tests are updated to stub requestAnimationFrame for synchronous callback execution.

Changes

Cohort / File(s) Summary
Scroll Throttling Implementation
components/elements/BackToTop.tsx, components/layout/DynamicHeaderWrapper.tsx, components/layout/Layout.tsx
Introduces throttling via requestAnimationFrame with isTicking flag to gate state updates. Scroll listeners now use { passive: true } option. useEffect dependency arrays changed to [] so effects run once on mount instead of re-attaching when scroll state changes.
Test RequestAnimationFrame Stubs
__tests__/components/layout/DynamicHeaderWrapper.test.tsx, __tests__/components/layout/Layout.test.tsx
Stubs window.requestAnimationFrame to synchronously invoke callbacks during scroll event tests, ensuring scroll-state assertions execute within the test without async timing delays.
BackToTop Test Expansion
__tests__/snapshots/elements/BackToTop.test.tsx
Adds new scroll-event test case with window.scrollY manipulation and requestAnimationFrame stub. Updates testing-library imports to include fireEvent for event dispatching.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A rabbit scrolls swiftly through time,
Throttling frames in rhythm sublime,
Each bounce and each bound now more fleet—
PassiveListeners make updates so sweet,
No flicker, no lag, just scroll so complete! 🐇✨

🚥 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 accurately summarizes the main objective of the PR: optimizing scroll event listeners using requestAnimationFrame throttling and passive event listeners to improve scrolling performance.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bolt-optimize-scroll-listeners-18273862933436870828
📝 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.

@qodo-code-review
Copy link

Review Summary by Qodo

Optimize scroll event listeners for smoother scrolling

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Optimize scroll event listeners with { passive: true } flag
• Throttle scroll state updates using requestAnimationFrame
• Prevent scroll blocking and reduce main thread jank
• Update tests to mock requestAnimationFrame behavior
Diagram
flowchart LR
  A["Scroll Event Fired"] -->|"passive: true"| B["Non-blocking Listener"]
  B -->|"requestAnimationFrame"| C["Throttle to Frame Rate"]
  C -->|"State Update"| D["Smooth Scrolling"]
Loading

Grey Divider

File Changes

1. components/elements/BackToTop.tsx ✨ Enhancement +9/-2

Throttle scroll handler with passive listener

• Added { passive: true } option to scroll event listener
• Implemented requestAnimationFrame throttling with isTicking flag
• State updates now batched to run once per animation frame

components/elements/BackToTop.tsx


2. components/layout/DynamicHeaderWrapper.tsx ✨ Enhancement +10/-5

Throttle scroll handler with passive listener

• Added { passive: true } option to scroll event listener
• Wrapped state update in requestAnimationFrame with throttling
• Changed scroll state comparison to use functional update pattern
• Removed scroll dependency from useEffect to prevent re-registration

components/layout/DynamicHeaderWrapper.tsx


3. components/layout/Layout.tsx ✨ Enhancement +11/-5

Throttle scroll handler with passive listener

• Added { passive: true } option to scroll event listener
• Implemented requestAnimationFrame throttling with isTicking flag
• Changed scroll state comparison to use functional update pattern
• Removed scroll dependency from useEffect to prevent re-registration

components/layout/Layout.tsx


View more (3)
4. __tests__/components/layout/DynamicHeaderWrapper.test.tsx 🧪 Tests +3/-0

Mock requestAnimationFrame for scroll test

• Added mock for window.requestAnimationFrame in scroll event test
• Mock executes callback immediately to test throttled behavior

tests/components/layout/DynamicHeaderWrapper.test.tsx


5. __tests__/components/layout/Layout.test.tsx 🧪 Tests +3/-0

Mock requestAnimationFrame for scroll test

• Added mock for window.requestAnimationFrame in scroll event test
• Mock executes callback immediately to test throttled behavior

tests/components/layout/Layout.test.tsx


6. __tests__/snapshots/elements/BackToTop.test.tsx 🧪 Tests +12/-1

Add scroll event test for BackToTop component

• Added new test case for scroll event state updates
• Imported fireEvent and screen from testing library
• Added mock for window.requestAnimationFrame with performance.now() parameter

tests/snapshots/elements/BackToTop.test.tsx


Grey Divider

Qodo Logo

@qodo-code-review
Copy link

qodo-code-review bot commented Mar 19, 2026

Code Review by Qodo

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

Grey Divider


Action required

1. rAF not cancelled 🐞 Bug ⛯ Reliability
Description
Layout/DynamicHeaderWrapper/BackToTop schedule setState inside requestAnimationFrame but never
cancel a pending frame on unmount, so the callback can still run after cleanup and call setState on
an unmounted component. This can trigger React warnings and leak work during route changes and
effect teardown/replay.
Code

components/layout/Layout.tsx[R83-92]

+    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;
+          setScroll((prev) => (prev !== scrollCheck ? scrollCheck : prev));
+          state.isTicking = false;
+        });
+        state.isTicking = true;
     }
Evidence
All three components schedule state updates in rAF, but their effect cleanup only removes the scroll
listener and does not cancel any in-flight animation frame (nor guard against unmount), allowing the
queued callback to execute after unmount.

components/layout/Layout.tsx[80-100]
components/layout/DynamicHeaderWrapper.tsx[18-34]
components/elements/BackToTop.tsx[7-21]

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

## Issue description
Scroll handlers in `Layout`, `DynamicHeaderWrapper`, and `BackToTop` throttle via `requestAnimationFrame`, but the effect cleanup does not cancel pending frames. If unmount happens after a scroll event scheduled a frame (route change, StrictMode effect teardown/replay), the queued callback can still run and call `setState` on an unmounted component.
## Issue Context
Each component uses an `isTicking` flag to limit rAF scheduling, but does not store the returned rAF id, so there’s nothing to cancel during cleanup.
## Fix Focus Areas
- components/layout/Layout.tsx[80-100]
- components/layout/DynamicHeaderWrapper.tsx[18-34]
- components/elements/BackToTop.tsx[7-21]
## Suggested fix
- Store the rAF id (e.g., `let rafId: number | null = null` or `useRef&amp;lt;number | null&amp;gt;(null)`).
- In the rAF callback, optionally guard with a `mounted` flag (`let mounted = true;` set to `false` in cleanup) before calling `setState`.
- In the effect cleanup: `if (rafId !== null) cancelAnimationFrame(rafId);` and reset ticking/mounted state as needed.

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


2. Broken rAF mock🐞 Bug ✓ Correctness
Description
Two tests override window.requestAnimationFrame with a function that returns void, which is
incompatible with the DOM type (cb) => number under strict TypeScript settings. This can fail
tsc/CI, and the override is not restored, risking cross-test pollution.
Code

tests/components/layout/DynamicHeaderWrapper.test.tsx[R54-58]

   Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true });
+    window.requestAnimationFrame = (cb) => {
+      cb();
+    };
   fireEvent.scroll(document);
Evidence
The repo has strict: true and includes all **/*.ts(x) (including tests) in typechecking; the
mocked implementations in the two layout-related tests do not return a number, violating the
declared type for window.requestAnimationFrame. BackToTop’s test shows the correct return pattern
(return 1), highlighting inconsistency.

tests/components/layout/DynamicHeaderWrapper.test.tsx[48-61]
tests/components/layout/Layout.test.tsx[92-105]
tests/snapshots/elements/BackToTop.test.tsx[11-20]
tsconfig.json[1-28]

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.test.tsx` and `Layout.test.tsx` assign `window.requestAnimationFrame = (cb) =&amp;gt; { cb(); };` which returns `void` instead of the required `number`. With `strict: true` and tests included in `tsconfig.json`, this can break TypeScript compilation.
## Issue Context
`BackToTop.test.tsx` already uses the correct shape by returning `1`, but none of the tests restore the original `requestAnimationFrame` after overriding it.
## Fix Focus Areas
- __tests__/components/layout/DynamicHeaderWrapper.test.tsx[48-61]
- __tests__/components/layout/Layout.test.tsx[92-105]
## Suggested fix
- Replace direct assignment with a spy/mocked impl that returns a number:
- `const rafSpy = jest.spyOn(window, &amp;#x27;requestAnimationFrame&amp;#x27;).mockImplementation((cb) =&amp;gt; { cb(0); return 1; });`
- Restore in `afterEach`: `rafSpy.mockRestore()` (or `jest.restoreAllMocks()`).
- Optionally also reset `scrollY` if other tests depend on it.

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



Remediation recommended

3. Assertionless scroll test 🐞 Bug ⚙ Maintainability
Description
The added BackToTop scroll test triggers a scroll event but contains no assertions, so it will pass
even if the scroll/rAF logic is broken. This provides false confidence and also leaves a global rAF
override in place for subsequent tests.
Code

tests/snapshots/elements/BackToTop.test.tsx[R11-20]

+  it("updates scroll state on scroll event", () => {
+    render(<BackToTop target="#top" />);
+
+    Object.defineProperty(window, "scrollY", { value: 150, writable: true, configurable: true });
+    window.requestAnimationFrame = (cb) => {
+      cb(performance.now());
+      return 1;
+    };
+    fireEvent.scroll(window);
+  });
Evidence
The test performs render, mutates scrollY, overrides requestAnimationFrame, and fires a scroll
event, but never checks that the UI changed (e.g., that the back-to-top control appears).

tests/snapshots/elements/BackToTop.test.tsx[11-20]
components/elements/BackToTop.tsx[33-43]

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

## Issue description
`BackToTop.test.tsx` fires a scroll event but makes no assertions, so it cannot catch regressions in the new rAF-throttled scroll behavior.
## Issue Context
`BackToTop` only renders the clickable control when `hasScrolled` is true.
## Fix Focus Areas
- __tests__/snapshots/elements/BackToTop.test.tsx[11-20]
- components/elements/BackToTop.tsx[33-43]
## Suggested fix
- Add an assertion that the control appears when `scrollY &amp;gt; 100` after the scroll event.
- Example: `expect(document.querySelector(&amp;#x27;.paginacontainer&amp;#x27;)).toBeInTheDocument()`.
- If using async behavior, wrap with `await waitFor(...)`.
- Restore `requestAnimationFrame` after the test (prefer `jest.spyOn(...).mockImplementation(...)` + `mockRestore`).
- Remove unused imports if any remain after adding assertions.

ⓘ 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

@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 user experience by optimizing scroll event handling across key components. By introducing passive event listeners and requestAnimationFrame throttling, the changes prevent browser scroll jank and reduce CPU usage, resulting in noticeably smoother and more efficient page scrolling.

Highlights

  • Scroll Event Optimization: Implemented { passive: true } and requestAnimationFrame throttling for scroll event listeners in Layout.tsx, DynamicHeaderWrapper.tsx, and BackToTop.tsx.
  • Performance Improvement: Reduced main thread blocking and eliminated excessive state updates by batching scroll-related renders to the display refresh rate, leading to smoother scrolling.
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.

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.

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 effectively optimizes scroll event listeners by using requestAnimationFrame throttling and the passive: true option, which is a great improvement for scrolling performance. I've identified a recurring issue across the new useEffect hooks: they don't clean up pending requestAnimationFrame calls on unmount. This could lead to React warnings and memory leaks. I've provided suggestions to cancel the animation frame in the cleanup function for BackToTop.tsx, DynamicHeaderWrapper.tsx, and Layout.tsx. Additionally, I found a new test case in __tests__/snapshots/elements/BackToTop.test.tsx that lacks assertions, making it ineffective. I've suggested adding assertions to properly verify the component's behavior.

Comment on lines +11 to +20
it("updates scroll state on scroll event", () => {
render(<BackToTop target="#top" />);

Object.defineProperty(window, "scrollY", { value: 150, writable: true, configurable: true });
window.requestAnimationFrame = (cb) => {
cb(performance.now());
return 1;
};
fireEvent.scroll(window);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

high

This test case is missing assertions. A test without assertions doesn't verify any behavior and will pass even if the component is broken. You should add assertions to check that the component's state is updated correctly after the scroll event and that the 'Back to Top' button is displayed.

  it("updates scroll state on scroll event", () => {
    const { container } = render(<BackToTop target="#top" />);

    expect(container.querySelector(".paginacontainer")).toBeNull();

    Object.defineProperty(window, "scrollY", { value: 150, writable: true, configurable: true });
    window.requestAnimationFrame = (cb) => {
      cb(performance.now());
      return 1;
    };
    fireEvent.scroll(window);

    expect(container.querySelector(".paginacontainer")).not.toBeNull();
  });

Comment on lines 7 to 21
useEffect(() => {
const state = { isTicking: false };
const onScroll = () => {
setHasScrolled(window.scrollY > 100);
if (!state.isTicking) {
window.requestAnimationFrame(() => {
setHasScrolled(window.scrollY > 100);
state.isTicking = false;
});
state.isTicking = true;
}
};

window.addEventListener("scroll", onScroll);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of requestAnimationFrame could lead to a state update on an unmounted component if the component unmounts after the animation frame is requested but before it executes. This can cause memory leaks and warnings in development mode.

To prevent this, you should cancel the animation frame in the useEffect cleanup function.

  useEffect(() => {
    const state = { isTicking: false };
    let animationFrameId: number;
    const onScroll = () => {
      if (!state.isTicking) {
        animationFrameId = window.requestAnimationFrame(() => {
          setHasScrolled(window.scrollY > 100);
          state.isTicking = false;
        });
        state.isTicking = true;
      }
    };

    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.cancelAnimationFrame(animationFrameId);
    };
  }, []);

Comment on lines 18 to +34
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;
setScroll((prev) => (prev !== scrollCheck ? scrollCheck : prev));
state.isTicking = false;
});
state.isTicking = true;
}
};
document.addEventListener("scroll", handleScroll);
document.addEventListener("scroll", handleScroll, { passive: true });
return () => {
document.removeEventListener("scroll", handleScroll);
};
}, [scroll]);
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of requestAnimationFrame could lead to a state update on an unmounted component if the component unmounts after the animation frame is requested but before it executes. This can cause memory leaks and warnings in development mode.

To prevent this, you should cancel the animation frame in the useEffect cleanup function.

  React.useEffect(() => {
    const state = { isTicking: false };
    let animationFrameId: number;
    const handleScroll = (): void => {
      if (!state.isTicking) {
        animationFrameId = window.requestAnimationFrame(() => {
          const scrollCheck: boolean = window.scrollY > 100;
          setScroll((prev) => (prev !== scrollCheck ? scrollCheck : prev));
          state.isTicking = false;
        });
        state.isTicking = true;
      }
    };
    document.addEventListener("scroll", handleScroll, { passive: true });
    return () => {
      document.removeEventListener("scroll", handleScroll);
      window.cancelAnimationFrame(animationFrameId);
    };
  }, []);

Comment on lines 80 to +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;
setScroll((prev) => (prev !== scrollCheck ? scrollCheck : prev));
state.isTicking = false;
});
state.isTicking = true;
}
};

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

return () => {
document.removeEventListener("scroll", handleScroll);
};
}, [scroll]);
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of requestAnimationFrame could lead to a state update on an unmounted component if the component unmounts after the animation frame is requested but before it executes. This can cause memory leaks and warnings in development mode.

To prevent this, you should cancel the animation frame in the useEffect cleanup function.

  useEffect(() => {
    AOS.init();

    const state = { isTicking: false };
    let animationFrameId: number;
    const handleScroll = (): void => {
      if (!state.isTicking) {
        animationFrameId = window.requestAnimationFrame(() => {
          const scrollCheck: boolean = window.scrollY > 100;
          setScroll((prev) => (prev !== scrollCheck ? scrollCheck : prev));
          state.isTicking = false;
        });
        state.isTicking = true;
      }
    };

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

    return () => {
      document.removeEventListener("scroll", handleScroll);
      window.cancelAnimationFrame(animationFrameId);
    };
  }, []);

Comment on lines +83 to 92
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;
setScroll((prev) => (prev !== scrollCheck ? scrollCheck : prev));
state.isTicking = false;
});
state.isTicking = true;
}

Choose a reason for hiding this comment

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

Action required

1. Raf not cancelled 🐞 Bug ⛯ Reliability

Layout/DynamicHeaderWrapper/BackToTop schedule setState inside requestAnimationFrame but never
cancel a pending frame on unmount, so the callback can still run after cleanup and call setState on
an unmounted component. This can trigger React warnings and leak work during route changes and
effect teardown/replay.
Agent Prompt
## Issue description
Scroll handlers in `Layout`, `DynamicHeaderWrapper`, and `BackToTop` throttle via `requestAnimationFrame`, but the effect cleanup does not cancel pending frames. If unmount happens after a scroll event scheduled a frame (route change, StrictMode effect teardown/replay), the queued callback can still run and call `setState` on an unmounted component.

## Issue Context
Each component uses an `isTicking` flag to limit rAF scheduling, but does not store the returned rAF id, so there’s nothing to cancel during cleanup.

## Fix Focus Areas
- components/layout/Layout.tsx[80-100]
- components/layout/DynamicHeaderWrapper.tsx[18-34]
- components/elements/BackToTop.tsx[7-21]

## Suggested fix
- Store the rAF id (e.g., `let rafId: number | null = null` or `useRef<number | null>(null)`).
- In the rAF callback, optionally guard with a `mounted` flag (`let mounted = true;` set to `false` in cleanup) before calling `setState`.
- In the effect cleanup: `if (rafId !== null) cancelAnimationFrame(rafId);` and reset ticking/mounted state as needed.

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

- Add `{ passive: true }` to global `scroll` event listeners to prevent scroll blocking.
- Throttle state updates inside scroll handlers using `requestAnimationFrame` to run at most once per frame.
- Tested and verified no visual regression on scrolling.
- Fixed unused variable `screen` in `__tests__/snapshots/elements/BackToTop.test.tsx` causing CI lint error.

Co-authored-by: anyulled <100741+anyulled@users.noreply.github.com>
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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
components/layout/Layout.tsx (1)

83-93: Consider extracting a shared useScrolled hook to reduce duplication.

The RAF-throttled scroll pattern is now duplicated across three files (Layout.tsx, DynamicHeaderWrapper.tsx, BackToTop.tsx). A custom hook would centralize this logic and ensure consistency.

♻️ Example shared hook
// hooks/useScrolled.ts
import { useEffect, useState } from "react";

export function useScrolled(threshold = 100): boolean {
  const [scrolled, setScrolled] = useState(false);

  useEffect(() => {
    const state = { isTicking: false };
    const handleScroll = () => {
      if (!state.isTicking) {
        window.requestAnimationFrame(() => {
          setScrolled((prev) => {
            const check = window.scrollY > threshold;
            return prev !== check ? check : prev;
          });
          state.isTicking = false;
        });
        state.isTicking = true;
      }
    };

    window.addEventListener("scroll", handleScroll, { passive: true });
    return () => window.removeEventListener("scroll", handleScroll);
  }, [threshold]);

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

In `@components/layout/Layout.tsx` around lines 83 - 93, Extract the RAF-throttled
scroll logic from Layout.tsx into a reusable hook (e.g., export function
useScrolled(threshold = 100)) and replace the inline state/handleScroll in
Layout.tsx (the state object, handleScroll function, and setScroll usage) with
this hook; the hook should encapsulate the requestAnimationFrame pattern,
add/remove the "scroll" listener with passive: true, accept a threshold
parameter, and return a boolean scrolled value so Layout.tsx (and
DynamicHeaderWrapper.tsx and BackToTop.tsx) can simply call useScrolled() and
use the returned boolean instead of duplicating the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@__tests__/components/layout/DynamicHeaderWrapper.test.tsx`:
- Around line 55-57: Update the requestAnimationFrame stub in
DynamicHeaderWrapper.test.tsx so its signature matches real RAF: accept the
callback (cb) and call it with a numeric timestamp, then return a numeric
request ID; specifically replace the current stub window.requestAnimationFrame =
(cb) => { cb(); } with one that invokes cb(performance.now() or Date.now()) and
returns a non-zero integer, mirroring the pattern used in BackToTop.test.tsx and
Layout.test.tsx to ensure the FrameRequestCallback and return value are correct.

In `@__tests__/components/layout/Layout.test.tsx`:
- Around line 99-101: The requestAnimationFrame stub in the test currently calls
cb() without the DOMHighResTimeStamp and doesn't return an ID; update the stub
for window.requestAnimationFrame to match the real signature by accepting a
FrameRequestCallback, invoking it with performance.now() (or Date.now()) and
returning a numeric request id (e.g., 1) so it matches the stub in
BackToTop.test.tsx and prevents signature inconsistencies when tests run.

In `@__tests__/snapshots/elements/BackToTop.test.tsx`:
- Line 2: The import statement in BackToTop.test.tsx currently imports render,
fireEvent, and screen but screen is unused; update the import at the top (the
line that imports render, fireEvent, screen from "@testing-library/react") to
remove screen so only render and fireEvent are imported, which will resolve the
unused-import lint error.
- Around line 11-20: The test in BackToTop.test.tsx exercises a scroll but has
no assertions; update the it("updates scroll state on scroll event") test to
assert the component's response (e.g., that the internal hasScrolled state
becomes true and the back-to-top control is rendered/visible). After setting
window.scrollY and dispatching the scroll (or using window.requestAnimationFrame
stub), wrap the event in act() or use waitFor() to let effects run, then query
the BackToTop UI (by role, text, or test id used in the BackToTop component) and
assert it exists/is visible; also restore any window stubs
(scrollY/requestAnimationFrame) after the test. Ensure you reference the
BackToTop component and its hasScrolled-driven output when adding the
assertions.

---

Nitpick comments:
In `@components/layout/Layout.tsx`:
- Around line 83-93: Extract the RAF-throttled scroll logic from Layout.tsx into
a reusable hook (e.g., export function useScrolled(threshold = 100)) and replace
the inline state/handleScroll in Layout.tsx (the state object, handleScroll
function, and setScroll usage) with this hook; the hook should encapsulate the
requestAnimationFrame pattern, add/remove the "scroll" listener with passive:
true, accept a threshold parameter, and return a boolean scrolled value so
Layout.tsx (and DynamicHeaderWrapper.tsx and BackToTop.tsx) can simply call
useScrolled() and use the returned boolean instead of duplicating the logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fdfc1716-1bc1-4295-a54a-0836da25eb5e

📥 Commits

Reviewing files that changed from the base of the PR and between d6e25aa and 2e41957.

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

Comment on lines +11 to +20
it("updates scroll state on scroll event", () => {
render(<BackToTop target="#top" />);

Object.defineProperty(window, "scrollY", { value: 150, writable: true, configurable: true });
window.requestAnimationFrame = (cb) => {
cb(performance.now());
return 1;
};
fireEvent.scroll(window);
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Test lacks assertions — it exercises code but verifies nothing.

The test fires a scroll event but never asserts that hasScrolled updated or that the "back to top" element is rendered. Without assertions, this test provides no regression protection.

🧪 Proposed fix — add meaningful assertions
   it("updates scroll state on scroll event", () => {
-    render(<BackToTop target="#top" />);
+    const { container } = render(<BackToTop target="#top" />);
+
+    // Initially not scrolled - element should not be visible
+    expect(container.querySelector(".paginacontainer")).toBeNull();

     Object.defineProperty(window, "scrollY", { value: 150, writable: true, configurable: true });
     window.requestAnimationFrame = (cb) => {
       cb(performance.now());
       return 1;
     };
     fireEvent.scroll(window);
+
+    // After scrolling past 100px, the back-to-top element should appear
+    expect(container.querySelector(".paginacontainer")).toBeInTheDocument();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__tests__/snapshots/elements/BackToTop.test.tsx` around lines 11 - 20, The
test in BackToTop.test.tsx exercises a scroll but has no assertions; update the
it("updates scroll state on scroll event") test to assert the component's
response (e.g., that the internal hasScrolled state becomes true and the
back-to-top control is rendered/visible). After setting window.scrollY and
dispatching the scroll (or using window.requestAnimationFrame stub), wrap the
event in act() or use waitFor() to let effects run, then query the BackToTop UI
(by role, text, or test id used in the BackToTop component) and assert it
exists/is visible; also restore any window stubs (scrollY/requestAnimationFrame)
after the test. Ensure you reference the BackToTop component and its
hasScrolled-driven output when adding the assertions.

- Add `{ passive: true }` to global `scroll` event listeners to prevent scroll blocking.
- Throttle state updates inside scroll handlers using `requestAnimationFrame` to run at most once per frame.
- Tested and verified no visual regression on scrolling.
- Fixed unused variable `screen` in `__tests__/snapshots/elements/BackToTop.test.tsx` causing CI lint error.

Co-authored-by: anyulled <100741+anyulled@users.noreply.github.com>
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