diff --git a/packages/runtime-tests/integration/composables/use-animation.test.tsx b/packages/runtime-tests/integration/composables/use-animation.test.tsx index 942df8a..ee826e8 100644 --- a/packages/runtime-tests/integration/composables/use-animation.test.tsx +++ b/packages/runtime-tests/integration/composables/use-animation.test.tsx @@ -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>; 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>; + let time!: Readonly>; + let delta!: Readonly>; + let reset!: () => void; + const App = defineComponent(() => { + const anim = useAnimation({ interval: 30 }); + frame = anim.frame; + time = anim.time; + delta = anim.delta; + reset = anim.reset; + return () => {String(frame.value)}; + }); + 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(); }); diff --git a/packages/runtime/src/composables/useAnimation.ts b/packages/runtime/src/composables/useAnimation.ts index 96edd83..9d09690 100644 --- a/packages/runtime/src/composables/useAnimation.ts +++ b/packages/runtime/src/composables/useAnimation.ts @@ -57,8 +57,13 @@ export interface UseAnimationReturn { readonly delta: Readonly>; /** - * 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);