Skip to content

Commit fa622fe

Browse files
committed
perf(timer): schedule by nearest due time
1 parent f85a3a2 commit fa622fe

6 files changed

Lines changed: 153 additions & 13 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ Current build:
139139

140140
| File | Raw | Gzip | Brotli |
141141
| --- | ---: | ---: | ---: |
142-
| `dist/index.js` | 11.82 kB | 3.55 kB | 3.20 kB |
143-
| `dist/index.cjs` | 12.94 kB | 3.79 kB | 3.42 kB |
142+
| `dist/index.js` | 12.45 kB | 3.75 kB | 3.36 kB |
143+
| `dist/index.cjs` | 13.69 kB | 4.01 kB | 3.60 kB |
144144
| `dist/index.d.ts` | 3.95 kB | 992 B | 888 B |
145145

146146
CI writes a size summary to the GitHub Actions UI and posts bundle-size reports on pull requests.

src/__tests__/useTimer.schedules.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,40 @@ describe('useTimer schedules', () => {
9696
await act(async () => {});
9797
expect(callback).toHaveBeenCalledTimes(3);
9898
});
99+
100+
it('uses the latest schedule callback without restarting the timer', async () => {
101+
const firstCallback = vi.fn();
102+
const secondCallback = vi.fn();
103+
const { rerender } = renderHook(({ callback }) =>
104+
useTimer({
105+
autoStart: true,
106+
updateIntervalMs: 100,
107+
schedules: [{ id: 'poll', everyMs: 100, callback }],
108+
}),
109+
{ initialProps: { callback: firstCallback } },
110+
);
111+
112+
rerender({ callback: secondCallback });
113+
act(() => vi.advanceTimersByTime(100));
114+
await act(async () => {});
115+
116+
expect(firstCallback).not.toHaveBeenCalled();
117+
expect(secondCallback).toHaveBeenCalledTimes(1);
118+
});
119+
120+
it('checks schedule cadence independently from render update interval', async () => {
121+
const callback = vi.fn();
122+
renderHook(() =>
123+
useTimer({
124+
autoStart: true,
125+
updateIntervalMs: 1000,
126+
schedules: [{ id: 'fast-poll', everyMs: 100, callback }],
127+
}),
128+
);
129+
130+
act(() => vi.advanceTimersByTime(100));
131+
await act(async () => {});
132+
133+
expect(callback).toHaveBeenCalledTimes(1);
134+
});
99135
});

src/__tests__/useTimer.strict-mode.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,38 @@ describe('useTimer rerender and Strict Mode behavior', () => {
2424
expect(result.current.tick).toBe(1);
2525
});
2626

27+
it('keeps control method identities stable across rerenders and ticks', () => {
28+
const { result, rerender } = renderHook(() => useTimer({ autoStart: true, updateIntervalMs: 100 }));
29+
const controls = {
30+
start: result.current.start,
31+
pause: result.current.pause,
32+
resume: result.current.resume,
33+
reset: result.current.reset,
34+
restart: result.current.restart,
35+
cancel: result.current.cancel,
36+
};
37+
38+
rerender();
39+
act(() => vi.advanceTimersByTime(100));
40+
41+
expect(result.current.start).toBe(controls.start);
42+
expect(result.current.pause).toBe(controls.pause);
43+
expect(result.current.resume).toBe(controls.resume);
44+
expect(result.current.reset).toBe(controls.reset);
45+
expect(result.current.restart).toBe(controls.restart);
46+
expect(result.current.cancel).toBe(controls.cancel);
47+
});
48+
49+
it('does not delay the active timeout on parent rerender', () => {
50+
const { result, rerender } = renderHook(() => useTimer({ autoStart: true, updateIntervalMs: 100 }));
51+
52+
act(() => vi.advanceTimersByTime(50));
53+
rerender();
54+
act(() => vi.advanceTimersByTime(50));
55+
56+
expect(result.current.tick).toBe(1);
57+
});
58+
2759
it('Strict Mode does not duplicate onEnd', () => {
2860
const onEnd = vi.fn();
2961
renderHook(

src/__tests__/useTimerGroup.schedules.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,33 @@ describe('useTimerGroup schedules and debug', () => {
4444
expect(result.current.get('0')?.tick).toBe(1);
4545
expect(result.current.get('99')?.tick).toBe(1);
4646
});
47+
48+
it('keeps group control method identities stable across rerenders and ticks', () => {
49+
const items = [{ id: 'a', autoStart: true }];
50+
const { result, rerender } = renderHook(() => useTimerGroup({ updateIntervalMs: 100, items }));
51+
const controls = {
52+
start: result.current.start,
53+
pause: result.current.pause,
54+
resume: result.current.resume,
55+
reset: result.current.reset,
56+
restart: result.current.restart,
57+
cancel: result.current.cancel,
58+
pauseAll: result.current.pauseAll,
59+
resumeAll: result.current.resumeAll,
60+
restartAll: result.current.restartAll,
61+
};
62+
63+
rerender();
64+
act(() => vi.advanceTimersByTime(100));
65+
66+
expect(result.current.start).toBe(controls.start);
67+
expect(result.current.pause).toBe(controls.pause);
68+
expect(result.current.resume).toBe(controls.resume);
69+
expect(result.current.reset).toBe(controls.reset);
70+
expect(result.current.restart).toBe(controls.restart);
71+
expect(result.current.cancel).toBe(controls.cancel);
72+
expect(result.current.pauseAll).toBe(controls.pauseAll);
73+
expect(result.current.resumeAll).toBe(controls.resumeAll);
74+
expect(result.current.restartAll).toBe(controls.restartAll);
75+
});
4776
});

src/useTimer.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,24 @@ export function useTimer(options: UseTimerOptions = {}): TimerSnapshot & TimerCo
199199
[callOnEnd, clearScheduledTick, emit, evaluateSchedules],
200200
);
201201

202+
const getNextDelay = useCallback((clock = readClock()) => {
203+
const updateIntervalMs = optionsRef.current.updateIntervalMs ?? 1000;
204+
let nextDelay = updateIntervalMs;
205+
206+
const state = stateRef.current!;
207+
if (state.status !== 'running') return updateIntervalMs;
208+
209+
const schedules = optionsRef.current.schedules ?? [];
210+
schedules.forEach((schedule, index) => {
211+
const key = schedule.id ?? String(index);
212+
const scheduleState = schedulesRef.current.get(key);
213+
const lastRunAt = scheduleState?.lastRunAt ?? state.startedAt ?? clock.wallNow;
214+
nextDelay = Math.min(nextDelay, Math.max(1, lastRunAt + schedule.everyMs - clock.wallNow));
215+
});
216+
217+
return nextDelay;
218+
}, []);
219+
202220
const start = useCallback(() => {
203221
const clock = readClock();
204222
if (!startTimerState(stateRef.current!, clock)) return;
@@ -305,15 +323,15 @@ export function useTimer(options: UseTimerOptions = {}): TimerSnapshot & TimerCo
305323
emit('timer:tick', tickSnapshot);
306324
processRunningState(clock);
307325
rerender();
308-
}, optionsRef.current.updateIntervalMs ?? 1000);
326+
}, getNextDelay());
309327

310328
return () => {
311329
if (timeoutRef.current !== null) {
312330
emit('scheduler:stop', getSnapshot());
313331
}
314332
clearScheduledTick();
315333
};
316-
}, [clearScheduledTick, emit, generation, getSnapshot, processRunningState, snapshot.tick, status]);
334+
}, [clearScheduledTick, emit, generation, getNextDelay, getSnapshot, processRunningState, snapshot.tick, status]);
317335

318336
return {
319337
...snapshot,

src/useTimerGroup.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,25 @@ export function useTimerGroup(options: UseTimerGroupOptions = {}): TimerGroupRes
235235
[callOnEnd, emit, evaluateItemSchedules],
236236
);
237237

238+
const getNextDelay = useCallback((clock = readClock()) => {
239+
const updateIntervalMs = optionsRef.current.updateIntervalMs ?? 1000;
240+
let nextDelay = updateIntervalMs;
241+
242+
for (const item of itemsRef.current.values()) {
243+
if (item.state.status !== 'running') continue;
244+
245+
const schedules = item.definition.schedules ?? [];
246+
schedules.forEach((schedule, index) => {
247+
const key = schedule.id ?? String(index);
248+
const scheduleState = item.schedules.get(key);
249+
const lastRunAt = scheduleState?.lastRunAt ?? item.state.startedAt ?? clock.wallNow;
250+
nextDelay = Math.min(nextDelay, Math.max(1, lastRunAt + schedule.everyMs - clock.wallNow));
251+
});
252+
}
253+
254+
return nextDelay;
255+
}, []);
256+
238257
const ensureItem = useCallback((definition: TimerGroupItem): { item: InternalGroupItem; added: boolean } => {
239258
const existing = itemsRef.current.get(definition.id);
240259
if (existing) {
@@ -401,7 +420,7 @@ export function useTimerGroup(options: UseTimerGroupOptions = {}): TimerGroupRes
401420
}
402421

403422
rerender();
404-
}, optionsRef.current.updateIntervalMs ?? 1000);
423+
}, getNextDelay());
405424

406425
return () => {
407426
if (timeoutRef.current !== null) {
@@ -410,7 +429,7 @@ export function useTimerGroup(options: UseTimerGroupOptions = {}): TimerGroupRes
410429
clearScheduledTick();
411430
mountedRef.current = false;
412431
};
413-
}, [activeSignature, clearScheduledTick, emit, processItem]);
432+
}, [activeSignature, clearScheduledTick, emit, getNextDelay, processItem]);
414433

415434
const get = useCallback(
416435
(id: string) => {
@@ -422,6 +441,12 @@ export function useTimerGroup(options: UseTimerGroupOptions = {}): TimerGroupRes
422441
);
423442

424443
const now = readClock().wallNow;
444+
const startAll = useCallback(() => Array.from(itemsRef.current.keys()).forEach(start), [start]);
445+
const pauseAll = useCallback(() => Array.from(itemsRef.current.keys()).forEach(pause), [pause]);
446+
const resumeAll = useCallback(() => Array.from(itemsRef.current.keys()).forEach(resume), [resume]);
447+
const resetAll = useCallback((resetOptions?: { autoStart?: boolean }) => Array.from(itemsRef.current.keys()).forEach(id => reset(id, resetOptions)), [reset]);
448+
const restartAll = useCallback(() => Array.from(itemsRef.current.keys()).forEach(restart), [restart]);
449+
const cancelAll = useCallback((reason?: string) => Array.from(itemsRef.current.keys()).forEach(id => cancel(id, reason)), [cancel]);
425450

426451
return useMemo(
427452
() => ({
@@ -439,14 +464,14 @@ export function useTimerGroup(options: UseTimerGroupOptions = {}): TimerGroupRes
439464
reset,
440465
restart,
441466
cancel,
442-
startAll: () => Array.from(itemsRef.current.keys()).forEach(start),
443-
pauseAll: () => Array.from(itemsRef.current.keys()).forEach(pause),
444-
resumeAll: () => Array.from(itemsRef.current.keys()).forEach(resume),
445-
resetAll: (resetOptions?: { autoStart?: boolean }) => Array.from(itemsRef.current.keys()).forEach(id => reset(id, resetOptions)),
446-
restartAll: () => Array.from(itemsRef.current.keys()).forEach(restart),
447-
cancelAll: (reason?: string) => Array.from(itemsRef.current.keys()).forEach(id => cancel(id, reason)),
467+
startAll,
468+
pauseAll,
469+
resumeAll,
470+
resetAll,
471+
restartAll,
472+
cancelAll,
448473
}),
449-
[add, cancel, clear, get, now, pause, remove, reset, restart, resume, start, update],
474+
[add, cancel, cancelAll, clear, get, now, pause, pauseAll, remove, reset, resetAll, restart, restartAll, resume, resumeAll, start, startAll, update],
450475
);
451476
}
452477

0 commit comments

Comments
 (0)