diff --git a/.jules/bolt.md b/.jules/bolt.md index d81fd70a..2d6874d2 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -17,3 +17,8 @@ **Learning:** When attempting to optimize an O(N^2) array spread operation (`[...existing, talk]`) inside a grouping loop in `groupTalksByTrack`, the purely functional/immutable constraint specified by the team (and the lack of `Map.groupBy` support in Node 20.x Jest environments) means that we must fall back to immutable reductions. **Action:** When constraints require strict immutability without mutation of objects, use `reduce` with object and array spreads (e.g., `{ ...acc, [key]: [...(acc[key] || []), item] }`) even if it introduces O(N^2) overhead for large arrays. Avoid using `push()` or modifying accumulators directly. Always run Prettier/formatting checks before merge to resolve CI failures. + +## 2026-04-12 - React Event Handler Debouncing Anti-Pattern + +**Learning:** Found a broken debounce implementation in `TalksFilterBar.tsx` where `setTimeout` was called inside an event handler (`handleSearchChange`), and a cleanup function was returned. React event handlers (unlike `useEffect`) do not consume returned cleanup functions. As a result, every keystroke queued a new timer and state update, completely failing to debounce and causing redundant renders/URL updates. +**Action:** Always use a `useRef` to store the timeout ID for debouncing inside event handlers, manually clear it before setting a new one, and clean it up in a `useEffect` on component unmount. diff --git a/components/layout/TalksFilterBar.tsx b/components/layout/TalksFilterBar.tsx index c05acb1f..69351cec 100644 --- a/components/layout/TalksFilterBar.tsx +++ b/components/layout/TalksFilterBar.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useState, useTransition } from "react"; +import { useCallback, useEffect, useState, useTransition, useRef } from "react"; import { motion } from "framer-motion"; interface TalksFilterBarProps { @@ -21,6 +21,15 @@ export default function TalksFilterBar({ tracks, year: _year }: TalksFilterBarPr const [selectedTrack, setSelectedTrack] = useState(searchParams.get("track") || ""); const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || ""); + const searchTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); // Update state when URL changes useEffect(() => { @@ -60,11 +69,15 @@ export default function TalksFilterBar({ tracks, year: _year }: TalksFilterBarPr const handleSearchChange = (e: React.ChangeEvent) => { const newQuery = e.target.value; setSearchQuery(newQuery); + + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + // Debounce the URL update for search - const timeoutId = setTimeout(() => { + searchTimeoutRef.current = setTimeout(() => { updateFilters(selectedTrack, newQuery); }, 300); - return () => clearTimeout(timeoutId); }; return (