From f4c6b27cd952361e33786fd7db0c8c34fe7e0241 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 19 Mar 2026 11:05:33 -0400 Subject: [PATCH] refactor(web): replace `ProgressProvider` context with a self-contained `NavigationProgress` component driven by TanStack Router lifecycle events - Remove `ProgressContext`, `useProgress`, and manual `progress.start()` calls from `CommandPalette`, `TitleKeyboardShortcuts`, and `__root.tsx` - Replace DOM click/popstate listeners with `router.subscribe("onBeforeLoad")` and `router.subscribe("onResolved")` so progress starts/stops automatically on every navigation - Render `` as a sibling in the root instead of wrapping the tree in a provider --- apps/web/src/components/command-palette.tsx | 9 +- .../src/components/navigation-progress.tsx | 139 ++++-------------- .../titles/title-keyboard-shortcuts.tsx | 11 +- apps/web/src/routes/__root.tsx | 11 +- 4 files changed, 32 insertions(+), 138 deletions(-) diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index bd2c15f..15e2e64 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -14,7 +14,6 @@ import { useNavigate } from "@tanstack/react-router"; import { useAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useProgress } from "@/components/navigation-progress"; import { Command, CommandEmpty, @@ -82,7 +81,6 @@ interface SearchResult { export function CommandPalette() { const { t } = useLingui(); const navigate = useNavigate(); - const progress = useProgress(); const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(commandPaletteOpenAtom); const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom); const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom); @@ -103,7 +101,6 @@ export function CommandPalette() { useHotkeySequence( ["G", "H"], () => { - progress.start(); void navigate({ to: "/dashboard" }); }, { enabled, timeout: 500 }, @@ -111,7 +108,6 @@ export function CommandPalette() { useHotkeySequence( ["G", "E"], () => { - progress.start(); void navigate({ to: "/explore" }); }, { enabled, timeout: 500 }, @@ -146,14 +142,13 @@ export function CommandPalette() { (result: SearchResult) => { if (!result.id) return; setCommandPaletteOpen(false); - progress.start(); if (result.type === "person") { void navigate({ to: "/people/$id", params: { id: result.id } }); } else { void navigate({ to: "/titles/$id", params: { id: result.id } }); } }, - [setCommandPaletteOpen, progress, navigate], + [setCommandPaletteOpen, navigate], ); const handleRecentSearch = useCallback((q: string) => { @@ -337,7 +332,6 @@ export function CommandPalette() { { setCommandPaletteOpen(false); - progress.start(); void navigate({ to: "/dashboard" }); }} > @@ -348,7 +342,6 @@ export function CommandPalette() { { setCommandPaletteOpen(false); - progress.start(); void navigate({ to: "/explore" }); }} > diff --git a/apps/web/src/components/navigation-progress.tsx b/apps/web/src/components/navigation-progress.tsx index 0a9ff3c..db5b6a4 100644 --- a/apps/web/src/components/navigation-progress.tsx +++ b/apps/web/src/components/navigation-progress.tsx @@ -1,29 +1,12 @@ -import { useLocation } from "@tanstack/react-router"; -import { - createContext, - type ReactNode, - useContext, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, -} from "react"; - -type ProgressApi = { - start: () => void; - done: () => void; - set: (pct: number) => void; -}; - -const ProgressContext = createContext(null); +import { useRouter } from "@tanstack/react-router"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)); } -export function ProgressProvider({ children }: { children: ReactNode }) { - const { pathname, searchStr } = useLocation(); +export function NavigationProgress() { + const router = useRouter(); const [visible, setVisible] = useState(false); const [progress, setProgress] = useState(0); @@ -41,7 +24,7 @@ export function ProgressProvider({ children }: { children: ReactNode }) { clearTimeout(safetyTimerRef.current); } - function start() { + const start = useEffectEvent(() => { if (inFlightRef.current) return; inFlightRef.current = true; clearTimers(); @@ -65,10 +48,10 @@ export function ProgressProvider({ children }: { children: ReactNode }) { clearTimers(); setVisible(false); setProgress(0); - }, 12000); - } + }, 12_000); + }); - function done() { + const done = useEffectEvent(() => { if (!inFlightRef.current) return; inFlightRef.current = false; clearTimers(); @@ -80,67 +63,19 @@ export function ProgressProvider({ children }: { children: ReactNode }) { setVisible(false); setTimeout(() => setProgress(0), 200); }, 200); - } - - function set(pct: number) { - const next = clamp(pct, 0, 100); - if (next >= 100) { - done(); - return; - } - if (!inFlightRef.current) start(); - setProgress(next); - } - - // Effect events — always see latest closures, don't appear in deps - const onLinkClick = useEffectEvent((e: MouseEvent) => { - if (e.defaultPrevented || e.button !== 0) return; - if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return; - - const anchor = (e.target as Element)?.closest?.("a[href]"); - if (!(anchor instanceof HTMLAnchorElement)) return; - if (anchor.target && anchor.target !== "_self") return; - if (anchor.hasAttribute("download")) return; - - const href = anchor.getAttribute("href"); - if (!href || href.startsWith("#")) return; - - try { - const url = new URL(href, window.location.href); - if (url.origin !== window.location.origin) return; - } catch { - return; - } - - start(); }); - const onPopState = useEffectEvent(() => start()); - - const onRouteChange = useEffectEvent(() => { - if (inFlightRef.current) done(); - }); - - // Set up listeners once + // Subscribe to TanStack Router navigation lifecycle events useEffect(() => { - document.addEventListener("click", onLinkClick, true); - window.addEventListener("popstate", onPopState); + const unsubBeforeLoad = router.subscribe("onBeforeLoad", (event) => { + if (event.pathChanged) start(); + }); + const unsubResolved = router.subscribe("onResolved", () => done()); return () => { - document.removeEventListener("click", onLinkClick, true); - window.removeEventListener("popstate", onPopState); + unsubBeforeLoad(); + unsubResolved(); }; - }, []); - - // Finish on route change — routeKey is intentionally a dep to trigger on navigation - const routeKey = pathname + (searchStr ?? ""); - const firstRenderRef = useRef(true); - useEffect(() => { - if (firstRenderRef.current) { - firstRenderRef.current = false; - return; - } - onRouteChange(); - }, [routeKey]); + }, [router]); // Cleanup timers on unmount useEffect(() => { @@ -152,40 +87,16 @@ export function ProgressProvider({ children }: { children: ReactNode }) { }; }, []); - // Stable context API via ref indirection - const apiRef = useRef({ start, done, set }); - apiRef.current = { start, done, set }; - - const api = useMemo( - () => ({ - start: () => apiRef.current.start(), - done: () => apiRef.current.done(), - set: (pct) => apiRef.current.set(pct), - }), - [], - ); - return ( - +