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 };