From b8f8569e6e73ab179e71e7b5a01c44dc13cbe1c0 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 15:36:55 +0200 Subject: [PATCH] fix(hooks): stop infinite render loop in useViewResource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useViewResource built `reload` with `load` in its useCallback deps, and the fetch effect depended on `reload`. Callers routinely pass an inline `load` closure (e.g. JobTable), so `load` — and therefore `reload` and the effect — changed every render, calling setState and re-rendering: an unbounded "Maximum update depth exceeded" loop that froze the renderer on /jobs and every other list view (DashboardView, RemindersView, CompaniesTable). Fix: hold `load` in a ref (like the existing hasLoadedRef) and drop it from the dependency arrays. Re-fetching is still driven by `deps`/`enabled`; the ref always points at the latest closure. No API/behaviour change for callers. Runtime-verified live: /jobs went from a render storm (frozen renderer, 100s of console errors) to 0 errors in a 2s window and a clean render. Suites that drive JobTable→useViewResource pass in isolation; the remaining full-run flakiness is pre-existing (state-pollution/timing in the heavy RTL suites, unrelated). Co-Authored-By: Claude Opus 4.8 --- docs/performance/PERFORMANCE_IMPROVEMENTS.md | 1 + job-tracker-ui/src/hooks/useViewResource.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/performance/PERFORMANCE_IMPROVEMENTS.md b/docs/performance/PERFORMANCE_IMPROVEMENTS.md index 2ceab9a..62eebaf 100644 --- a/docs/performance/PERFORMANCE_IMPROVEMENTS.md +++ b/docs/performance/PERFORMANCE_IMPROVEMENTS.md @@ -5,6 +5,7 @@ ## Changes made (this pass) | Change | File | Effect | Verified | |---|---|---|---| +| **Stop an infinite render loop on every list view** — hold `load` in a ref in `useViewResource` so `reload`/the fetch effect keep a stable identity | `job-tracker-ui/src/hooks/useViewResource.ts` | Fixes "Maximum update depth exceeded" on `/jobs` (and any `DashboardView`/`RemindersView`/`CompaniesTable` view whose caller passes an inline `load`) — pegged the CPU/renderer | **Runtime-confirmed**: `/jobs` went from a render storm (renderer frozen, 100s of errors) to 0 console errors in a live 2s window and a clean render; `workflow-trust-signals` (drives `JobTable`→`useViewResource`) passes | | **Stop the infinite `/auth/me` request loop** — make `clearAuthClientState` emit `auth-changed` only on a real signed-in→out transition | `job-tracker-ui/src/auth.ts` | Eliminates a runaway request storm (100+ `GET /auth/me` and climbing) that ran continuously whenever the user was logged out | **Runtime-confirmed** in a live stack: `/auth/me` count 100+ & growing → 0 and stable after fix | | Revoke CV-preview blob URLs on unmount only (ref-based), not on every carousel change | `job-tracker-ui/src/pages/ProfilePage.tsx` | Fixes broken previews on multi-template decks; still frees URLs on unmount | `profile-page.test.tsx` 5/5 | diff --git a/job-tracker-ui/src/hooks/useViewResource.ts b/job-tracker-ui/src/hooks/useViewResource.ts index de6aee9..76a2439 100644 --- a/job-tracker-ui/src/hooks/useViewResource.ts +++ b/job-tracker-ui/src/hooks/useViewResource.ts @@ -70,6 +70,17 @@ export function useViewResource( hasLoadedRef.current = hasLoaded; }, [hasLoaded]); + // Hold `load` in a ref so `reload` (and the fetch effect that depends on it) + // keep a stable identity across renders. Callers routinely pass an inline + // `load` closure; if `load` were a dependency, every render would create a new + // `reload`, re-run the effect, setState, and re-render — an infinite loop + // ("Maximum update depth exceeded"). Re-fetching is driven by `deps`/`enabled` + // instead, and the ref always points at the latest closure. + const loadRef = useRef(load); + useEffect(() => { + loadRef.current = load; + }); + const reload = useCallback(async () => { if (!enabled) return; @@ -77,7 +88,7 @@ export function useViewResource( setLoading(!alreadyLoaded); setRefreshing(alreadyLoaded); try { - const next = await load(); + const next = await loadRef.current(); setData(next); setError(null); setHasLoaded(true); @@ -88,7 +99,7 @@ export function useViewResource( setLoading(false); setRefreshing(false); } - }, [enabled, errorMessage, load]); + }, [enabled, errorMessage]); useEffect(() => { if (!enabled) {