diff --git a/.jules/bolt.md b/.jules/bolt.md index 6c01db7f..fde64210 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -12,3 +12,8 @@ **Learning:** Found `useEffect` fetching static content (Speakers) in a Client Component (`Section5`) on the homepage. This caused unnecessary layout shifts and delayed LCP. **Action:** Move data fetching to the parent Server Component (`page.tsx`) and pass data as props. This leverages ISR caching and eliminates client-side waterfall. + +## 2026-03-17 - Scroll Event Listener Anti-Pattern + +**Learning:** Found multiple instances of `scroll` event listeners attached to the `window` or `document` object without debouncing or throttling. These events can trigger frequently and block the main thread. +**Action:** When adding scroll event listeners, use `{ passive: true }` options and throttle execution using `requestAnimationFrame` to prevent main-thread blocking. Ensure local state variables used for throttling are wrapped in a constant object to satisfy ESLint `no-restricted-syntax`. diff --git a/__tests__/components/layout/DynamicHeaderWrapper.test.tsx b/__tests__/components/layout/DynamicHeaderWrapper.test.tsx index 45f30ebc..1f2d8dc8 100644 --- a/__tests__/components/layout/DynamicHeaderWrapper.test.tsx +++ b/__tests__/components/layout/DynamicHeaderWrapper.test.tsx @@ -51,9 +51,16 @@ describe("DynamicHeaderWrapper", () => { expect(screen.getByTestId("header8")).toHaveAttribute("data-scroll", "false"); + const rafMock = jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 1; + }); + Object.defineProperty(window, "scrollY", { value: 120, writable: true, configurable: true }); fireEvent.scroll(document); expect(screen.getByTestId("header8")).toHaveAttribute("data-scroll", "true"); + + rafMock.mockRestore(); }); }); diff --git a/__tests__/components/layout/Layout.test.tsx b/__tests__/components/layout/Layout.test.tsx index e326e1c3..82a5fd13 100644 --- a/__tests__/components/layout/Layout.test.tsx +++ b/__tests__/components/layout/Layout.test.tsx @@ -95,9 +95,16 @@ describe("Layout", () => { expect(screen.getByTestId("header-1")).toHaveAttribute("data-scroll", "false"); + const rafMock = jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 1; + }); + Object.defineProperty(window, "scrollY", { value: 150, writable: true, configurable: true }); fireEvent.scroll(document); expect(screen.getByTestId("header-1")).toHaveAttribute("data-scroll", "true"); + + rafMock.mockRestore(); }); }); diff --git a/components/elements/BackToTop.tsx b/components/elements/BackToTop.tsx index 3fdb6396..f70e716c 100644 --- a/components/elements/BackToTop.tsx +++ b/components/elements/BackToTop.tsx @@ -5,11 +5,19 @@ 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); }, []); diff --git a/components/layout/DynamicHeaderWrapper.tsx b/components/layout/DynamicHeaderWrapper.tsx index 1206feb6..5f1de3b9 100644 --- a/components/layout/DynamicHeaderWrapper.tsx +++ b/components/layout/DynamicHeaderWrapper.tsx @@ -16,13 +16,21 @@ export default function DynamicHeaderWrapper({ navigation }: Readonly(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; + 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); }; diff --git a/components/layout/Layout.tsx b/components/layout/Layout.tsx index 7f289889..778a5ffc 100644 --- a/components/layout/Layout.tsx +++ b/components/layout/Layout.tsx @@ -79,14 +79,22 @@ export default function Layout({ headerStyle, footerStyle, breadcrumbTitle: _bre 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);