From d2d6143ed62b4475d2aca46d76f66c4df975af5a Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Mon, 24 Aug 2026 09:33:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E5=88=87=E6=8D=A2=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E5=90=8E=E6=95=B0=E5=AD=97=E4=B8=8D=E6=9B=B4=E6=96=B0=EF=BC=8C?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=98=BE=E7=A4=BA=E7=9A=84=E6=98=AF=E5=89=8D?= =?UTF-8?q?=E4=B8=80=E5=A4=A9=E7=9A=84=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/launch.json | 11 ----------- client/src/lib/motion.ts | 23 +++++++++++++++++++++-- 2 files changed, 21 insertions(+), 13 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 602e0ba..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "client", - "runtimeExecutable": "npm", - "runtimeArgs": ["start", "--workspace=client"], - "port": 3000 - } - ] -} diff --git a/client/src/lib/motion.ts b/client/src/lib/motion.ts index f99b7f0..b19e1ea 100644 --- a/client/src/lib/motion.ts +++ b/client/src/lib/motion.ts @@ -36,11 +36,19 @@ export function useCountUp(value: number | null, durationMs = 650): number | nul const reduced = usePrefersReducedMotion(); const [display, setDisplay] = useState(value); const frame = useRef(); + const settleTimer = useRef(); const from = useRef(0); useEffect(() => { - if (value == null || reduced) { + const settle = () => { 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; } @@ -52,12 +60,23 @@ export function useCountUp(value: number | null, durationMs = 650): number | nul const t = Math.min(1, (now - start) / durationMs); setDisplay(origin + delta * easeOut(t)); if (t < 1) frame.current = requestAnimationFrame(tick); - else from.current = value; + else settle(); }; 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 () => { if (frame.current) cancelAnimationFrame(frame.current); + window.clearTimeout(settleTimer.current); }; }, [value, durationMs, reduced]);