diff --git a/packages/runtime-tests/integration/composables/use-animation.test.tsx b/packages/runtime-tests/integration/composables/use-animation.test.tsx index 35ea1c0..942df8a 100644 --- a/packages/runtime-tests/integration/composables/use-animation.test.tsx +++ b/packages/runtime-tests/integration/composables/use-animation.test.tsx @@ -444,6 +444,98 @@ describe("useAnimation", () => { second.unmount(); }); + // Ink parity (use-animation.tsx:529-566 "time and delta reset to 0 when + // interval changes"): changing `interval` reactively on a LIVE, mounted, + // ACTIVE animation (no remount) must reset frame/time/delta to 0 and + // re-subscribe at the new interval — Ink recomputes safeInterval every render + // and resets when it differs while active. This is the bug repro: a captured + // (non-reactive) interval makes this a no-op. + test("frame/time/delta reset to 0 when interval changes live while active", async () => { + const interval = shallowRef(50); + let frameVal = 0; + let timeVal = 0; + let deltaVal = 0; + const App = defineComponent(() => { + const { frame, time, delta } = useAnimation({ interval }); + watchEffect(() => { + frameVal = frame.value; + timeVal = time.value; + deltaVal = delta.value; + }); + return () => {String(frame.value)}; + }); + const { unmount } = await render(App); + + await delay(200); + expect(frameVal).toBeGreaterThanOrEqual(1); + expect(timeVal).toBeGreaterThanOrEqual(50); + + // Mirror Ink's '0,0,0' assertion: changing the live interval resets all three. + interval.value = 200; + await nextTick(); + expect(frameVal).toBe(0); + expect(timeVal).toBe(0); + expect(deltaVal).toBe(0); + + // And it keeps running at the new interval afterwards. + await delay(250); + expect(frameVal).toBeGreaterThanOrEqual(1); + + unmount(); + }); + + // Ink parity: `shouldReset` is gated on `isActive`. Changing `interval` while + // INACTIVE must NOT reset values (the animation is paused — nothing to reset) + // and must NOT start a timer. This matches vue-tui's existing reset-while-paused + // behavior. The new interval only takes effect on the next activation. + test("changing interval while inactive does not reset and does not start", async () => { + const interval = shallowRef(50); + const active = shallowRef(false); + let frameVal = -1; + const App = defineComponent(() => { + const { frame } = useAnimation({ interval, isActive: active }); + watchEffect(() => { + frameVal = frame.value; + }); + return () => {String(frame.value)}; + }); + const { unmount } = await render(App); + + // Inactive from mount: frame stays 0. + await delay(120); + expect(frameVal).toBe(0); + + // Change interval while still inactive — must remain frozen at 0, no timer. + interval.value = 200; + await nextTick(); + await delay(120); + expect(frameVal).toBe(0); + + // Activating now uses the new interval and advances. + active.value = true; + await delay(250); + expect(frameVal).toBeGreaterThanOrEqual(1); + + unmount(); + }); + + test("interval accepts a plain number (backward compatible)", async () => { + // The widening to MaybeRefOrGetter must remain a strict superset: + // a literal number still works exactly as before. + let frameVal = 0; + const App = defineComponent(() => { + const { frame } = useAnimation({ interval: 50 }); + watchEffect(() => { + frameVal = frame.value; + }); + return () => {String(frame.value)}; + }); + const { unmount } = await render(App); + await delay(150); + expect(frameVal).toBeGreaterThanOrEqual(1); + unmount(); + }); + test("time and delta reset to 0 when animation is resumed", async () => { const active = shallowRef(true); let frameVal = 0; diff --git a/packages/runtime/src/composables/useAnimation.ts b/packages/runtime/src/composables/useAnimation.ts index d5b3e02..96edd83 100644 --- a/packages/runtime/src/composables/useAnimation.ts +++ b/packages/runtime/src/composables/useAnimation.ts @@ -17,9 +17,17 @@ import { AnimationSchedulerKey } from "../context.ts"; export interface AnimationOptions { /** * Time between ticks in milliseconds. + * + * Reactive: pass a ref/getter to change the interval on a live animation. + * While ACTIVE, changing it resets `frame`/`time`/`delta` to `0` and + * re-subscribes at the new interval (Ink parity — `shouldReset` recomputes + * `safeInterval` every render and resets when it differs while active). + * While INACTIVE, the new value is recorded but nothing resets and no timer + * starts; it takes effect on the next activation. A plain `number` keeps the + * previous fixed behavior (the type is a strict superset). * @default 100 */ - interval?: number; + interval?: MaybeRefOrGetter; /** * Whether the animation is running. When set to `false`, the animation stops. @@ -77,7 +85,10 @@ export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn const time = shallowRef(0); const delta = shallowRef(0); - const interval = normalizeInterval(options.interval); + // Recomputed whenever the (reactive) interval changes — Ink re-reads + // safeInterval every render. `tick`/`start` read this current value so a + // re-subscribe after an interval change uses the new value. + let interval = normalizeInterval(toValue(options.interval)); // Fall back to a local standalone scheduler when used outside a vue-tui // render tree (graceful degradation, not a silent break). const scheduler: AnimationScheduler = @@ -146,6 +157,22 @@ export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn { immediate: true, flush: "sync" }, ); + // Watch the (reactive) interval. Ink's `shouldReset` is gated on isActive: + // an interval change resets + re-subscribes only when active. While inactive + // we just record the new normalized value (used by the next start()) and do + // NOT reset — there is nothing running to reset, matching reset-while-paused. + // Not `immediate`: the initial value is already applied above; immediate would + // double-subscribe with the isActive watch on mount. + watch( + () => normalizeInterval(toValue(options.interval)), + (next) => { + const wasActive = handle !== undefined; + interval = next; + if (wasActive) start(); + }, + { flush: "sync" }, + ); + onScopeDispose(stop); return { frame, time, delta, reset };