From 599830dba42f5ec5e576b8d2dc8bbf76103a2d8d Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:53:41 -0500 Subject: [PATCH] Revert "Run requestIdleCallback polyfill after paint in 5ms slices of a 10ms per-frame budget" This reverts #129 (merge b4f19bd). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/requestidlecallback.ts | 169 ++------- test/requestidlecallback.js | 701 +----------------------------------- 2 files changed, 30 insertions(+), 840 deletions(-) diff --git a/src/requestidlecallback.ts b/src/requestidlecallback.ts index e12e8c8..3920e6c 100644 --- a/src/requestidlecallback.ts +++ b/src/requestidlecallback.ts @@ -1,151 +1,34 @@ -// https://w3c.github.io/requestidlecallback/#why50 caps idle deadlines at 50ms. We use a 10ms wall-clock budget per -// frame instead: this polyfill runs work right after paint, and lab data showed 10ms keeps WebKit presentation delay -// low. Each callback runs in its own task, with at most 5ms of that budget, so input, rendering, React's -// MessageChannel scheduler, timers and messages interleave with idle work instead of queueing behind one 10ms block, -// and promise continuations settle between callbacks as they do with native requestIdleCallback. -const periodMs = 10 -const sliceMs = 5 -// rAF can stop firing while the document still reports `visible` (for example in a display:none cross-origin -// iframe), so a frame that doesn't arrive in time is replaced by a task. -const frameFallbackMs = 250 -// With no frames to pace periods (hidden tab), only a timer separates them. Real Safari showed back-to-back 10ms -// periods using more than half a core for a self-rescheduling queue, so hidden periods are spaced out. -const hiddenGapMs = 100 - -type Entry = {callback: IdleRequestCallback; timeout?: ReturnType} - -const pending = new Map() -// Requests made after the frame gate fired but before its period opens have not been through a paint yet, so they -// wait for the next period. -const unpainted = new Set() -let nextId = 1 -let listening = false -let running = false -let periodDeadline = 0 -let channel: MessageChannel | undefined -let frame: number | undefined -let frameFallback: ReturnType | undefined -let task: ReturnType | undefined - -function remove(id: number): void { - pending.delete(id) - unpainted.delete(id) -} - -function slice(first: boolean): void { - const now = performance.now() - const sliceEnd = Math.min(now + sliceMs, periodDeadline) - try { - if (first || now < periodDeadline) { - // Callbacks queued during the period (including self-rescheduling ones) may run in a later task of the same - // period, bounded by the wall deadline. The spec defers them to the next idle period, but that would give a - // self-rescheduling queue only one callback per frame. - for (const [id, entry] of pending) { - if (unpainted.has(id)) continue - remove(id) - clearTimeout(entry.timeout) - const {callback} = entry - callback({didTimeout: false, timeRemaining: () => Math.max(0, sliceEnd - performance.now())}) - break - } - } - } finally { - if (pending.size > unpainted.size && performance.now() < periodDeadline) { - yieldToEventLoop() - } else { - running = false - unpainted.clear() - if (pending.size > 0) schedule() - } - } -} - -function nextSlice(): void { - slice(false) -} - -function yieldToEventLoop(): void { - if (typeof MessageChannel === 'undefined') { - setTimeout(nextSlice, 0) - return - } - if (!channel) { - channel = new MessageChannel() - channel.port1.onmessage = nextSlice - } - channel.port2.postMessage(null) -} - -function openPeriod(): void { - task = undefined - running = true - periodDeadline = performance.now() + periodMs - // Nothing was painted while hidden, so there is no paint gate left to honor. - if (typeof document !== 'undefined' && document.visibilityState === 'hidden') unpainted.clear() - slice(true) -} - -function post(delay = 0): void { - if (frame !== undefined) cancelAnimationFrame(frame) - clearTimeout(frameFallback) - frame = undefined - frameFallback = undefined - task = setTimeout(openPeriod, delay) -} - -function schedule(): void { - if (running || frame !== undefined || task !== undefined) return - if (typeof document !== 'undefined' && typeof requestAnimationFrame === 'function') { - if (!listening) { - listening = true - document.addEventListener('visibilitychange', onVisibilityChange) - } - if (document.visibilityState !== 'hidden') { - // A stale frame or fallback must not open a second period once the gate has moved on. - const gate = requestAnimationFrame(() => { - if (frame === gate) post() - }) - frame = gate - frameFallback = setTimeout(() => { - if (frame === gate) post() - }, frameFallbackMs) - return - } - post(hiddenGapMs) - return - } - post() -} - -function onVisibilityChange(): void { - if (document.visibilityState === 'hidden' && frame !== undefined) post() -} +// https://w3c.github.io/requestidlecallback/#why50 +// "Capping idle deadlines to 50ms means that even if the user input occurs +// immediately after the idle task has begun, the user agent still has a +// remaining 50ms in which to respond to the user input without producing user +// perceptible lag" +const maxDeadline = 50 export function requestIdleCallback(callback: IdleRequestCallback, options: IdleRequestOptions = {}): number { - // Convert before registering anything so a throwing getter or value can't leave a callback scheduled without an - // id. `?.` rather than relying on the default: native accepts null, which bypasses default parameters. - const timeout = options?.timeout - const delay = timeout && timeout > 0 ? timeout : 0 - const id = nextId++ - const entry: Entry = {callback} - pending.set(id, entry) - if (task !== undefined) unpainted.add(id) - if (delay > 0) { - entry.timeout = setTimeout(() => { - if (pending.get(id) !== entry) return - remove(id) - callback({didTimeout: true, timeRemaining: () => 0}) - }, delay) - } - schedule() - return id + const start = Date.now() + const timeout = options.timeout || 0 + const deadline: IdleDeadline = Object.defineProperty( + { + didTimeout: false, + timeRemaining() { + return Math.max(0, maxDeadline - (Date.now() - start)) + }, + }, + 'didTimeout', + { + get() { + return Date.now() - start > timeout + }, + }, + ) + return window.setTimeout(() => { + callback(deadline) + }) } export function cancelIdleCallback(id: number): void { - const entry = pending.get(id) - if (!entry) return - remove(id) - clearTimeout(entry.timeout) + clearTimeout(id) } /*#__PURE__*/ diff --git a/test/requestidlecallback.js b/test/requestidlecallback.js index 23b0c96..d81029c 100644 --- a/test/requestidlecallback.js +++ b/test/requestidlecallback.js @@ -1,79 +1,7 @@ import {expect} from 'chai' -import {apply, cancelIdleCallback, isPolyfilled, isSupported, requestIdleCallback} from '../src/requestidlecallback.ts' - -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) - -const busyWait = ms => { - const end = performance.now() + ms - while (performance.now() < end) { - // spin - } -} +import {apply, isPolyfilled, isSupported, requestIdleCallback} from '../src/requestidlecallback.ts' describe('requestIdleCallback', () => { - const realRaf = globalThis.requestAnimationFrame - const realCaf = globalThis.cancelAnimationFrame - let queuedFrames = [] - let issuedFrames = [] - let cancelledFrames = new Set() - let nextFrameId = 1 - - // Real animation frames are throttled or never delivered when the test page is in the background (wtr runs - // test files in parallel), so frames are driven by hand: requestAnimationFrame only queues, and runFrame() - // plays the queue in registration order, like a browser does at the next rendering opportunity. Not calling - // runFrame() simulates a frame that never comes. - const useManualFrames = () => { - queuedFrames = [] - issuedFrames = [] - cancelledFrames = new Set() - globalThis.requestAnimationFrame = cb => { - const id = nextFrameId++ - queuedFrames.push({id, cb}) - issuedFrames.push(cb) - return id - } - globalThis.cancelAnimationFrame = id => { - cancelledFrames.add(id) - } - } - - const runFrame = () => { - const frames = queuedFrames - queuedFrames = [] - const now = performance.now() - for (const {id, cb} of frames) { - if (!cancelledFrames.has(id)) cb(now) - } - } - - // Fires every frame callback ever registered, even cancelled ones: a late or stray frame must not run work twice. - const runAllIssuedFrames = () => { - const now = performance.now() - for (const cb of issuedFrames) cb(now) - } - - const stubVisibility = state => { - Object.defineProperty(document, 'visibilityState', {configurable: true, get: () => state}) - } - - beforeEach(() => { - useManualFrames() - stubVisibility('visible') - }) - - afterEach(async () => { - // Only one idle period is scheduled at a time, and a test may end with that period still waiting for a frame - // that never came (e.g. a timeout test). Deliver any such frame so the next test starts from a clean slate. - for (let i = 0; i < 3; i += 1) { - runFrame() - await sleep(10) - } - globalThis.requestAnimationFrame = realRaf - globalThis.cancelAnimationFrame = realCaf - delete document.visibilityState - queuedFrames = [] - }) - it('has standard isSupported, isPolyfilled, apply API', () => { expect(isSupported).to.be.a('function') expect(isPolyfilled).to.be.a('function') @@ -83,631 +11,10 @@ describe('requestIdleCallback', () => { }) it('resolves to first resolving value', async () => { - const arg = await new Promise(resolve => { - requestIdleCallback(resolve) - runFrame() - }) + const arg = await new Promise(resolve => requestIdleCallback(resolve)) + expect(Object.keys(arg)).to.eql(['didTimeout', 'timeRemaining']) expect(arg).to.have.property('didTimeout').to.be.a('boolean') expect(arg).to.have.property('timeRemaining').to.be.a('function') - expect(arg.timeRemaining()).to.be.a('number').lessThanOrEqual(5).greaterThanOrEqual(0) - }) - - describe('scheduling', () => { - it('does not run the callback before the next animation frame', async () => { - const order = [] - requestIdleCallback(() => order.push('idle')) - requestAnimationFrame(() => order.push('frame')) - await sleep(100) - expect(order).to.eql([]) - runFrame() - // The callback is not run synchronously from the frame; it is posted as a task. - expect(order).to.eql(['frame']) - await sleep(100) - expect(order).to.eql(['frame', 'idle']) - }) - - it('accepts null options like native requestIdleCallback', async () => { - const received = [] - const id = requestIdleCallback(deadline => received.push(deadline.didTimeout), null) - expect(id).to.be.a('number') - runFrame() - await sleep(100) - expect(received).to.eql([false]) - }) - - it('throws without scheduling the callback when reading options.timeout throws', async () => { - let calls = 0 - const options = { - get timeout() { - throw new Error('boom') - }, - } - expect(() => requestIdleCallback(() => calls++, options)).to.throw('boom') - runFrame() - await sleep(50) - expect(calls).to.equal(0) - }) - - it('throws without registering the callback when options.timeout cannot be converted', async () => { - let calls = 0 - expect(() => requestIdleCallback(() => calls++, {timeout: Symbol('bad')})).to.throw(TypeError) - requestIdleCallback(() => {}) - runFrame() - await sleep(100) - expect(calls).to.equal(0) - }) - - it('returns distinct numeric ids', () => { - const ids = [requestIdleCallback(() => {}), requestIdleCallback(() => {}), requestIdleCallback(() => {})] - for (const id of ids) { - expect(id).to.be.a('number') - cancelIdleCallback(id) - } - expect(new Set(ids).size).to.equal(3) - }) - - it('runs each of several requests once', async () => { - const calls = [0, 0, 0] - for (let i = 0; i < calls.length; i += 1) requestIdleCallback(() => calls[i]++) - runFrame() - await sleep(100) - expect(calls).to.eql([1, 1, 1]) - }) - }) - - describe('deadline', () => { - const getDeadline = (options = {}) => - new Promise(resolve => { - requestIdleCallback(resolve, options) - runFrame() - }) - - it('reports didTimeout false when there is no timeout', async () => { - const deadline = await getDeadline() - expect(deadline.didTimeout).to.equal(false) - }) - - it('reports didTimeout false when the frame path wins before a long timeout', async () => { - const deadline = await getDeadline({timeout: 5000}) - expect(deadline.didTimeout).to.equal(false) - }) - - it('has close to 5ms remaining (one slice) when the first callback starts', async () => { - // Read inside the callback: a read after `await` can be delayed past the budget on a loaded machine. - const remaining = await new Promise(resolve => { - requestIdleCallback(deadline => resolve(deadline.timeRemaining())) - runFrame() - }) - expect(remaining).to.be.greaterThan(3) - expect(remaining).to.be.lessThanOrEqual(5) - }) - - it('decreases as the callback keeps running', async () => { - const readings = await new Promise(resolve => { - requestIdleCallback(deadline => { - const first = deadline.timeRemaining() - busyWait(3) - resolve({first, second: deadline.timeRemaining()}) - }) - runFrame() - }) - expect(readings.second).to.be.lessThan(readings.first) - // 5ms slice minus 3ms of spinning. - expect(readings.second).to.be.lessThanOrEqual(2.5) - }) - - it('reaches 0 after running longer than 5ms', async () => { - const remaining = await new Promise(resolve => { - requestIdleCallback(deadline => { - busyWait(6) - resolve(deadline.timeRemaining()) - }) - runFrame() - }) - expect(remaining).to.equal(0) - }) - - it('measures the budget from callback start, not from scheduling', async () => { - // Hold the main thread so the callback is delayed well past the budget after scheduling. - let remaining - requestIdleCallback(deadline => { - remaining = deadline.timeRemaining() - }) - runFrame() - busyWait(15) - await sleep(100) - expect(remaining).to.be.greaterThan(3) - }) - }) - - describe('idle periods', () => { - it('runs several callbacks in FIFO order', async () => { - const order = [] - for (let i = 0; i < 5; i += 1) requestIdleCallback(() => order.push(i)) - runFrame() - await sleep(100) - expect(order).to.eql([0, 1, 2, 3, 4]) - }) - - it('caps a later callback at what is left of the 10ms period', async () => { - // Read inside the callbacks: a read after `await` can be delayed on a loaded machine. - let firstStart - let secondStart - let secondRemaining - requestIdleCallback(() => { - firstStart = performance.now() - busyWait(7) - }) - requestIdleCallback(deadline => { - secondStart = performance.now() - secondRemaining = deadline.timeRemaining() - }) - // A stalled machine can push the second callback into the next frame, so deliver more frames if needed. - for (let frames = 0; frames < 3 && secondRemaining === undefined; frames += 1) { - runFrame() - await sleep(100) - } - expect(secondRemaining).to.be.a('number') - expect(secondRemaining).to.be.greaterThanOrEqual(0) - expect(secondRemaining).to.be.lessThanOrEqual(5) - // The period opened no later than the first callback, so its deadline is at most firstStart + 10. - if (secondStart - firstStart < 10) { - expect(secondRemaining).to.be.lessThanOrEqual(firstStart + 10 - secondStart + 0.01) - } - }) - - it('lets promise continuations run between callbacks of one period', async () => { - const log = [] - requestIdleCallback(() => { - log.push('A') - queueMicrotask(() => log.push('micro')) - }) - requestIdleCallback(() => log.push('B')) - // Only one frame is delivered: B must still run, because the 10ms period has budget left. - runFrame() - await sleep(100) - expect(log).to.eql(['A', 'micro', 'B']) - }) - - it('runs the next callback in a separate task, after the whole microtask queue drains', async () => { - const log = [] - requestIdleCallback(() => { - log.push('A') - // A chain of microtasks: a yield that is itself a microtask would let B in before the chain ends. - let depth = 0 - const step = () => { - depth += 1 - if (depth < 5) queueMicrotask(step) - else log.push('chain-end') - } - queueMicrotask(step) - }) - requestIdleCallback(() => log.push('B')) - runFrame() - await sleep(100) - expect(log).to.eql(['A', 'chain-end', 'B']) - }) - - it('lets a promise continuation cancel the next callback before it runs', async () => { - const ran = [] - const ids = {} - requestIdleCallback(async () => { - ran.push('A') - await null - cancelIdleCallback(ids.second) - }) - ids.second = requestIdleCallback(() => ran.push('B')) - requestIdleCallback(() => ran.push('C')) - runFrame() - await sleep(100) - expect(ran).to.eql(['A', 'C']) - }) - - it('waits for the next frame when a request is made after the frame gate fired but before the period opens', async () => { - const ran = [] - requestIdleCallback(() => ran.push('first')) - runFrame() - // The gate has fired and the period's task is queued: this request has not been through a paint yet. - requestIdleCallback(() => ran.push('late')) - await sleep(100) - expect(ran).to.eql(['first']) - - runFrame() - await sleep(100) - expect(ran).to.eql(['first', 'late']) - }) - - it('cancels a request that is waiting for the next frame after the gate fired', async () => { - let calls = 0 - requestIdleCallback(() => {}) - runFrame() - const id = requestIdleCallback(() => calls++) - cancelIdleCallback(id) - await sleep(100) - runFrame() - await sleep(100) - expect(calls).to.equal(0) - }) - - it('keeps running callbacks across slices until the 10ms period budget is used up', async () => { - const total = 6 - const ran = [] - for (let i = 0; i < total; i += 1) { - requestIdleCallback(() => { - ran.push(i) - busyWait(4) - }) - } - runFrame() - await sleep(100) - // 4ms each: roughly 2-3 fit in 10ms, and the rest wait for the next frame. - expect(ran.length).to.be.greaterThanOrEqual(2) - expect(ran.length).to.be.lessThan(total) - - for (let frames = 0; frames < total && ran.length < total; frames += 1) { - runFrame() - await sleep(100) - } - expect(ran).to.eql([0, 1, 2, 3, 4, 5]) - }) - - it('defers the remaining callbacks to the next frame once the deadline has passed', async () => { - const ran = [] - requestIdleCallback(() => { - ran.push('first') - busyWait(12) - }) - requestIdleCallback(() => ran.push('second')) - runFrame() - await sleep(100) - expect(ran).to.eql(['first']) - - runFrame() - await sleep(100) - expect(ran).to.eql(['first', 'second']) - }) - - it('does not run a callback scheduled from inside a running callback in the same task', async () => { - const log = [] - requestIdleCallback(() => { - log.push('outer') - requestIdleCallback(() => log.push('inner')) - queueMicrotask(() => log.push('micro')) - }) - runFrame() - await sleep(100) - // The microtask runs when the outer callback's task ends; the inner callback must come after it. - expect(log).to.eql(['outer', 'micro', 'inner']) - }) - - it('may run a callback scheduled from a short callback later in the same period, without a new frame', async () => { - const ran = [] - requestIdleCallback(() => { - ran.push('outer') - requestIdleCallback(() => ran.push('inner')) - }) - runFrame() - // No further runFrame(): the period still has budget, so the inner callback runs after a task tick. - await sleep(100) - expect(ran).to.eql(['outer', 'inner']) - }) - - it('runs a callback scheduled from inside a callback exactly once and after its siblings', async () => { - const ran = [] - requestIdleCallback(() => { - ran.push('outer') - requestIdleCallback(() => ran.push('inner')) - }) - requestIdleCallback(() => ran.push('sibling')) - runFrame() - await sleep(100) - runFrame() - await sleep(100) - expect(ran).to.eql(['outer', 'sibling', 'inner']) - }) - - it('does not strand later callbacks when a callback throws', async () => { - const savedOnError = window.onerror - const errors = [] - // Mocha fails the running test on an uncaught error via window.onerror; record it instead. - window.onerror = (message, source, line, column, error) => { - errors.push(error?.message ?? String(message)) - return true - } - try { - const ran = [] - requestIdleCallback(() => { - throw new Error('callback failure') - }) - requestIdleCallback(() => ran.push('second')) - requestIdleCallback(() => ran.push('third')) - runFrame() - await sleep(100) - expect(errors).to.have.lengthOf(1) - expect(errors[0]).to.contain('callback failure') - - runFrame() - await sleep(100) - expect(ran).to.eql(['second', 'third']) - expect(errors).to.have.lengthOf(1) - } finally { - window.onerror = savedOnError - } - }) - - it('does not strand later callbacks when a callback in a later task of the period throws', async () => { - const savedOnError = window.onerror - const errors = [] - window.onerror = (message, source, line, column, error) => { - errors.push(error?.message ?? String(message)) - return true - } - try { - const ran = [] - requestIdleCallback(() => ran.push('first')) - requestIdleCallback(() => { - throw new Error('later failure') - }) - requestIdleCallback(() => ran.push('third')) - // One frame only: 'third' runs in a task after the one that threw. - runFrame() - await sleep(100) - expect(errors).to.eql(['later failure']) - expect(ran).to.eql(['first', 'third']) - } finally { - window.onerror = savedOnError - } - }) - - it('does not run every queued callback in one period when each takes a while', async () => { - const total = 8 - const ran = [] - for (let i = 0; i < total; i += 1) { - requestIdleCallback(() => { - ran.push(i) - busyWait(3) - }) - } - runFrame() - await sleep(100) - // The first callback always runs, but 8 x 3ms cannot fit in one 10ms period. - expect(ran.length).to.be.greaterThanOrEqual(1) - expect(ran.length).to.be.lessThan(total) - - // Nothing is lost: each further frame lets more callbacks run, still in order. - for (let frames = 0; frames < total && ran.length < total; frames += 1) { - runFrame() - await sleep(100) - } - expect(ran).to.eql([0, 1, 2, 3, 4, 5, 6, 7]) - }) - }) - - describe('timeout', () => { - it('runs with didTimeout true and no time remaining when the frame never comes', async () => { - const received = [] - requestIdleCallback( - deadline => { - received.push({didTimeout: deadline.didTimeout, remaining: deadline.timeRemaining()}) - }, - {timeout: 20}, - ) - await sleep(150) - expect(received).to.eql([{didTimeout: true, remaining: 0}]) - }) - - it('does not run before the timeout elapses when the frame never comes', async () => { - let calls = 0 - requestIdleCallback(() => calls++, {timeout: 300}) - await sleep(50) - expect(calls).to.equal(0) - await sleep(500) - expect(calls).to.equal(1) - }) - - it('runs at most once when the frame fires after the timeout already ran the callback', async () => { - let calls = 0 - requestIdleCallback(() => calls++, {timeout: 20}) - await sleep(150) - expect(calls).to.equal(1) - runAllIssuedFrames() - await sleep(100) - expect(calls).to.equal(1) - }) - - it('runs at most once when the timeout fires after the frame already ran the callback', async () => { - let calls = 0 - requestIdleCallback(() => calls++, {timeout: 40}) - runFrame() - await sleep(300) - expect(calls).to.equal(1) - }) - }) - - describe('cancelIdleCallback', () => { - it('cancels immediately after scheduling', async () => { - let calls = 0 - const id = requestIdleCallback(() => calls++) - cancelIdleCallback(id) - runFrame() - await sleep(100) - expect(calls).to.equal(0) - }) - - it('cancels after the animation frame fired but before the callback ran', async () => { - let cancelled = 0 - let control = 0 - const id = requestIdleCallback(() => cancelled++) - requestIdleCallback(() => control++) - // Registered after the polyfill's frame callbacks, so it runs after them in the same frame. - requestAnimationFrame(() => cancelIdleCallback(id)) - runFrame() - await sleep(100) - expect(cancelled).to.equal(0) - expect(control).to.equal(1) - }) - - it('cancels after the frame fired when a timeout is also pending', async () => { - let calls = 0 - const id = requestIdleCallback(() => calls++, {timeout: 20}) - requestAnimationFrame(() => cancelIdleCallback(id)) - runFrame() - await sleep(150) - expect(calls).to.equal(0) - }) - - it('cancels a request whose timeout is pending', async () => { - let calls = 0 - const id = requestIdleCallback(() => calls++, {timeout: 20}) - cancelIdleCallback(id) - await sleep(150) - expect(calls).to.equal(0) - // A frame arriving late must not revive it either. - runAllIssuedFrames() - await sleep(100) - expect(calls).to.equal(0) - }) - - it('only cancels the given request', async () => { - let cancelled = 0 - let kept = 0 - const id = requestIdleCallback(() => cancelled++) - requestIdleCallback(() => kept++) - cancelIdleCallback(id) - runFrame() - await sleep(100) - expect(cancelled).to.equal(0) - expect(kept).to.equal(1) - }) - - it('is a no-op for unknown ids', async () => { - let calls = 0 - requestIdleCallback(() => calls++) - expect(() => cancelIdleCallback(987654321)).to.not.throw() - expect(() => cancelIdleCallback(-1)).to.not.throw() - expect(() => cancelIdleCallback(0)).to.not.throw() - runFrame() - await sleep(100) - expect(calls).to.equal(1) - }) - - it('is a no-op for an id that already ran', async () => { - let id = 0 - await new Promise(resolve => { - id = requestIdleCallback(() => setTimeout(resolve)) - runFrame() - }) - expect(() => cancelIdleCallback(id)).to.not.throw() - }) - }) - - describe('hidden documents', () => { - it('runs without waiting for an animation frame when already hidden at schedule time', async () => { - stubVisibility('hidden') - const received = [] - requestIdleCallback(deadline => received.push(deadline.didTimeout)) - await sleep(300) - expect(received).to.eql([false]) - }) - - it('spaces out periods of a self-rescheduling queue so a hidden tab does not spin', async () => { - stubVisibility('hidden') - const stamps = [] - let running = true - const loop = () => - requestIdleCallback(() => { - stamps.push(performance.now()) - if (running) loop() - }) - loop() - await sleep(600) - running = false - await sleep(250) - const gaps = stamps - .slice(1) - .map((t, i) => t - stamps[i]) - .filter(gap => gap > 5) - expect(gaps.length).to.be.greaterThan(1) - expect(Math.min(...gaps)).to.be.greaterThan(80) - }) - - it('runs without the frame when the document becomes hidden while waiting', async () => { - const received = [] - requestIdleCallback(deadline => received.push(deadline.didTimeout)) - await sleep(50) - expect(received).to.eql([]) - - stubVisibility('hidden') - document.dispatchEvent(new Event('visibilitychange')) - await sleep(100) - expect(received).to.eql([false]) - }) - - it('runs the callback only once if the frame also fires after the document became hidden', async () => { - let calls = 0 - requestIdleCallback(() => calls++) - stubVisibility('hidden') - document.dispatchEvent(new Event('visibilitychange')) - await sleep(100) - runAllIssuedFrames() - await sleep(100) - expect(calls).to.equal(1) - }) - - it('does not run a cancelled request when the document becomes hidden', async () => { - let calls = 0 - const id = requestIdleCallback(() => calls++) - cancelIdleCallback(id) - stubVisibility('hidden') - document.dispatchEvent(new Event('visibilitychange')) - await sleep(100) - expect(calls).to.equal(0) - }) - }) - - describe('animation frames that never arrive', () => { - it('runs a request without a timeout through a task fallback while the document stays visible', async () => { - const received = [] - requestIdleCallback(deadline => received.push(deadline.didTimeout)) - await sleep(100) - expect(received).to.eql([]) - await sleep(400) - expect(received).to.eql([false]) - }) - - it('does not open a second period when the frame arrives after the fallback', async () => { - let calls = 0 - requestIdleCallback(() => calls++) - await sleep(500) - expect(calls).to.equal(1) - // The stale frame was cancelled by the fallback, and delivering it anyway must not run anything again. - expect(cancelledFrames.size).to.equal(1) - runAllIssuedFrames() - await sleep(100) - expect(calls).to.equal(1) - }) - - it('does not run the fallback after the frame arrived first', async () => { - let calls = 0 - requestIdleCallback(() => calls++) - runFrame() - await sleep(500) - expect(calls).to.equal(1) - }) - }) - - describe('apply', () => { - it('leaves a native requestIdleCallback alone', () => { - // Test runs in Chrome, which ships native requestIdleCallback. - expect(isSupported()).to.equal(true) - const nativeRequest = globalThis.requestIdleCallback - const nativeCancel = globalThis.cancelIdleCallback - apply() - expect(globalThis.requestIdleCallback).to.equal(nativeRequest) - expect(globalThis.cancelIdleCallback).to.equal(nativeCancel) - expect(globalThis.requestIdleCallback).to.not.equal(requestIdleCallback) - expect(globalThis.cancelIdleCallback).to.not.equal(cancelIdleCallback) - expect(isPolyfilled()).to.equal(false) - }) + expect(arg.timeRemaining()).to.be.a('number').lessThanOrEqual(50).greaterThanOrEqual(0) }) })