fix(ui): 切换日期后数字不更新,卡片显示的是前一天的数据

useCountUp 只靠 requestAnimationFrame 推进,没有兜底。帧不来的时候
(后台标签页、被节流的 webview)setDisplay 一次都不会调用,组件继续
渲染上一个值——往前翻一天,标题的日期变了,但每一个数字还停在前一天。
不是动画没播,是把另一天的数据当成这一天理直气壮地显示出来。

实测(造的数据):08-24 → 08-23 → 08-22 三天,三张卡片全都显示 8,826,
而真实值是 8,826 / 10,232 / 10,715。

- 动画结束时间点加一个 setTimeout 兜底,保证最终值一定落地
- document.hidden 时直接跳过动画
- from 引用在收敛时同步更新,避免动画被打断后从错误的起点继续

动画是装饰,数字不是。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-24 09:33:22 +08:00
parent 564aaef6c1
commit d2d6143ed6
2 changed files with 21 additions and 13 deletions

View File

@@ -1,11 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "client",
"runtimeExecutable": "npm",
"runtimeArgs": ["start", "--workspace=client"],
"port": 3000
}
]
}

View File

@@ -36,11 +36,19 @@ export function useCountUp(value: number | null, durationMs = 650): number | nul
const reduced = usePrefersReducedMotion(); const reduced = usePrefersReducedMotion();
const [display, setDisplay] = useState<number | null>(value); const [display, setDisplay] = useState<number | null>(value);
const frame = useRef<number>(); const frame = useRef<number>();
const settleTimer = useRef<number>();
const from = useRef(0); const from = useRef(0);
useEffect(() => { useEffect(() => {
if (value == null || reduced) { const settle = () => {
setDisplay(value); setDisplay(value);
from.current = value ?? 0;
};
// Nothing to animate towards, or the viewer asked for no motion. Also
// covers a hidden tab, where requestAnimationFrame does not run at all.
if (value == null || reduced || document.hidden) {
settle();
return; return;
} }
@@ -52,12 +60,23 @@ export function useCountUp(value: number | null, durationMs = 650): number | nul
const t = Math.min(1, (now - start) / durationMs); const t = Math.min(1, (now - start) / durationMs);
setDisplay(origin + delta * easeOut(t)); setDisplay(origin + delta * easeOut(t));
if (t < 1) frame.current = requestAnimationFrame(tick); if (t < 1) frame.current = requestAnimationFrame(tick);
else from.current = value; else settle();
}; };
frame.current = requestAnimationFrame(tick); frame.current = requestAnimationFrame(tick);
/* The animation is decoration; the number is not.
This used to be rAF alone, so when frames never arrived — a
backgrounded tab, a throttled webview — `setDisplay` was never called
and the card kept rendering the *previous* value. Stepping back a day
changed the date in the header while every figure stayed on the day
before: not a missing animation, but the wrong data shown confidently.
The timer guarantees the final value lands either way. */
settleTimer.current = window.setTimeout(settle, durationMs + 250);
return () => { return () => {
if (frame.current) cancelAnimationFrame(frame.current); if (frame.current) cancelAnimationFrame(frame.current);
window.clearTimeout(settleTimer.current);
}; };
}, [value, durationMs, reduced]); }, [value, durationMs, reduced]);