Skip to content

Commit 00ef0ed

Browse files
authored
feat: add jest.advanceTimersToFrame() (#14598)
1 parent 1bacb5e commit 00ef0ed

File tree

9 files changed

+313
-0
lines changed

9 files changed

+313
-0
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
- `[@jest/core, @jest/test-sequencer]` [**BREAKING**] Exposes `globalConfig` & `contexts` to `TestSequencer` ([#14535](https://github.com/jestjs/jest/pull/14535), & [#14543](https://github.com/jestjs/jest/pull/14543))
99
- `[jest-environment-jsdom]` [**BREAKING**] Upgrade JSDOM to v22 ([#13825](https://github.com/jestjs/jest/pull/13825))
1010
- `[@jest/fake-timers]` [**BREAKING**] Upgrade `@sinonjs/fake-timers` to v11 ([#14544](https://github.com/jestjs/jest/pull/14544))
11+
- `[@jest/fake-timers]` Exposing new modern timers function `advanceTimersToFrame()` which advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame` ([#14598](https://github.com/jestjs/jest/pull/14598))
12+
- `[jest-runtime]` Exposing new modern timers function `jest.advanceTimersToFrame()` from `@jest/fake-timers` ([#14598](https://github.com/jestjs/jest/pull/14598))
1113
- `[@jest/schemas]` Upgrade `@sinclair/typebox` to v0.31 ([#14072](https://github.com/jestjs/jest/pull/14072))
1214
- `[@jest/types]` `test.each()`: Accept a readonly (`as const`) table properly ([#14565](https://github.com/jestjs/jest/pull/14565))
1315
- `[jest-snapshot]` [**BREAKING**] Add support for [Error causes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) in snapshots ([#13965](https://github.com/facebook/jest/pull/13965))

docs/JestObjectAPI.md

+10
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,16 @@ This function is not available when using legacy fake timers implementation.
989989

990990
:::
991991

992+
### `jest.advanceTimersToNextFrame()`
993+
994+
Advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame`. `advanceTimersToNextFrame()` is a helpful way to execute code that is scheduled using `requestAnimationFrame`.
995+
996+
:::info
997+
998+
This function is not available when using legacy fake timers implementation.
999+
1000+
:::
1001+
9921002
### `jest.clearAllTimers()`
9931003

9941004
Removes any pending timers from the timer system.

docs/TimerMocks.md

+24
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,30 @@ it('calls the callback after 1 second via advanceTimersByTime', () => {
167167

168168
Lastly, it may occasionally be useful in some tests to be able to clear all of the pending timers. For this, we have `jest.clearAllTimers()`.
169169

170+
## Advance Timers to the next Frame
171+
172+
In applications, often you want to schedule work inside of an animation frame (with `requestAnimationFrame`). We expose a convenience method `jest.advanceTimersToNextFrame()` to advance all timers enough milliseconds to execute all actively scheduled animation frames.
173+
174+
For mock timing purposes, animation frames are executed every `16ms` (mapping to roughly `60` frames per second) after the clock starts. When you schedule a callback in an animation frame (with `requestAnimationFrame(callback)`), the `callback` will be called when the clock has advanced `16ms`. `jest.advanceTimersToNextFrame()` will advance the clock just enough to get to the next `16ms` increment. If the clock has already advanced `6ms` since a animation frame `callback` was scheduled, then the clock will be advanced by `10ms`.
175+
176+
```javascript
177+
jest.useFakeTimers();
178+
it('calls the animation frame callback after advanceTimersToNextFrame()', () => {
179+
const callback = jest.fn();
180+
181+
requestAnimationFrame(callback);
182+
183+
// At this point in time, the callback should not have been called yet
184+
expect(callback).not.toBeCalled();
185+
186+
jest.advanceTimersToNextFrame();
187+
188+
// Now our callback should have been called!
189+
expect(callback).toBeCalled();
190+
expect(callback).toHaveBeenCalledTimes(1);
191+
});
192+
```
193+
170194
## Selective Faking
171195

172196
Sometimes your code may require to avoid overwriting the original implementation of one or another API. If that is the case, you can use `doNotFake` option. For example, here is how you could provide a custom mock function for `performance.mark()` in jsdom environment:

packages/jest-environment/src/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ export interface Jest {
6767
* Not available when using legacy fake timers implementation.
6868
*/
6969
advanceTimersByTimeAsync(msToRun: number): Promise<void>;
70+
/**
71+
* Advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame`.
72+
* `advanceTimersToNextFrame()` is a helpful way to execute code that is scheduled using `requestAnimationFrame`.
73+
*
74+
* @remarks
75+
* Not available when using legacy fake timers implementation.
76+
*/
77+
advanceTimersToNextFrame(): void;
7078
/**
7179
* Advances all timers by the needed milliseconds so that only the next
7280
* timeouts/intervals will run. Optionally, you can provide steps, so it will

packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts

+239
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,30 @@ describe('FakeTimers', () => {
103103
timers.useFakeTimers();
104104
expect(global.clearImmediate).not.toBe(origClearImmediate);
105105
});
106+
107+
it('mocks requestAnimationFrame if it exists on global', () => {
108+
const global = {
109+
Date,
110+
clearTimeout,
111+
requestAnimationFrame: () => -1,
112+
setTimeout,
113+
} as unknown as typeof globalThis;
114+
const timers = new FakeTimers({config: makeProjectConfig(), global});
115+
timers.useFakeTimers();
116+
expect(global.requestAnimationFrame).toBeDefined();
117+
});
118+
119+
it('mocks cancelAnimationFrame if it exists on global', () => {
120+
const global = {
121+
Date,
122+
cancelAnimationFrame: () => {},
123+
clearTimeout,
124+
setTimeout,
125+
} as unknown as typeof globalThis;
126+
const timers = new FakeTimers({config: makeProjectConfig(), global});
127+
timers.useFakeTimers();
128+
expect(global.cancelAnimationFrame).toBeDefined();
129+
});
106130
});
107131

108132
describe('runAllTicks', () => {
@@ -570,6 +594,202 @@ describe('FakeTimers', () => {
570594
});
571595
});
572596

597+
describe('advanceTimersToNextFrame', () => {
598+
it('runs scheduled animation frame callbacks in order', () => {
599+
const global = {
600+
Date,
601+
clearTimeout,
602+
process,
603+
requestAnimationFrame: () => -1,
604+
setTimeout,
605+
} as unknown as typeof globalThis;
606+
607+
const timers = new FakeTimers({config: makeProjectConfig(), global});
608+
timers.useFakeTimers();
609+
610+
const runOrder: Array<string> = [];
611+
const mock1 = jest.fn(() => runOrder.push('mock1'));
612+
const mock2 = jest.fn(() => runOrder.push('mock2'));
613+
const mock3 = jest.fn(() => runOrder.push('mock3'));
614+
615+
global.requestAnimationFrame(mock1);
616+
global.requestAnimationFrame(mock2);
617+
global.requestAnimationFrame(mock3);
618+
619+
timers.advanceTimersToNextFrame();
620+
621+
expect(runOrder).toEqual(['mock1', 'mock2', 'mock3']);
622+
});
623+
624+
it('should only run currently scheduled animation frame callbacks', () => {
625+
const global = {
626+
Date,
627+
clearTimeout,
628+
process,
629+
requestAnimationFrame: () => -1,
630+
setTimeout,
631+
} as unknown as typeof globalThis;
632+
633+
const timers = new FakeTimers({config: makeProjectConfig(), global});
634+
timers.useFakeTimers();
635+
636+
const runOrder: Array<string> = [];
637+
function run() {
638+
runOrder.push('first-frame');
639+
640+
// scheduling another animation frame in the first frame
641+
global.requestAnimationFrame(() => runOrder.push('second-frame'));
642+
}
643+
644+
global.requestAnimationFrame(run);
645+
646+
// only the first frame should be executed
647+
timers.advanceTimersToNextFrame();
648+
649+
expect(runOrder).toEqual(['first-frame']);
650+
651+
timers.advanceTimersToNextFrame();
652+
653+
expect(runOrder).toEqual(['first-frame', 'second-frame']);
654+
});
655+
656+
it('should allow cancelling of scheduled animation frame callbacks', () => {
657+
const global = {
658+
Date,
659+
cancelAnimationFrame: () => {},
660+
clearTimeout,
661+
process,
662+
requestAnimationFrame: () => -1,
663+
setTimeout,
664+
} as unknown as typeof globalThis;
665+
666+
const timers = new FakeTimers({config: makeProjectConfig(), global});
667+
const callback = jest.fn();
668+
timers.useFakeTimers();
669+
670+
const timerId = global.requestAnimationFrame(callback);
671+
global.cancelAnimationFrame(timerId);
672+
673+
timers.advanceTimersToNextFrame();
674+
675+
expect(callback).not.toHaveBeenCalled();
676+
});
677+
678+
it('should only advance as much time is needed to get to the next frame', () => {
679+
const global = {
680+
Date,
681+
cancelAnimationFrame: () => {},
682+
clearTimeout,
683+
process,
684+
requestAnimationFrame: () => -1,
685+
setTimeout,
686+
} as unknown as typeof globalThis;
687+
688+
const timers = new FakeTimers({config: makeProjectConfig(), global});
689+
timers.useFakeTimers();
690+
691+
const runOrder: Array<string> = [];
692+
const start = global.Date.now();
693+
694+
const callback = () => runOrder.push('frame');
695+
global.requestAnimationFrame(callback);
696+
697+
// Advancing timers less than a frame (which is 16ms)
698+
timers.advanceTimersByTime(6);
699+
expect(global.Date.now()).toEqual(start + 6);
700+
701+
// frame not yet executed
702+
expect(runOrder).toEqual([]);
703+
704+
// move timers forward to execute frame
705+
timers.advanceTimersToNextFrame();
706+
707+
// frame has executed as time has moved forward 10ms to get to the 16ms frame time
708+
expect(runOrder).toEqual(['frame']);
709+
expect(global.Date.now()).toEqual(start + 16);
710+
});
711+
712+
it('should execute any timers on the way to the animation frame', () => {
713+
const global = {
714+
Date,
715+
cancelAnimationFrame: () => {},
716+
clearTimeout,
717+
process,
718+
requestAnimationFrame: () => -1,
719+
setTimeout,
720+
} as unknown as typeof globalThis;
721+
722+
const timers = new FakeTimers({config: makeProjectConfig(), global});
723+
timers.useFakeTimers();
724+
725+
const runOrder: Array<string> = [];
726+
727+
global.requestAnimationFrame(() => runOrder.push('frame'));
728+
729+
// scheduling a timeout that will be executed on the way to the frame
730+
global.setTimeout(() => runOrder.push('timeout'), 10);
731+
732+
// move timers forward to execute frame
733+
timers.advanceTimersToNextFrame();
734+
735+
expect(runOrder).toEqual(['timeout', 'frame']);
736+
});
737+
738+
it('should not execute any timers scheduled inside of an animation frame callback', () => {
739+
const global = {
740+
Date,
741+
cancelAnimationFrame: () => {},
742+
clearTimeout,
743+
process,
744+
requestAnimationFrame: () => -1,
745+
setTimeout,
746+
} as unknown as typeof globalThis;
747+
748+
const timers = new FakeTimers({config: makeProjectConfig(), global});
749+
timers.useFakeTimers();
750+
751+
const runOrder: Array<string> = [];
752+
753+
global.requestAnimationFrame(() => {
754+
runOrder.push('frame');
755+
// scheduling a timer inside of a frame
756+
global.setTimeout(() => runOrder.push('timeout'), 1);
757+
});
758+
759+
timers.advanceTimersToNextFrame();
760+
761+
// timeout not yet executed
762+
expect(runOrder).toEqual(['frame']);
763+
764+
// validating that the timer will still be executed
765+
timers.advanceTimersByTime(1);
766+
expect(runOrder).toEqual(['frame', 'timeout']);
767+
});
768+
769+
it('should call animation frame callbacks with the latest system time', () => {
770+
const global = {
771+
Date,
772+
clearTimeout,
773+
performance,
774+
process,
775+
requestAnimationFrame: () => -1,
776+
setTimeout,
777+
} as unknown as typeof globalThis;
778+
779+
const timers = new FakeTimers({config: makeProjectConfig(), global});
780+
timers.useFakeTimers();
781+
782+
const callback = jest.fn();
783+
784+
global.requestAnimationFrame(callback);
785+
786+
timers.advanceTimersToNextFrame();
787+
788+
// `requestAnimationFrame` callbacks are called with a `DOMHighResTimeStamp`
789+
expect(callback).toHaveBeenCalledWith(global.performance.now());
790+
});
791+
});
792+
573793
describe('reset', () => {
574794
it('resets all pending setTimeouts', () => {
575795
const global = {
@@ -649,6 +869,25 @@ describe('FakeTimers', () => {
649869
timers.advanceTimersByTime(50);
650870
expect(mock1).toHaveBeenCalledTimes(0);
651871
});
872+
873+
it('resets all scheduled animation frames', () => {
874+
const global = {
875+
Date,
876+
clearTimeout,
877+
process,
878+
requestAnimationFrame: () => -1,
879+
setTimeout,
880+
} as unknown as typeof globalThis;
881+
const timers = new FakeTimers({config: makeProjectConfig(), global});
882+
timers.useFakeTimers();
883+
884+
const mock1 = jest.fn();
885+
global.requestAnimationFrame(mock1);
886+
887+
timers.reset();
888+
timers.runAllTimers();
889+
expect(mock1).toHaveBeenCalledTimes(0);
890+
});
652891
});
653892

654893
describe('runOnlyPendingTimers', () => {

0 commit comments

Comments
 (0)