From cf7aee26603848924f54066cb6554fe50c0c41ec Mon Sep 17 00:00:00 2001 From: kewton Date: Tue, 14 Apr 2026 11:45:38 +0900 Subject: [PATCH] fix(sidebar): prevent spurious full-page reloads on rapid branch clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: handleBranchClick created a new 300ms fallback timer on every click, but the cleanup function returned by useCallback was discarded (event handlers ignore return values), and popstate does not fire on programmatic router.push calls. As a result, timers accumulated — each one comparing a stale targetPath against the current pathname. Rapid clicks caused old timers to fire after navigation had already moved to a newer item, triggering `window.location.href` full-page reloads. Fix: track the single active timer in a ref (fallbackTimerRef). Each click cancels the previous timer before scheduling a new one, ensuring only the most-recently-clicked item can trigger the fallback. Also removed the broken popstate listener and increased delay to 500ms. Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/Sidebar.tsx | 35 +++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 2fddebfe..63333846 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -156,27 +156,36 @@ export const Sidebar = memo(function Sidebar() { })); }, []); - // Handle branch selection - // Fallback: if router.push fails to navigate (e.g., Next.js Router Cache corruption), - // use window.location.href after a short delay to ensure navigation succeeds. + // Ref to track the single active fallback timer. + // Using a ref (not state) so updates don't trigger re-renders. + const fallbackTimerRef = useRef | null>(null); + + // Handle branch selection. + // Fallback: if router.push silently fails (e.g., Next.js Router Cache corruption), + // navigate via window.location.href after a short delay. + // + // Bug fix: previously each click created a new timer whose cleanup was never + // invoked (useCallback return values are discarded by event handlers), and + // popstate does not fire on programmatic router.push calls. Accumulated timers + // compared stale target paths against the current pathname and triggered spurious + // full-page reloads. Using a single ref-tracked timer ensures only the latest + // click's fallback can fire. const handleBranchClick = useCallback((branchId: string) => { selectWorktree(branchId); const targetPath = `/worktrees/${branchId}`; router.push(targetPath); closeMobileDrawer(); - // Fallback navigation if router.push silently fails - const timerId = setTimeout(() => { + + // Cancel any timer from a previous click before setting a new one. + if (fallbackTimerRef.current !== null) { + clearTimeout(fallbackTimerRef.current); + } + fallbackTimerRef.current = setTimeout(() => { + fallbackTimerRef.current = null; if (window.location.pathname !== targetPath) { window.location.href = targetPath; } - }, 300); - // Cleanup: if route changes before timeout, cancel fallback - const handleRouteChange = () => clearTimeout(timerId); - window.addEventListener('popstate', handleRouteChange, { once: true }); - return () => { - clearTimeout(timerId); - window.removeEventListener('popstate', handleRouteChange); - }; + }, 500); }, [selectWorktree, router, closeMobileDrawer]); // DnD sensors: require 8px move before activating (distinguishes click from drag)