feat(runtime): useAnimation interval is reactive (Ink parity) (#85)
Changing the interval option on a live useAnimation was a no-op (it was captured once at setup). interval now accepts a MaybeRefOrGetter<number> (strict superset of number); while active, a change resets frame/time/delta to 0 and re-subscribes at the new interval; while inactive the new value is recorded and applies on the next activation. Mirrors Ink's shouldReset gating (use-animation.ts), which recomputes safeInterval every render and resets only when active. Adds 3 tests (live change while active resets; while inactive doesn't; plain number still works). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -444,6 +444,98 @@ describe("useAnimation", () => {
|
|||||||
second.unmount();
|
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 () => <Text>{String(frame.value)}</Text>;
|
||||||
|
});
|
||||||
|
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 () => <Text>{String(frame.value)}</Text>;
|
||||||
|
});
|
||||||
|
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<number> 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 () => <Text>{String(frame.value)}</Text>;
|
||||||
|
});
|
||||||
|
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 () => {
|
test("time and delta reset to 0 when animation is resumed", async () => {
|
||||||
const active = shallowRef(true);
|
const active = shallowRef(true);
|
||||||
let frameVal = 0;
|
let frameVal = 0;
|
||||||
|
|||||||
@@ -17,9 +17,17 @@ import { AnimationSchedulerKey } from "../context.ts";
|
|||||||
export interface AnimationOptions {
|
export interface AnimationOptions {
|
||||||
/**
|
/**
|
||||||
* Time between ticks in milliseconds.
|
* 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
|
* @default 100
|
||||||
*/
|
*/
|
||||||
interval?: number;
|
interval?: MaybeRefOrGetter<number>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the animation is running. When set to `false`, the animation stops.
|
* 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 time = shallowRef(0);
|
||||||
const delta = 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
|
// Fall back to a local standalone scheduler when used outside a vue-tui
|
||||||
// render tree (graceful degradation, not a silent break).
|
// render tree (graceful degradation, not a silent break).
|
||||||
const scheduler: AnimationScheduler =
|
const scheduler: AnimationScheduler =
|
||||||
@@ -146,6 +157,22 @@ export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn
|
|||||||
{ immediate: true, flush: "sync" },
|
{ 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);
|
onScopeDispose(stop);
|
||||||
|
|
||||||
return { frame, time, delta, reset };
|
return { frame, time, delta, reset };
|
||||||
|
|||||||
Reference in New Issue
Block a user