Skip to content
Closed
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
4 changes: 4 additions & 0 deletions __tests__/components/layout/DynamicHeaderWrapper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ describe("DynamicHeaderWrapper", () => {
expect(screen.getByTestId("header8")).toHaveAttribute("data-scroll", "false");

Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true });
window.requestAnimationFrame = (cb) => {
cb(performance.now());
return 1;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fireEvent.scroll(document);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

expect(screen.getByTestId("header8")).toHaveAttribute("data-scroll", "true");
Expand Down
4 changes: 4 additions & 0 deletions __tests__/components/layout/Layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ describe("Layout", () => {
expect(screen.getByTestId("header-1")).toHaveAttribute("data-scroll", "false");

Object.defineProperty(window, "scrollY", { value: 150, writable: true, configurable: true });
window.requestAnimationFrame = (cb) => {
cb(performance.now());
return 1;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fireEvent.scroll(document);

expect(screen.getByTestId("header-1")).toHaveAttribute("data-scroll", "true");
Expand Down
13 changes: 12 additions & 1 deletion __tests__/snapshots/elements/BackToTop.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { expect, describe, it } from "@jest/globals";
import { render } from "@testing-library/react";
import { render, fireEvent } from "@testing-library/react";
import BackToTop from "@/components/elements/BackToTop";

describe("BackToTop Component", () => {
it("matches snapshot", () => {
const { container } = render(<BackToTop target="#top" />);
expect(container).toMatchSnapshot();
});

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);
});
Comment on lines +11 to +20
Copy link
Copy Markdown
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 +11 to +20
Copy link
Copy Markdown
Contributor

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.

});
11 changes: 9 additions & 2 deletions components/elements/BackToTop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ export default function BackToTop({ target }: Readonly<{ target: string }>) {
const [hasScrolled, setHasScrolled] = useState(false);

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);
}, []);
Comment on lines 7 to 21
Copy link
Copy Markdown
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);
    };
  }, []);


Expand Down
15 changes: 10 additions & 5 deletions components/layout/DynamicHeaderWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,22 @@ export default function DynamicHeaderWrapper({ navigation }: Readonly<DynamicHea
const [scroll, setScroll] = useState<boolean>(false);

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]);
}, []);
Comment on lines 18 to +34
Copy link
Copy Markdown
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);
    };
  }, []);


return (
<>
Expand Down
16 changes: 11 additions & 5 deletions components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,25 @@

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;
}
Comment on lines +83 to 92
Copy link
Copy Markdown

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

};

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

return () => {
document.removeEventListener("scroll", handleScroll);
};
}, [scroll]);
}, []);
Comment on lines 80 to +100
Copy link
Copy Markdown
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);
    };
  }, []);


const defaultNavigation: EditionNavigation = {
main: mainNavLinks,
Expand All @@ -101,9 +107,9 @@

const resolvedHeaderStyle = headerStyle || 1;
const resolvedFooterStyle = footerStyle || 1;
const headerRenderer = headerRenderers[resolvedHeaderStyle] ?? headerRenderers[1];

Check warning on line 110 in components/layout/Layout.tsx

View workflow job for this annotation

GitHub Actions / lint

Generic Object Injection Sink
const headerElement = headerRenderer({ scroll, isSearch, handleSearch }, defaultNavigation);
const footerElement = footerComponents[resolvedFooterStyle] ?? footerComponents[1];

Check warning on line 112 in components/layout/Layout.tsx

View workflow job for this annotation

GitHub Actions / lint

Generic Object Injection Sink

return (
<>
Expand Down
Loading