diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 3a841757..a2698d1e 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -599,6 +599,9 @@ export function LiveTemperatureThresholdChart({ const [chartFreshness, setChartFreshness] = useState(() => createChartFreshnessState(), ); + const latestRowRef = useRef(row); + latestRowRef.current = row; + const getLatestRowSnapshot = useCallback(() => latestRowRef.current, []); const hasLoadedHourlyDetailRef = useRef(false); const hourlyCityRef = useRef(""); const chartVisibilityRef = useRef(null); @@ -732,14 +735,15 @@ export function LiveTemperatureThresholdChart({ const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { if (!data) return; const loadedAtMs = Date.now(); - const rowSeed = seedHourlyForecastFromRow(row); - const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, row); + const latestRow = getLatestRowSnapshot(); + const rowSeed = seedHourlyForecastFromRow(latestRow); + const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, latestRow); hasLoadedHourlyDetailRef.current = true; if (options?.updateLiveTemp) { const temp = getLiveTempFromHourly(dataWithCurrentRow); if (temp !== null) setLiveTemp(temp); } - setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)); + setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow)); setDetailError(null); setShowingStaleDetail(false); setChartFreshness((prev) => ({ @@ -752,7 +756,7 @@ export function LiveTemperatureThresholdChart({ ? "network" : prev.detailSource, })); - }, [row]); + }, [getLatestRowSnapshot]); useEffect(() => { if (!city || !currentRowObservationSignature) return; @@ -826,7 +830,7 @@ export function LiveTemperatureThresholdChart({ } if (!cached && !hasLoadedHourlyDetailRef.current) { - setHourly(seedHourlyForecastFromRow(row)); + setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot())); setShowingStaleDetail(false); } setIsHourlyLoading(true); @@ -888,7 +892,6 @@ export function LiveTemperatureThresholdChart({ }; }, [ city, - row, targetResolution, isChartVisible, compact, @@ -897,6 +900,7 @@ export function LiveTemperatureThresholdChart({ slotIndex, detailLoadReady, detailRetryNonce, + getLatestRowSnapshot, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail, @@ -909,7 +913,7 @@ export function LiveTemperatureThresholdChart({ lastPatchAtRef.current = patchAppliedAtMs; const tempValue = validNumber(latestPatch.changes.temp); if (tempValue !== null) setLiveTemp(tempValue); - setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch)); + setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch)); setChartFreshness((prev) => ({ ...prev, ssePatchAppliedAtMs: patchAppliedAtMs, @@ -954,7 +958,7 @@ export function LiveTemperatureThresholdChart({ return () => { cancelled = true; }; - }, [latestPatch, row, city, targetResolution, compact, isActive, isMaximized, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]); + }, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]); useEffect(() => { if (!resyncVersion || !city) return; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 762f911f..462bf9a8 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -125,7 +125,7 @@ export async function runTests() { ); assert( chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") && - chartSource.includes("setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row))"), + chartSource.includes("setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow))"), "visible chart fallback must refresh full city detail at the current chart resolution while preserving newer live observations", ); assert( @@ -244,7 +244,7 @@ export async function runTests() { assert( chartLogicSource.includes("_hourlyRequestCache") && chartLogicSource.includes("seedHourlyForecastFromRow") && - chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"), + chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))"), "terminal charts should render from row data immediately and dedupe concurrent city detail requests", ); assert( @@ -328,13 +328,26 @@ export async function runTests() { chartSource.includes("!retryScheduled"), "cold partial detail-batch misses should stay in loading state and retry cached detail once before showing unavailable", ); + assert( + chartSource.includes("latestRowRef") && + chartSource.includes("latestRowRef.current = row"), + "temperature chart should keep the latest row in a ref so live row changes do not restart detail fetches", + ); const successfulHourlyDetailBlock = - /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[row\]\);/.exec(chartSource)?.[0] || ""; + /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || ""; assert( successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && - successfulHourlyDetailBlock.includes("mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)"), - "successful city detail refreshes must clear stale-cache retry state when fresh detail arrives", + successfulHourlyDetailBlock.includes("getLatestRowSnapshot") && + !/\[row\]/.test(successfulHourlyDetailBlock), + "successful city detail refreshes must read the latest row without depending on the row object", + ); + const coldDetailFetchBlock = + /useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?fetchHourlyForecastForCity\(city, \{ resolution: targetResolution \}\)[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || ""; + assert( + coldDetailFetchBlock.length > 0 && + !/^\s*row\s*,?\s*$/m.test(coldDetailFetchBlock), + "cold detail fetch effect must not restart just because the live scan row object changed", ); const rawSuccessfulSetHourlyCalls = chartSource .replace(successfulHourlyDetailBlock, "")