fix(runtime): useAnimation reset() while paused keeps the last frame, zeros on resume (Ink parity) (#92)

reset() zeroed frame/time/delta unconditionally, so calling it while paused
(isActive=false) flipped the frozen frame to 0 immediately. Ink keeps the last frame
until resume: its reset only bumps a key consumed by the isActive-gated effect, which
early-returns while inactive (use-animation.ts:83-89). Now reset() zeros + restarts only
when active (via start()); while paused it's a no-op and the next resume's start() zeros
-- matching Ink. Active reset is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 16:42:51 +08:00
committed by GitHub
parent f2397bfa00
commit 4c18ec0dd0
2 changed files with 67 additions and 8 deletions
@@ -680,7 +680,13 @@ describe("useAnimation", () => {
unmount();
});
test("reset() while paused zeroes refs and stays at 0", async () => {
// Ink parity (use-animation.ts:83-89,138): reset() while PAUSED only bumps
// resetKey; the zeroing (setAnimState(zeroAnimState)) lives inside the
// layout effect, which early-returns while !isActive. shouldReset is gated
// on isActive too. So a paused reset() keeps the last frame frozen and zeros
// only on the NEXT resume. (Previously this test locked the divergent
// immediate-zero-while-paused behavior; flipped to match Ink.)
test("reset() while paused keeps the last frame; zeros on resume", async () => {
const active = shallowRef(true);
let frame!: Readonly<ShallowRef<number>>;
let reset!: () => void;
@@ -694,11 +700,52 @@ describe("useAnimation", () => {
await delay(120);
active.value = false;
await nextTick();
const frozen = frame.value;
expect(frozen).toBeGreaterThanOrEqual(1);
// reset() while paused must NOT zero — the last frame stays frozen.
reset();
await nextTick();
expect(frame.value).toBe(frozen);
await delay(120);
expect(frame.value).toBe(frozen);
// Resuming zeros (the deferred reset lands here) then advances.
active.value = true;
await nextTick();
expect(frame.value).toBe(0);
await delay(120);
expect(frame.value).toBeGreaterThanOrEqual(1);
unmount();
});
// Guard: reset() while ACTIVE must still zero frame/time/delta immediately
// (Ink: the layout effect re-runs on resetKey while isActive and calls
// setAnimState(zeroAnimState)). The paused-reset fix must not regress this.
test("reset() while active zeros immediately then keeps advancing", async () => {
let frame!: Readonly<ShallowRef<number>>;
let time!: Readonly<ShallowRef<number>>;
let delta!: Readonly<ShallowRef<number>>;
let reset!: () => void;
const App = defineComponent(() => {
const anim = useAnimation({ interval: 30 });
frame = anim.frame;
time = anim.time;
delta = anim.delta;
reset = anim.reset;
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(150);
expect(frame.value).toBeGreaterThanOrEqual(1);
reset();
expect(frame.value).toBe(0);
expect(time.value).toBe(0);
expect(delta.value).toBe(0);
await delay(150);
expect(frame.value).toBeGreaterThanOrEqual(1);
unmount();
});
@@ -57,8 +57,13 @@ export interface UseAnimationReturn {
readonly delta: Readonly<ShallowRef<number>>;
/**
* Resets `frame`, `time`, and `delta` to `0` and restarts timing from the current moment.
* Useful for one-shot animations triggered by events.
* Resets `frame`, `time`, and `delta` to `0` and restarts timing from the
* current moment. Useful for one-shot animations triggered by events.
*
* While the animation is INACTIVE (paused via `isActive`), `reset()` keeps the
* last frame frozen instead of zeroing immediately; the zeroing is deferred to
* the next resume (Ink parity — Ink's reset bumps a key consumed only by the
* isActive-gated effect, so a paused reset zeros on resume, not before).
*/
readonly reset: () => void;
}
@@ -138,11 +143,18 @@ export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn
}
function reset() {
const wasActive = handle !== undefined;
frame.value = 0;
time.value = 0;
delta.value = 0;
if (wasActive) start();
// Ink parity (use-animation.ts:83-89,138): reset() only bumps a resetKey;
// the actual zeroing (setAnimState(zeroAnimState)) lives INSIDE the layout
// effect, which early-returns while !isActive, and `shouldReset` is gated on
// isActive. So:
// - ACTIVE: zero + restart timing now. start() already does both (the
// effect re-runs on resetKey while isActive in Ink).
// - INACTIVE (paused): do NOT zero — keep the last frame frozen. The next
// resume runs the isActive watch → start(), which zeros, so the reset
// lands on resume (Ink defers zeroing the same way).
if (handle !== undefined) {
start();
}
}
// Watch isActive — when toggled to true, start (which resets values);