fix(runtime): useAnimation interval preserves fractional values, matching Ink (#104)

normalizeInterval rounded the interval (Math.round), so a 60fps interval (16.67ms)
became 17ms and 8.4ms became 8ms — drifting frame=floor(elapsed/interval) and the
scheduler's nextDueTime over time. Ink's normalizeAnimationInterval
(use-animation.ts:147-151) does not round. Removed Math.round; the clamp
(>=1, <=MAX_TIMER_INTERVAL) is unchanged and the scheduler already ceil()s the
setTimeout delay so a fractional interval doesn't busy-loop.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 00:02:42 +08:00
committed by GitHub
parent b6dc9a0919
commit 6078cb7a80
2 changed files with 13 additions and 1 deletions
@@ -20,6 +20,15 @@ describe.sequential("normalizeInterval", () => {
expect(normalizeInterval(Number.POSITIVE_INFINITY)).toBe(100); expect(normalizeInterval(Number.POSITIVE_INFINITY)).toBe(100);
expect(normalizeInterval(Number.MAX_SAFE_INTEGER)).toBe(2_147_483_647); expect(normalizeInterval(Number.MAX_SAFE_INTEGER)).toBe(2_147_483_647);
}); });
test("preserves fractional intervals, matching Ink (no rounding)", () => {
// Ink's normalizeAnimationInterval does NOT round (use-animation.ts:147-151),
// so a 60fps interval (16.67ms) or 8.4ms stays fractional; rounding would drift
// frame=floor(elapsed/interval) and the scheduler's nextDueTime over time.
expect(normalizeInterval(16.67)).toBe(16.67);
expect(normalizeInterval(8.4)).toBe(8.4);
expect(normalizeInterval(0.5)).toBe(1); // still clamped to >= 1
});
}); });
describe.sequential("createAnimationScheduler", () => { describe.sequential("createAnimationScheduler", () => {
+4 -1
View File
@@ -3,7 +3,10 @@ const MAX_TIMER_INTERVAL = 2_147_483_647;
export function normalizeInterval(interval: number | undefined): number { export function normalizeInterval(interval: number | undefined): number {
if (interval === undefined || !Number.isFinite(interval)) return DEFAULT_INTERVAL; if (interval === undefined || !Number.isFinite(interval)) return DEFAULT_INTERVAL;
return Math.min(Math.max(1, Math.round(interval)), MAX_TIMER_INTERVAL); // No rounding — Ink's normalizeAnimationInterval (use-animation.ts:147-151) preserves
// fractional intervals (e.g. 16.67ms for 60fps); rounding would drift frame counts and
// the scheduler's nextDueTime over time. The scheduler already ceil()s the setTimeout delay.
return Math.min(Math.max(1, interval), MAX_TIMER_INTERVAL);
} }
type AnimationSubscriber = { type AnimationSubscriber = {