diff --git a/docs/api/vi.md b/docs/api/vi.md index 0c7e89530413..3d534a785cdd 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -603,6 +603,24 @@ await vi.advanceTimersToNextTimerAsync() // log: 2 await vi.advanceTimersToNextTimerAsync() // log: 3 ``` +### vi.advanceTimersToNextFrame 2.1.0 {#vi-advancetimerstonextframe} + +- **Type:** `() => Vitest` + +Similar to [`vi.advanceTimersByTime`](https://vitest.dev/api/vi#vi-advancetimersbytime), but will advance timers by the milliseconds needed to execute callbacks currently scheduled with `requestAnimationFrame`. + +```ts +let frameRendered = false + +requestAnimationFrame(() => { + frameRendered = true +}) + +vi.advanceTimersToNextFrame() + +expect(frameRendered).toBe(true) +``` + ### vi.getTimerCount - **Type:** `() => number` diff --git a/packages/vitest/src/integrations/mock/timers.ts b/packages/vitest/src/integrations/mock/timers.ts index 12a082c3ec6e..d985d049a7a1 100644 --- a/packages/vitest/src/integrations/mock/timers.ts +++ b/packages/vitest/src/integrations/mock/timers.ts @@ -113,6 +113,12 @@ export class FakeTimers { } } + advanceTimersToNextFrame(): void { + if (this._checkFakeTimers()) { + this._clock.runToFrame() + } + } + runAllTicks(): void { if (this._checkFakeTimers()) { // @ts-expect-error method not exposed diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 7c3133188bdc..611d0281fc0b 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -73,6 +73,10 @@ export interface VitestUtils { * Will call next available timer and wait until it's resolved if it was set asynchronously. Useful to make assertions between each timer call. */ advanceTimersToNextTimerAsync: () => Promise + /** + * Similar to [`vi.advanceTimersByTime`](https://vitest.dev/api/vi#vi-advancetimersbytime), but will advance timers by the milliseconds needed to execute callbacks currently scheduled with `requestAnimationFrame`. + */ + advanceTimersToNextFrame: () => VitestUtils /** * Get the number of waiting timers. */ @@ -511,6 +515,11 @@ function createVitest(): VitestUtils { return utils }, + advanceTimersToNextFrame() { + timers().advanceTimersToNextFrame() + return utils + }, + getTimerCount() { return timers().getTimerCount() }, diff --git a/test/core/test/fixtures/timers.suite.ts b/test/core/test/fixtures/timers.suite.ts index 3afc1e894cf7..f82a687c0427 100644 --- a/test/core/test/fixtures/timers.suite.ts +++ b/test/core/test/fixtures/timers.suite.ts @@ -811,6 +811,202 @@ describe('FakeTimers', () => { }) }) + describe('advanceTimersToNextFrame', () => { + it('runs scheduled animation frame callbacks in order', () => { + const global = { + Date, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis + + const timers = new FakeTimers({ global }) + timers.useFakeTimers() + + const runOrder: Array = [] + const mock1 = vi.fn(() => runOrder.push('mock1')) + const mock2 = vi.fn(() => runOrder.push('mock2')) + const mock3 = vi.fn(() => runOrder.push('mock3')) + + global.requestAnimationFrame(mock1) + global.requestAnimationFrame(mock2) + global.requestAnimationFrame(mock3) + + timers.advanceTimersToNextFrame() + + expect(runOrder).toEqual(['mock1', 'mock2', 'mock3']) + }) + + it('should only run currently scheduled animation frame callbacks', () => { + const global = { + Date, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis + + const timers = new FakeTimers({ global }) + timers.useFakeTimers() + + const runOrder: Array = [] + function run() { + runOrder.push('first-frame') + + // scheduling another animation frame in the first frame + global.requestAnimationFrame(() => runOrder.push('second-frame')) + } + + global.requestAnimationFrame(run) + + // only the first frame should be executed + timers.advanceTimersToNextFrame() + + expect(runOrder).toEqual(['first-frame']) + + timers.advanceTimersToNextFrame() + + expect(runOrder).toEqual(['first-frame', 'second-frame']) + }) + + it('should allow cancelling of scheduled animation frame callbacks', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis + + const timers = new FakeTimers({ global }) + const callback = vi.fn() + timers.useFakeTimers() + + const timerId = global.requestAnimationFrame(callback) + global.cancelAnimationFrame(timerId) + + timers.advanceTimersToNextFrame() + + expect(callback).not.toHaveBeenCalled() + }) + + it('should only advance as much time is needed to get to the next frame', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis + + const timers = new FakeTimers({ global }) + timers.useFakeTimers() + + const runOrder: Array = [] + const start = global.Date.now() + + const callback = () => runOrder.push('frame') + global.requestAnimationFrame(callback) + + // Advancing timers less than a frame (which is 16ms) + timers.advanceTimersByTime(6) + expect(global.Date.now()).toEqual(start + 6) + + // frame not yet executed + expect(runOrder).toEqual([]) + + // move timers forward to execute frame + timers.advanceTimersToNextFrame() + + // frame has executed as time has moved forward 10ms to get to the 16ms frame time + expect(runOrder).toEqual(['frame']) + expect(global.Date.now()).toEqual(start + 16) + }) + + it('should execute any timers on the way to the animation frame', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis + + const timers = new FakeTimers({ global }) + timers.useFakeTimers() + + const runOrder: Array = [] + + global.requestAnimationFrame(() => runOrder.push('frame')) + + // scheduling a timeout that will be executed on the way to the frame + global.setTimeout(() => runOrder.push('timeout'), 10) + + // move timers forward to execute frame + timers.advanceTimersToNextFrame() + + expect(runOrder).toEqual(['timeout', 'frame']) + }) + + it('should not execute any timers scheduled inside of an animation frame callback', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis + + const timers = new FakeTimers({ global }) + timers.useFakeTimers() + + const runOrder: Array = [] + + global.requestAnimationFrame(() => { + runOrder.push('frame') + // scheduling a timer inside of a frame + global.setTimeout(() => runOrder.push('timeout'), 1) + }) + + timers.advanceTimersToNextFrame() + + // timeout not yet executed + expect(runOrder).toEqual(['frame']) + + // validating that the timer will still be executed + timers.advanceTimersByTime(1) + expect(runOrder).toEqual(['frame', 'timeout']) + }) + + it('should call animation frame callbacks with the latest system time', () => { + const global = { + Date, + clearTimeout, + performance, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis + + const timers = new FakeTimers({ global }) + timers.useFakeTimers() + + const callback = vi.fn() + + global.requestAnimationFrame(callback) + + timers.advanceTimersToNextFrame() + + // `requestAnimationFrame` callbacks are called with a `DOMHighResTimeStamp` + expect(callback).toHaveBeenCalledWith(global.performance.now()) + }) + }) + describe('reset', () => { it('resets all pending setTimeouts', () => { const global = { Date: FakeDate, clearTimeout, process, setTimeout }