diff --git a/src/App.tsx b/src/App.tsx index fc2466c..47a83c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,8 +15,13 @@ import { useTheme } from '@/hooks/use-theme' import { useAuth } from '@/hooks/use-auth' import { usePersistentState } from '@/hooks/use-persistent-state' import { cn } from '@/lib/utils' +import type { RequestTrace } from '@/lib/types' const DEFAULT_SORT: MetricSort = { key: 'count', dir: 'desc' } +// Stable stand-in for "no traces yet" (before the first snapshot arrives). +// useTraceStore keys its effects off this array's identity, so handing it a +// fresh `[]` on every render would re-trigger them on every render. +const NO_TRACES: RequestTrace[] = [] export default function App() { const { authenticated, signIn } = useAuth() @@ -29,7 +34,7 @@ export default function App() { // Retain trace history in IndexedDB for a real platform; pass mock through. const persist = conn.status === 'live' || conn.status === 'disconnected' || conn.status === 'paused' - const traceStore = useTraceStore(data?.traces ?? [], persist) + const traceStore = useTraceStore(data?.traces ?? NO_TRACES, persist) const selected = useMemo(() => { if (!selectedModuleId || !data) return null diff --git a/src/hooks/use-trace-store.ts b/src/hooks/use-trace-store.ts index 736e6ab..8650160 100644 --- a/src/hooks/use-trace-store.ts +++ b/src/hooks/use-trace-store.ts @@ -24,6 +24,11 @@ function merge(a: StoredTrace[], b: StoredTrace[]): StoredTrace[] { return [...byKey.values()].sort((x, y) => y.epochMillis - x.epochMillis) } +/** True when both lists hold the same trace keys in the same order. */ +function sameRows(a: StoredTrace[], b: StoredTrace[]): boolean { + return a.length === b.length && a.every((t, i) => t._key === b[i]._key) +} + export interface TraceStoreView { rows: StoredTrace[] total: number @@ -48,11 +53,14 @@ export function useTraceStore(liveTraces: RequestTrace[], persist: boolean): Tra const rowsRef = useRef(rows) rowsRef.current = rows - // Sample mode: no persistence, just show what we were handed. + // Sample mode: no persistence, just show what we were handed. `passthrough` is + // a fresh array each run, so it is only committed when the contents actually + // differ — otherwise a caller whose `liveTraces` identity changes per render + // would drive setRows → render → effect until React's nested-update cap. useEffect(() => { if (persist) return const passthrough = liveTraces.map(withKey).sort((a, b) => b.epochMillis - a.epochMillis) - setRows(passthrough) + setRows((prev) => (sameRows(prev, passthrough) ? prev : passthrough)) setTotal(passthrough.length) setHasMore(false) }, [persist, liveTraces])