diff --git a/src/packages/guide/src/components/climate-sim/ClimateSimDisplay.tsx b/src/packages/guide/src/components/climate-sim/ClimateSimDisplay.tsx index 0719d35d..00c15d99 100644 --- a/src/packages/guide/src/components/climate-sim/ClimateSimDisplay.tsx +++ b/src/packages/guide/src/components/climate-sim/ClimateSimDisplay.tsx @@ -14,7 +14,7 @@ import { useSimulationWorker } from '@/hooks/useSimulationWorker' import { HexGLRenderer } from './HexGLRenderer' import { LayerPanel, ENVIRONMENT_DEFAULT_MASK, LIFE_DEFAULT_MASK } from './LayerPanel' import type { SimCategory } from './LayerPanel' -import { ScenarioTabs } from './ScenarioTabs' +import { ScenarioTabs, SCENARIO_GROUPS } from './ScenarioTabs' import { TerrainLegend } from './TerrainLegend' import { EventControlPanel } from './EventControlPanel' import { EventLog } from './EventLog' @@ -113,8 +113,9 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React const noGui = (searchParams.get('noGui') ?? searchParams.get('nogui')) === 'true' - const initialPlanet = PLANETS.find((p) => p.id === urlPlanet && p.available) ? urlPlanet! : 'earth' - const initialId = urlName && VALID_IDS.has(urlName) ? urlName : (SCENARIOS.find((s) => (s.planet ?? 'earth') === initialPlanet)?.id ?? SCENARIOS[0]?.id ?? '') + const initialPlanet = PLANETS.find((p) => p.id === urlPlanet && p.available) ? urlPlanet! : PLANETS[0]!.id + const defaultScenarioId = SCENARIO_GROUPS.find((g) => g.planet === initialPlanet)?.scenarioIds[0] ?? SCENARIOS[0]?.id ?? '' + const initialId = urlName && VALID_IDS.has(urlName) ? urlName : defaultScenarioId const initialView: ViewCenter = urlView && VALID_VIEWS.has(urlView as ViewCenter) ? (urlView as ViewCenter) : 'equator' // URL turns are 1-indexed (first=1, last=totalTurns); internal state is 0-indexed const initialTurn = urlTurn != null ? Math.max(0, Math.floor(Number(urlTurn)) - 1) : 0 @@ -152,18 +153,22 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React const planetRef = useRef(null) const activePlanetDef = PLANETS.find((p) => p.id === activePlanet) ?? PLANETS[0]! - const rafRef = useRef(0) - const lastRef = useRef(0) - const turnRef = useRef(initialTurn) - const pausedRef = useRef(initialPause) - const speedRef = useRef(1) - const loopingRef = useRef(initialLoop) - const lastSyncedTurnRef = useRef(-1) + const rafRef = useRef(0) + const lastRef = useRef(0) + const turnRef = useRef(initialTurn) + const pausedRef = useRef(initialPause) + const speedRef = useRef(1) + const loopingRef = useRef(initialLoop) + const lastSyncedTurnRef = useRef(-1) + // Ref mirror of activeScenarioId so the RAF tick closure always reads the current value + // without the RAF effect needing activeScenarioId in its dependency array. + const activeScenarioIdRef = useRef(activeScenarioId) useEffect(() => { pausedRef.current = paused }, [paused]) useEffect(() => { speedRef.current = simSpeed }, [simSpeed]) useEffect(() => { loopingRef.current = looping }, [looping]) + useEffect(() => { activeScenarioIdRef.current = activeScenarioId }, [activeScenarioId]) useEffect(() => { if (!planetOpen) return @@ -201,10 +206,14 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React const scenarioData = workerScenarios.get(activeScenarioId) const totalTurns = scenarioData?.totalTurns ?? initialTurns + // Fires once when bufferReady becomes true and on scenario changes. + // Per-turn requests during animation are handled directly inside the RAF tick + // to avoid calling setCurrentFrame from inside a passive effect (which would + // accumulate nestedPassiveUpdateCount and hit React 19's 50-update limit). useEffect(() => { if (!scenarioData?.bufferReady) return - requestFrame(activeScenarioId, currentTurn) - }, [activeScenarioId, currentTurn, scenarioData, requestFrame]) + requestFrame(activeScenarioId, turnRef.current) + }, [activeScenarioId, scenarioData?.bufferReady, requestFrame]) // eslint-disable-line react-hooks/exhaustive-deps // ── URL sync ─────────────────────────────────────────────────────────── const viewRef = useRef(initialView) @@ -254,12 +263,13 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React // Pressing play while at the end → restart from beginning if (!next && turnRef.current >= totalTurns - 1) { turnRef.current = 0 + requestFrame(activeScenarioId, 0) setCurrentTurn(0) } pausedRef.current = next setPaused(next) syncUrl(activeScenarioId, turnRef.current, next, {}) - }, [activeScenarioId, totalTurns, syncUrl]) + }, [activeScenarioId, totalTurns, syncUrl, requestFrame]) const handleToggleLoop = useCallback(() => { const next = !loopingRef.current @@ -278,9 +288,10 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React const handleScrub = useCallback((turn: number) => { const t = clampTurn(turn, totalTurns) turnRef.current = t + requestFrame(activeScenarioId, t) setCurrentTurn(t) syncUrl(activeScenarioId, t, pausedRef.current, {}) - }, [activeScenarioId, totalTurns, syncUrl]) + }, [activeScenarioId, totalTurns, syncUrl, requestFrame]) const handleExtend = useCallback(() => { workerExtend(activeScenarioId, EXTEND_TURNS) @@ -331,16 +342,24 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React // ── animation loop ───────────────────────────────────────────────────── + // requestFrame is called directly inside tick (not via a separate useEffect) so + // that setCurrentFrame is dispatched from a RAF macro-task rather than from a + // passive effect. Calling setState from useEffect increments React 19's + // nestedPassiveUpdateCount; at 80 ms/frame the counter reaches the 50-update + // limit in under 5 seconds, triggering "Maximum update depth exceeded". useEffect(() => { if (!scenarioData?.bufferReady) return + let cancelled = false const tick = (now: number): void => { + if (cancelled) return if (!pausedRef.current && now - lastRef.current >= FRAME_MS) { lastRef.current = now const next = turnRef.current + speedRef.current if (next >= totalTurns) { if (loopingRef.current) { turnRef.current = 0 + requestFrame(activeScenarioIdRef.current, 0) setCurrentTurn(0) } else { pausedRef.current = true @@ -348,23 +367,30 @@ export function ClimateSimDisplay({ easterEggs }: ClimateSimDisplayProps): React } } else { turnRef.current = next + requestFrame(activeScenarioIdRef.current, next) setCurrentTurn(next) } } rafRef.current = requestAnimationFrame(tick) } rafRef.current = requestAnimationFrame(tick) - return () => cancelAnimationFrame(rafRef.current) - }, [scenarioData, totalTurns]) // eslint-disable-line react-hooks/exhaustive-deps + return () => { + cancelled = true + cancelAnimationFrame(rafRef.current) + } + }, [scenarioData?.bufferReady, totalTurns, requestFrame]) // eslint-disable-line react-hooks/exhaustive-deps // URL sync deferred to a useEffect so setSearchParams never fires inside the RAF // callback — calling it there races with React 19's scheduler and throws. + // Skip in noGui mode: the URL is already fully specified on page load (screenshot/automation), + // and calling setSearchParams inside the tight RAF→effect cycle causes a render loop. useEffect(() => { + if (noGui) return if (currentTurn % 10 !== 0) return if (currentTurn === lastSyncedTurnRef.current) return lastSyncedTurnRef.current = currentTurn syncUrl(activeScenarioId, currentTurn, pausedRef.current, {}) - }, [currentTurn, activeScenarioId, syncUrl]) + }, [noGui, currentTurn, activeScenarioId, syncUrl]) // ── derived values ───────────────────────────────────────────────────── const planetScenarios = useMemo( diff --git a/src/packages/guide/src/components/climate-sim/ScenarioTabs.tsx b/src/packages/guide/src/components/climate-sim/ScenarioTabs.tsx index 93dde6e9..68fd8594 100644 --- a/src/packages/guide/src/components/climate-sim/ScenarioTabs.tsx +++ b/src/packages/guide/src/components/climate-sim/ScenarioTabs.tsx @@ -15,7 +15,7 @@ interface ScenarioGroup { planet: string } -const SCENARIO_GROUPS: ScenarioGroup[] = [ +export const SCENARIO_GROUPS: ScenarioGroup[] = [ { id: 'lifeless', label: 'Lifeless Worlds',