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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user