timeout-flow
Version:
Fluent, composable, pauseable JavaScript timers and time control flows — plus RAF utilities for frame-based logic.
317 lines (258 loc) • 8.33 kB
JavaScript
// ./raf/everyRaf.js
import { resolveDelayFnOptions } from '../resolveDelayAndFn.js';
import { attachAbort } from '../abort.js';
import { now } from '../now.js';
const isPlainOptions = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
/**
* Repeats a function every N milliseconds using requestAnimationFrame.
*
* Preferred:
* - everyRaf(fn, duration, [options])
*
* Also supported (legacy):
* - everyRaf(duration, fn, [maxTimes], [runImmediately], [options])
*
* Timing model:
* - Counts down using frame deltas.
* - Paused time does NOT count (pause freezes remaining-to-next-tick).
* - On large frame gaps, may "catch up" by running multiple ticks in one frame,
* but will never exceed max.
*
* @param {Function|string|number} a
* @param {Function|string|number|Object} b
* @param {number|boolean|Object} [c] - legacy maxTimes OR legacy runImmediately OR options
* @param {boolean|Object} [d] - legacy runImmediately OR options
* @param {Object} [e] - legacy options
* @returns {{
* pause(): void,
* resume(): void,
* cancel(): void,
* reset(restart?: boolean): void,
* readonly isRunning: boolean,
* readonly isPaused: boolean,
* readonly isFinished: boolean,
* readonly count: number
* }}
*/
export function everyRaf(a, b, c, d, e) {
const durationFirst = typeof a !== 'function';
// ---- Normalize legacy positional args into options (without breaking legacy behavior) ----
let legacyMax;
let legacyRunImmediately;
/** @type {any} */
let optionsCandidate;
if (durationFirst) {
// Legacy or duration-first:
// - everyRaf(duration, fn, options)
// - everyRaf(duration, fn, maxTimes, runImmediately, options)
// - everyRaf(duration, fn, maxTimes, options)
// - everyRaf(duration, fn, runImmediately, options) (extra leniency)
if (isPlainOptions(c)) {
optionsCandidate = c;
} else if (isPlainOptions(d)) {
optionsCandidate = d;
} else if (isPlainOptions(e)) {
optionsCandidate = e;
} else {
optionsCandidate = {};
}
if (typeof c === 'number') legacyMax = c;
if (typeof d === 'boolean') legacyRunImmediately = d;
// Lenient support: (duration, fn, true, options) => runImmediately=true
if (legacyRunImmediately == null && typeof c === 'boolean') legacyRunImmediately = c;
} else {
// Preferred fn-first:
// - everyRaf(fn, duration, options?)
optionsCandidate = isPlainOptions(c) ? c : {};
}
// Parse fn + duration using shared helper (duration is REQUIRED)
const { fn, delay, options } = resolveDelayFnOptions(a, b, optionsCandidate, undefined);
const signal = options?.signal;
const maxFromOptions = options?.max ?? Infinity;
const runImmediatelyFromOptions = options?.runImmediately ?? false;
// Legacy positional should win (to preserve prior behavior)
const max = legacyMax != null ? legacyMax : maxFromOptions;
const runImmediately =
legacyRunImmediately != null ? legacyRunImmediately : runImmediatelyFromOptions;
const intervalMs = Math.max(0, delay);
let rafId = 0;
let count = 0;
// remaining time until next tick (pause-safe)
let remainingToNext = intervalMs;
// last frame timestamp (used for delta while running)
let lastTs = null; // number | null
let running = false;
let paused = false;
let finished = false;
const cancelFrame = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
};
let cleanupAbort = () => { };
const cleanupAbortListener = () => {
cleanupAbort();
cleanupAbort = () => { };
};
const finish = () => {
cancelFrame();
running = false;
paused = false;
finished = true;
lastTs = null;
remainingToNext = 0;
cleanupAbortListener();
};
const cancel = () => {
if (finished) return;
finish();
};
const attachAbortListener = () => {
cleanupAbortListener();
// Preserve semantics: if already aborted, become terminal (and do not attach).
if (signal?.aborted) {
finished = true;
remainingToNext = 0;
return;
}
cleanupAbort = attachAbort(signal, cancel);
};
attachAbortListener();
const canRunMore = () => count < max;
const fire = () => {
fn?.(count++);
if (!canRunMore()) {
finish();
return false;
}
return true;
};
const tick = (ts) => {
if (!running || finished) return;
if (signal?.aborted) {
finish();
return;
}
if (!canRunMore()) {
finish();
return;
}
if (lastTs == null) lastTs = ts;
const delta = ts - lastTs;
lastTs = ts;
// Decrement remaining time based on frame delta
remainingToNext -= delta;
// Catch-up loop: if we missed multiple intervals, fire multiple times.
// Protect against infinite loops: intervalMs could be 0.
const step = Math.max(0, intervalMs);
if (step === 0) {
// If interval is 0ms, treat as "once per frame"
remainingToNext = 0;
fire();
rafId = requestAnimationFrame(tick);
return;
}
while (remainingToNext <= 0 && !finished) {
const ok = fire();
if (!ok) return;
// carry over overshoot
remainingToNext += step;
}
rafId = requestAnimationFrame(tick);
};
const startLoop = () => {
if (finished) return;
if (signal?.aborted) {
finish();
return;
}
if (!canRunMore()) {
finish();
return;
}
running = true;
paused = false;
lastTs = null;
rafId = requestAnimationFrame(tick);
};
const pause = () => {
if (!running || finished) return;
cancelFrame();
// Freeze remainingToNext based on time since lastTs
const currentTime = now(); // ✅ fixed: no shadowing / TDZ
if (lastTs != null) {
const delta = currentTime - lastTs;
remainingToNext = Math.max(0, remainingToNext - delta);
}
lastTs = null;
running = false;
paused = true;
};
const resume = () => {
if (finished || running || !paused) return;
if (signal?.aborted) return;
if (!canRunMore()) {
finish();
return;
}
startLoop();
};
const reset = (restart = false) => {
cancelFrame();
count = 0;
remainingToNext = intervalMs;
lastTs = null;
running = false;
paused = false;
finished = false;
// Re-attach abort on reset (consistent with non-RAF timers)
attachAbortListener();
if (finished) return;
// If max is already satisfied by definition (<=0 but not Infinity), finish.
if (!(max > 0) && max !== Infinity) {
finish();
return;
}
if (restart) {
// Optionally honor runImmediately on restart (matching constructor semantics)
if (runImmediately && canRunMore()) {
fire();
if (finished) return;
}
startLoop();
}
};
// Initial launch
if (!finished) {
if (!(max > 0) && max !== Infinity) {
finish();
} else {
if (runImmediately && canRunMore()) {
fire();
}
if (!finished) {
remainingToNext = Math.max(0, intervalMs);
startLoop();
}
}
} else {
cleanupAbortListener();
}
return {
pause,
resume,
cancel,
reset,
get isRunning() {
return running;
},
get isPaused() {
return paused;
},
get isFinished() {
return finished;
},
get count() {
return count;
},
};
}