Stabilize chart detail refresh against live rows

This commit is contained in:
2569718930@qq.com
2026-06-15 15:21:31 +08:00
parent 15e0cb09b8
commit 690acf1154
2 changed files with 30 additions and 13 deletions
@@ -599,6 +599,9 @@ export function LiveTemperatureThresholdChart({
const [chartFreshness, setChartFreshness] = useState<ChartFreshnessState>(() => const [chartFreshness, setChartFreshness] = useState<ChartFreshnessState>(() =>
createChartFreshnessState(), createChartFreshnessState(),
); );
const latestRowRef = useRef<ScanOpportunityRow | null>(row);
latestRowRef.current = row;
const getLatestRowSnapshot = useCallback(() => latestRowRef.current, []);
const hasLoadedHourlyDetailRef = useRef(false); const hasLoadedHourlyDetailRef = useRef(false);
const hourlyCityRef = useRef<string>(""); const hourlyCityRef = useRef<string>("");
const chartVisibilityRef = useRef<HTMLDivElement | null>(null); const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
@@ -732,14 +735,15 @@ export function LiveTemperatureThresholdChart({
const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => {
if (!data) return; if (!data) return;
const loadedAtMs = Date.now(); const loadedAtMs = Date.now();
const rowSeed = seedHourlyForecastFromRow(row); const latestRow = getLatestRowSnapshot();
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, row); const rowSeed = seedHourlyForecastFromRow(latestRow);
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, latestRow);
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
if (options?.updateLiveTemp) { if (options?.updateLiveTemp) {
const temp = getLiveTempFromHourly(dataWithCurrentRow); const temp = getLiveTempFromHourly(dataWithCurrentRow);
if (temp !== null) setLiveTemp(temp); if (temp !== null) setLiveTemp(temp);
} }
setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)); setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow));
setDetailError(null); setDetailError(null);
setShowingStaleDetail(false); setShowingStaleDetail(false);
setChartFreshness((prev) => ({ setChartFreshness((prev) => ({
@@ -752,7 +756,7 @@ export function LiveTemperatureThresholdChart({
? "network" ? "network"
: prev.detailSource, : prev.detailSource,
})); }));
}, [row]); }, [getLatestRowSnapshot]);
useEffect(() => { useEffect(() => {
if (!city || !currentRowObservationSignature) return; if (!city || !currentRowObservationSignature) return;
@@ -826,7 +830,7 @@ export function LiveTemperatureThresholdChart({
} }
if (!cached && !hasLoadedHourlyDetailRef.current) { if (!cached && !hasLoadedHourlyDetailRef.current) {
setHourly(seedHourlyForecastFromRow(row)); setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()));
setShowingStaleDetail(false); setShowingStaleDetail(false);
} }
setIsHourlyLoading(true); setIsHourlyLoading(true);
@@ -888,7 +892,6 @@ export function LiveTemperatureThresholdChart({
}; };
}, [ }, [
city, city,
row,
targetResolution, targetResolution,
isChartVisible, isChartVisible,
compact, compact,
@@ -897,6 +900,7 @@ export function LiveTemperatureThresholdChart({
slotIndex, slotIndex,
detailLoadReady, detailLoadReady,
detailRetryNonce, detailRetryNonce,
getLatestRowSnapshot,
markDetailDegraded, markDetailDegraded,
markDetailRequest, markDetailRequest,
applySuccessfulHourlyDetail, applySuccessfulHourlyDetail,
@@ -909,7 +913,7 @@ export function LiveTemperatureThresholdChart({
lastPatchAtRef.current = patchAppliedAtMs; lastPatchAtRef.current = patchAppliedAtMs;
const tempValue = validNumber(latestPatch.changes.temp); const tempValue = validNumber(latestPatch.changes.temp);
if (tempValue !== null) setLiveTemp(tempValue); if (tempValue !== null) setLiveTemp(tempValue);
setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch)); setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch));
setChartFreshness((prev) => ({ setChartFreshness((prev) => ({
...prev, ...prev,
ssePatchAppliedAtMs: patchAppliedAtMs, ssePatchAppliedAtMs: patchAppliedAtMs,
@@ -954,7 +958,7 @@ export function LiveTemperatureThresholdChart({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [latestPatch, row, city, targetResolution, compact, isActive, isMaximized, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]); }, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]);
useEffect(() => { useEffect(() => {
if (!resyncVersion || !city) return; if (!resyncVersion || !city) return;
@@ -125,7 +125,7 @@ export async function runTests() {
); );
assert( assert(
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") && 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", "visible chart fallback must refresh full city detail at the current chart resolution while preserving newer live observations",
); );
assert( assert(
@@ -244,7 +244,7 @@ export async function runTests() {
assert( assert(
chartLogicSource.includes("_hourlyRequestCache") && chartLogicSource.includes("_hourlyRequestCache") &&
chartLogicSource.includes("seedHourlyForecastFromRow") && 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", "terminal charts should render from row data immediately and dedupe concurrent city detail requests",
); );
assert( assert(
@@ -328,13 +328,26 @@ export async function runTests() {
chartSource.includes("!retryScheduled"), chartSource.includes("!retryScheduled"),
"cold partial detail-batch misses should stay in loading state and retry cached detail once before showing unavailable", "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 successfulHourlyDetailBlock =
/const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[row\]\);/.exec(chartSource)?.[0] || ""; /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || "";
assert( assert(
successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setDetailError(null)") &&
successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") &&
successfulHourlyDetailBlock.includes("mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)"), successfulHourlyDetailBlock.includes("getLatestRowSnapshot") &&
"successful city detail refreshes must clear stale-cache retry state when fresh detail arrives", !/\[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 const rawSuccessfulSetHourlyCalls = chartSource
.replace(successfulHourlyDetailBlock, "") .replace(successfulHourlyDetailBlock, "")