From dc179a5426cda7c54a27cad53a7445ac07eb1034 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 3 Nov 2026 17:34:36 -0500 Subject: [PATCH 1/6] Run requestIdleCallback polyfill after paint with a 10ms budget The polyfill ran idle callbacks as a 0ms timer with a 50ms budget measured from scheduling, so in Safari idle work landed between an event handler and the next paint and showed up as INP presentation delay. Callbacks now run in a task posted from requestAnimationFrame, get a 10ms budget measured from when they start, honour options.timeout with didTimeout: true, and can be cancelled at any stage. Hidden documents skip the animation frame, since rAF does not fire there. didTimeout is now false when no timeout was requested. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e817e803-845b-4e88-b4fe-713c9673fbe7 --- src/requestidlecallback.ts | 100 +++++++--- test/requestidlecallback.js | 370 +++++++++++++++++++++++++++++++++++- 2 files changed, 440 insertions(+), 30 deletions(-) diff --git a/src/requestidlecallback.ts b/src/requestidlecallback.ts index 3920e6c..fb8934b 100644 --- a/src/requestidlecallback.ts +++ b/src/requestidlecallback.ts @@ -1,34 +1,82 @@ -// 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 +// https://w3c.github.io/requestidlecallback/#why50 caps idle deadlines at 50ms. We use 10ms instead: this +// polyfill runs work right after paint, so it still delays the next frame's input handling, and lab data +// showed a 10ms budget keeps WebKit presentation delay low. +const maxDeadline = 10 -export function requestIdleCallback(callback: IdleRequestCallback, options: IdleRequestOptions = {}): number { - 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) +type Handle = { + callback: IdleRequestCallback + frame?: number + task?: ReturnType + timeout?: ReturnType +} + +const pending = new Map() +let nextId = 1 +let listening = false + +function clearHandles(handle: Handle): void { + if (handle.frame !== undefined) cancelAnimationFrame(handle.frame) + if (handle.task !== undefined) clearTimeout(handle.task) + if (handle.timeout !== undefined) clearTimeout(handle.timeout) +} + +function run(id: number, didTimeout: boolean): void { + const handle = pending.get(id) + if (!handle) return + pending.delete(id) + clearHandles(handle) + const start = performance.now() + handle.callback({ + didTimeout, + timeRemaining: () => (didTimeout ? 0 : Math.max(0, maxDeadline - (performance.now() - start))), }) } +function post(id: number, handle: Handle): void { + handle.frame = undefined + handle.task = setTimeout(() => run(id, false), 0) +} + +function onVisibilityChange(): void { + if (document.visibilityState !== 'hidden') return + for (const [id, handle] of pending) { + if (handle.frame !== undefined && handle.task === undefined) { + cancelAnimationFrame(handle.frame) + post(id, handle) + } + } +} + +export function requestIdleCallback(callback: IdleRequestCallback, options: IdleRequestOptions = {}): number { + // Read before registering anything so a throwing getter 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 id = nextId++ + const handle: Handle = {callback} + pending.set(id, handle) + + const canFrame = typeof document !== 'undefined' && typeof requestAnimationFrame === 'function' + if (canFrame && !listening) { + listening = true + document.addEventListener('visibilitychange', onVisibilityChange) + } + if (canFrame && document.visibilityState !== 'hidden') { + handle.frame = requestAnimationFrame(() => post(id, handle)) + } else { + post(id, handle) + } + + if (timeout && timeout > 0) { + handle.timeout = setTimeout(() => run(id, true), timeout) + } + return id +} + export function cancelIdleCallback(id: number): void { - clearTimeout(id) + const handle = pending.get(id) + if (!handle) return + pending.delete(id) + clearHandles(handle) } /*#__PURE__*/ diff --git a/test/requestidlecallback.js b/test/requestidlecallback.js index d81029c..c7e8242 100644 --- a/test/requestidlecallback.js +++ b/test/requestidlecallback.js @@ -1,7 +1,73 @@ import {expect} from 'chai' -import {apply, isPolyfilled, isSupported, requestIdleCallback} from '../src/requestidlecallback.ts' +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 + } +} 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(() => { + 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') @@ -11,10 +77,306 @@ describe('requestIdleCallback', () => { }) it('resolves to first resolving value', async () => { - const arg = await new Promise(resolve => requestIdleCallback(resolve)) - expect(Object.keys(arg)).to.eql(['didTimeout', 'timeRemaining']) + const arg = await new Promise(resolve => { + requestIdleCallback(resolve) + runFrame() + }) 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(50).greaterThanOrEqual(0) + expect(arg.timeRemaining()).to.be.a('number').lessThanOrEqual(10).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('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 10ms remaining when the 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(8) + expect(remaining).to.be.lessThanOrEqual(10) + }) + + 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) + expect(readings.second).to.be.lessThanOrEqual(7.5) + }) + + it('reaches 0 after running longer than 10ms', async () => { + const remaining = await new Promise(resolve => { + requestIdleCallback(deadline => { + busyWait(12) + 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 10ms after scheduling. + let remaining + requestIdleCallback(deadline => { + remaining = deadline.timeRemaining() + }) + runFrame() + busyWait(15) + await sleep(100) + expect(remaining).to.be.greaterThan(8) + }) + }) + + 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(100) + expect(received).to.eql([false]) + }) + + 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('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) + }) }) }) From 8bbeba91243b1a010de10763556b069b38bf87ac Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 3 Nov 2026 18:44:24 -0500 Subject: [PATCH 2/6] Share one 10ms deadline per idle period in requestIdleCallback polyfill Giving every callback its own 10ms budget let a burst of pending callbacks run back to back in one frame. In a WebKit lab with 8 independent idle queues, click INP rose to 92ms, worse than the original polyfill. Follow the spec's idle period model instead: schedule at most one period per frame (requestAnimationFrame, then setTimeout(0)), run the callbacks queued before it in FIFO order against a shared deadline, and leave the rest, including callbacks queued during the period, for the next frame. A throwing callback no longer strands the callbacks behind it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e817e803-845b-4e88-b4fe-713c9673fbe7 --- src/requestidlecallback.ts | 109 +++++++++++++++++---------------- test/requestidlecallback.js | 116 +++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 52 deletions(-) diff --git a/src/requestidlecallback.ts b/src/requestidlecallback.ts index fb8934b..52e0181 100644 --- a/src/requestidlecallback.ts +++ b/src/requestidlecallback.ts @@ -1,50 +1,64 @@ // https://w3c.github.io/requestidlecallback/#why50 caps idle deadlines at 50ms. We use 10ms instead: this -// polyfill runs work right after paint, so it still delays the next frame's input handling, and lab data -// showed a 10ms budget keeps WebKit presentation delay low. +// polyfill runs work right after paint, so it still delays the next frame's input handling. As in the spec, +// all callbacks in one idle period share a single deadline, so a burst of callbacks can't stack up 10ms each +// in one frame. Lab data showed a 10ms budget keeps WebKit presentation delay low. const maxDeadline = 10 -type Handle = { - callback: IdleRequestCallback - frame?: number - task?: ReturnType - timeout?: ReturnType -} +type Entry = {callback: IdleRequestCallback; timeout?: ReturnType} -const pending = new Map() +const pending = new Map() let nextId = 1 let listening = false +let running = false +let frame: number | undefined +let task: ReturnType | undefined -function clearHandles(handle: Handle): void { - if (handle.frame !== undefined) cancelAnimationFrame(handle.frame) - if (handle.task !== undefined) clearTimeout(handle.task) - if (handle.timeout !== undefined) clearTimeout(handle.timeout) +function runPeriod(): void { + task = undefined + running = true + const deadline = performance.now() + maxDeadline + let ran = false + try { + // Snapshot so callbacks queued during this period wait for the next one. + for (const id of [...pending.keys()]) { + if (ran && performance.now() >= deadline) break + const entry = pending.get(id) + if (!entry) continue + ran = true + pending.delete(id) + clearTimeout(entry.timeout) + entry.callback({didTimeout: false, timeRemaining: () => Math.max(0, deadline - performance.now())}) + } + } finally { + running = false + if (pending.size > 0) schedule() + } } -function run(id: number, didTimeout: boolean): void { - const handle = pending.get(id) - if (!handle) return - pending.delete(id) - clearHandles(handle) - const start = performance.now() - handle.callback({ - didTimeout, - timeRemaining: () => (didTimeout ? 0 : Math.max(0, maxDeadline - (performance.now() - start))), - }) +function post(): void { + frame = undefined + task = setTimeout(runPeriod, 0) } -function post(id: number, handle: Handle): void { - handle.frame = undefined - handle.task = setTimeout(() => run(id, false), 0) +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') { + frame = requestAnimationFrame(post) + return + } + } + post() } function onVisibilityChange(): void { - if (document.visibilityState !== 'hidden') return - for (const [id, handle] of pending) { - if (handle.frame !== undefined && handle.task === undefined) { - cancelAnimationFrame(handle.frame) - post(id, handle) - } - } + if (document.visibilityState !== 'hidden' || frame === undefined) return + cancelAnimationFrame(frame) + post() } export function requestIdleCallback(callback: IdleRequestCallback, options: IdleRequestOptions = {}): number { @@ -52,31 +66,24 @@ export function requestIdleCallback(callback: IdleRequestCallback, options: Idle // `?.` rather than relying on the default: native accepts null, which bypasses default parameters. const timeout = options?.timeout const id = nextId++ - const handle: Handle = {callback} - pending.set(id, handle) - - const canFrame = typeof document !== 'undefined' && typeof requestAnimationFrame === 'function' - if (canFrame && !listening) { - listening = true - document.addEventListener('visibilitychange', onVisibilityChange) - } - if (canFrame && document.visibilityState !== 'hidden') { - handle.frame = requestAnimationFrame(() => post(id, handle)) - } else { - post(id, handle) - } - + const entry: Entry = {callback} + pending.set(id, entry) if (timeout && timeout > 0) { - handle.timeout = setTimeout(() => run(id, true), timeout) + entry.timeout = setTimeout(() => { + if (pending.get(id) !== entry) return + pending.delete(id) + callback({didTimeout: true, timeRemaining: () => 0}) + }, timeout) } + schedule() return id } export function cancelIdleCallback(id: number): void { - const handle = pending.get(id) - if (!handle) return + const entry = pending.get(id) + if (!entry) return pending.delete(id) - clearHandles(handle) + clearTimeout(entry.timeout) } /*#__PURE__*/ diff --git a/test/requestidlecallback.js b/test/requestidlecallback.js index c7e8242..6f57ccc 100644 --- a/test/requestidlecallback.js +++ b/test/requestidlecallback.js @@ -61,7 +61,13 @@ describe('requestIdleCallback', () => { stubVisibility('visible') }) - afterEach(() => { + 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 @@ -204,6 +210,114 @@ describe('requestIdleCallback', () => { }) }) + 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('shares one deadline between callbacks queued together', async () => { + // Read inside the callback: a read after `await` can be delayed on a loaded machine. + let secondRemaining + requestIdleCallback(() => busyWait(6)) + requestIdleCallback(deadline => { + secondRemaining = deadline.timeRemaining() + }) + runFrame() + await sleep(100) + expect(secondRemaining).to.be.a('number') + expect(secondRemaining).to.be.lessThan(5) + expect(secondRemaining).to.be.greaterThanOrEqual(0) + }) + + 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('runs a callback scheduled from inside a running callback in a later period', async () => { + const ran = [] + requestIdleCallback(() => { + ran.push('outer') + requestIdleCallback(() => ran.push('inner')) + }) + requestIdleCallback(() => ran.push('sibling')) + runFrame() + await sleep(100) + expect(ran).to.eql(['outer', 'sibling']) + + 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 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 = [] From 1106ad1a2c689e23fea76c28b6aad750b6fb6457 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 3 Nov 2026 19:43:14 -0500 Subject: [PATCH 3/6] Yield to the event loop every 5ms inside each idle period Running a whole 10ms idle period as one task made foreground work queue behind it. In a WebKit lab, a React-scheduler-style MessageChannel chain took 540ms under the shared period vs 223ms with no idle work. Keep the 10ms wall-clock budget per frame, but spend it in 5ms slices, each posted as its own MessageChannel task, so input, rendering, timers and messages interleave with idle work. The same chain now takes about 280-315ms, with no change to INP, frame pacing, or idle throughput when nothing else is competing. Callbacks queued during a period may run in a later slice of the same period, bounded by the wall deadline, so self-rescheduling queues are not limited to one slice per frame. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e817e803-845b-4e88-b4fe-713c9673fbe7 --- src/requestidlecallback.ts | 62 ++++++++++++++++++------- test/requestidlecallback.js | 93 +++++++++++++++++++++++++++++++------ 2 files changed, 124 insertions(+), 31 deletions(-) diff --git a/src/requestidlecallback.ts b/src/requestidlecallback.ts index 52e0181..7982df2 100644 --- a/src/requestidlecallback.ts +++ b/src/requestidlecallback.ts @@ -1,8 +1,9 @@ -// https://w3c.github.io/requestidlecallback/#why50 caps idle deadlines at 50ms. We use 10ms instead: this -// polyfill runs work right after paint, so it still delays the next frame's input handling. As in the spec, -// all callbacks in one idle period share a single deadline, so a burst of callbacks can't stack up 10ms each -// in one frame. Lab data showed a 10ms budget keeps WebKit presentation delay low. -const maxDeadline = 10 +// 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. The budget is spent in 5ms slices, each its own task, so input, rendering, React's MessageChannel scheduler, +// timers and messages interleave with idle work instead of queueing behind one 10ms block. +const periodMs = 10 +const sliceMs = 5 type Entry = {callback: IdleRequestCallback; timeout?: ReturnType} @@ -10,34 +11,63 @@ const pending = new Map() let nextId = 1 let listening = false let running = false +let periodDeadline = 0 +let channel: MessageChannel | undefined let frame: number | undefined let task: ReturnType | undefined -function runPeriod(): void { - task = undefined - running = true - const deadline = performance.now() + maxDeadline - let ran = false +function slice(first: boolean): void { + const sliceEnd = Math.min(performance.now() + sliceMs, periodDeadline) + let ran = !first try { - // Snapshot so callbacks queued during this period wait for the next one. + // Callbacks queued during the period (including self-rescheduling ones) may run in a later slice 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 slice per frame. for (const id of [...pending.keys()]) { - if (ran && performance.now() >= deadline) break + if (ran && performance.now() >= sliceEnd) break const entry = pending.get(id) if (!entry) continue ran = true pending.delete(id) clearTimeout(entry.timeout) - entry.callback({didTimeout: false, timeRemaining: () => Math.max(0, deadline - performance.now())}) + entry.callback({didTimeout: false, timeRemaining: () => Math.max(0, sliceEnd - performance.now())}) } } finally { - running = false - if (pending.size > 0) schedule() + if (pending.size > 0 && performance.now() < periodDeadline) { + yieldToEventLoop() + } else { + running = false + 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 + slice(true) +} + function post(): void { frame = undefined - task = setTimeout(runPeriod, 0) + task = setTimeout(openPeriod, 0) } function schedule(): void { diff --git a/test/requestidlecallback.js b/test/requestidlecallback.js index 6f57ccc..3675daf 100644 --- a/test/requestidlecallback.js +++ b/test/requestidlecallback.js @@ -89,7 +89,7 @@ describe('requestIdleCallback', () => { }) 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(10).greaterThanOrEqual(0) + expect(arg.timeRemaining()).to.be.a('number').lessThanOrEqual(5).greaterThanOrEqual(0) }) describe('scheduling', () => { @@ -163,14 +163,14 @@ describe('requestIdleCallback', () => { expect(deadline.didTimeout).to.equal(false) }) - it('has close to 10ms remaining when the callback starts', async () => { + 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(8) - expect(remaining).to.be.lessThanOrEqual(10) + expect(remaining).to.be.greaterThan(3) + expect(remaining).to.be.lessThanOrEqual(5) }) it('decreases as the callback keeps running', async () => { @@ -183,13 +183,14 @@ describe('requestIdleCallback', () => { runFrame() }) expect(readings.second).to.be.lessThan(readings.first) - expect(readings.second).to.be.lessThanOrEqual(7.5) + // 5ms slice minus 3ms of spinning. + expect(readings.second).to.be.lessThanOrEqual(2.5) }) - it('reaches 0 after running longer than 10ms', async () => { + it('reaches 0 after running longer than 5ms', async () => { const remaining = await new Promise(resolve => { requestIdleCallback(deadline => { - busyWait(12) + busyWait(6) resolve(deadline.timeRemaining()) }) runFrame() @@ -198,7 +199,7 @@ describe('requestIdleCallback', () => { }) it('measures the budget from callback start, not from scheduling', async () => { - // Hold the main thread so the callback is delayed well past 10ms after scheduling. + // Hold the main thread so the callback is delayed well past the budget after scheduling. let remaining requestIdleCallback(deadline => { remaining = deadline.timeRemaining() @@ -206,7 +207,7 @@ describe('requestIdleCallback', () => { runFrame() busyWait(15) await sleep(100) - expect(remaining).to.be.greaterThan(8) + expect(remaining).to.be.greaterThan(3) }) }) @@ -219,20 +220,59 @@ describe('requestIdleCallback', () => { expect(order).to.eql([0, 1, 2, 3, 4]) }) - it('shares one deadline between callbacks queued together', async () => { + it('shares one deadline between callbacks queued together in the same slice', async () => { // Read inside the callback: a read after `await` can be delayed on a loaded machine. + // The first callback uses 3ms of the 5ms slice, so the second starts in the same slice and sees what is left. + // (A second callback that began a fresh slice would see about 5ms.) let secondRemaining - requestIdleCallback(() => busyWait(6)) + requestIdleCallback(() => busyWait(3)) requestIdleCallback(deadline => { secondRemaining = deadline.timeRemaining() }) runFrame() await sleep(100) expect(secondRemaining).to.be.a('number') - expect(secondRemaining).to.be.lessThan(5) + expect(secondRemaining).to.be.lessThan(2.5) expect(secondRemaining).to.be.greaterThanOrEqual(0) }) + it('yields to the event loop between slices of one period', async () => { + const log = [] + requestIdleCallback(() => { + log.push('A') + queueMicrotask(() => log.push('micro')) + busyWait(6) + }) + requestIdleCallback(() => log.push('B')) + // Only one frame is delivered: B must still run, because the 10ms period has budget left. + runFrame() + await sleep(100) + // A microtask queued by A runs before B only if B is in a separate task. + expect(log).to.eql(['A', 'micro', 'B']) + }) + + 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(() => { @@ -249,17 +289,40 @@ describe('requestIdleCallback', () => { expect(ran).to.eql(['first', 'second']) }) - it('runs a callback scheduled from inside a running callback in a later period', async () => { + 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')) }) - requestIdleCallback(() => ran.push('sibling')) 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', 'sibling']) + 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']) From 3a4ac313ebf12de2168ef18133eff6ab69ed4cd4 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 3 Nov 2026 20:04:19 -0500 Subject: [PATCH 4/6] Clarify how idle slices map to spec idle periods Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e817e803-845b-4e88-b4fe-713c9673fbe7 --- src/requestidlecallback.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/requestidlecallback.ts b/src/requestidlecallback.ts index 7982df2..513bfdd 100644 --- a/src/requestidlecallback.ts +++ b/src/requestidlecallback.ts @@ -20,9 +20,9 @@ function slice(first: boolean): void { const sliceEnd = Math.min(performance.now() + sliceMs, periodDeadline) let ran = !first try { - // Callbacks queued during the period (including self-rescheduling ones) may run in a later slice 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 slice per frame. + // Each slice acts as an idle period: its callbacks share the slice deadline, and callbacks queued while it runs + // (including self-rescheduling ones) wait for a later slice, as the spec requires. Allowing later slices in the same + // frame matters: holding them for the next frame halved idle throughput in the lab with no INP gain. for (const id of [...pending.keys()]) { if (ran && performance.now() >= sliceEnd) break const entry = pending.get(id) From fcdaa835916edf217bbdb780571303c97c62fc4a Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 3 Nov 2026 21:36:12 -0500 Subject: [PATCH 5/6] Run idle callbacks one per task and fix paint-gate and rAF-stall edge cases - Run each idle callback in its own task, capped at 5ms of the shared 10ms period, so promise continuations settle between callbacks like native. - Fall back to a task when requestAnimationFrame doesn't fire within 250ms while the document is still visible (display:none/offscreen iframes). - Make requests made between the frame gate and the period opening wait for the next frame, so they still run after a paint. - Call callbacks without a receiver, matching the timeout path. - Convert options.timeout before registering the callback. - Deflake the slice-yield test, and cover the above. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/requestidlecallback.ts | 77 ++++++++++++------ test/requestidlecallback.js | 155 +++++++++++++++++++++++++++++++++--- 2 files changed, 195 insertions(+), 37 deletions(-) diff --git a/src/requestidlecallback.ts b/src/requestidlecallback.ts index 513bfdd..cf94818 100644 --- a/src/requestidlecallback.ts +++ b/src/requestidlecallback.ts @@ -1,42 +1,57 @@ // 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. The budget is spent in 5ms slices, each its own task, so input, rendering, React's MessageChannel scheduler, -// timers and messages interleave with idle work instead of queueing behind one 10ms block. +// 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 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 sliceEnd = Math.min(performance.now() + sliceMs, periodDeadline) - let ran = !first + const now = performance.now() + const sliceEnd = Math.min(now + sliceMs, periodDeadline) try { - // Each slice acts as an idle period: its callbacks share the slice deadline, and callbacks queued while it runs - // (including self-rescheduling ones) wait for a later slice, as the spec requires. Allowing later slices in the same - // frame matters: holding them for the next frame halved idle throughput in the lab with no INP gain. - for (const id of [...pending.keys()]) { - if (ran && performance.now() >= sliceEnd) break - const entry = pending.get(id) - if (!entry) continue - ran = true - pending.delete(id) - clearTimeout(entry.timeout) - entry.callback({didTimeout: false, timeRemaining: () => Math.max(0, sliceEnd - performance.now())}) + 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 > 0 && performance.now() < periodDeadline) { + if (pending.size > unpainted.size && performance.now() < periodDeadline) { yieldToEventLoop() } else { running = false + unpainted.clear() if (pending.size > 0) schedule() } } @@ -66,7 +81,10 @@ function openPeriod(): void { } function post(): void { + if (frame !== undefined) cancelAnimationFrame(frame) + clearTimeout(frameFallback) frame = undefined + frameFallback = undefined task = setTimeout(openPeriod, 0) } @@ -78,7 +96,14 @@ function schedule(): void { document.addEventListener('visibilitychange', onVisibilityChange) } if (document.visibilityState !== 'hidden') { - frame = requestAnimationFrame(post) + // 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 } } @@ -86,24 +111,24 @@ function schedule(): void { } function onVisibilityChange(): void { - if (document.visibilityState !== 'hidden' || frame === undefined) return - cancelAnimationFrame(frame) - post() + if (document.visibilityState === 'hidden' && frame !== undefined) post() } export function requestIdleCallback(callback: IdleRequestCallback, options: IdleRequestOptions = {}): number { - // Read before registering anything so a throwing getter can't leave a callback scheduled without an id. - // `?.` rather than relying on the default: native accepts null, which bypasses default parameters. + // 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 (timeout && timeout > 0) { + if (task !== undefined) unpainted.add(id) + if (delay > 0) { entry.timeout = setTimeout(() => { if (pending.get(id) !== entry) return - pending.delete(id) + remove(id) callback({didTimeout: true, timeRemaining: () => 0}) - }, timeout) + }, delay) } schedule() return id @@ -112,7 +137,7 @@ export function requestIdleCallback(callback: IdleRequestCallback, options: Idle export function cancelIdleCallback(id: number): void { const entry = pending.get(id) if (!entry) return - pending.delete(id) + remove(id) clearTimeout(entry.timeout) } diff --git a/test/requestidlecallback.js b/test/requestidlecallback.js index 3675daf..4300d08 100644 --- a/test/requestidlecallback.js +++ b/test/requestidlecallback.js @@ -128,6 +128,15 @@ describe('requestIdleCallback', () => { 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) { @@ -220,37 +229,106 @@ describe('requestIdleCallback', () => { expect(order).to.eql([0, 1, 2, 3, 4]) }) - it('shares one deadline between callbacks queued together in the same slice', async () => { - // Read inside the callback: a read after `await` can be delayed on a loaded machine. - // The first callback uses 3ms of the 5ms slice, so the second starts in the same slice and sees what is left. - // (A second callback that began a fresh slice would see about 5ms.) + 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(() => busyWait(3)) + requestIdleCallback(() => { + firstStart = performance.now() + busyWait(7) + }) requestIdleCallback(deadline => { + secondStart = performance.now() secondRemaining = deadline.timeRemaining() }) - runFrame() - await sleep(100) + // 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.lessThan(2.5) 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('yields to the event loop between slices of one period', async () => { + it('lets promise continuations run between callbacks of one period', async () => { const log = [] requestIdleCallback(() => { log.push('A') queueMicrotask(() => log.push('micro')) - busyWait(6) }) requestIdleCallback(() => log.push('B')) // Only one frame is delivered: B must still run, because the 10ms period has budget left. runFrame() await sleep(100) - // A microtask queued by A runs before B only if B is in a separate task. 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 = [] @@ -357,6 +435,30 @@ describe('requestIdleCallback', () => { } }) + 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 = [] @@ -542,6 +644,37 @@ describe('requestIdleCallback', () => { }) }) + 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. From 8748db6feda3a1dc81c874302ef7b98802d435d2 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 3 Nov 2026 22:51:08 -0500 Subject: [PATCH 6/6] Space out hidden-tab idle periods In real Safari 27.2, a hidden tab with a self-rescheduling idle queue ran 10ms periods back to back (about 55% of a core). Hidden periods now start at least 100ms apart. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/requestidlecallback.ts | 11 +++++++++-- test/requestidlecallback.js | 23 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/requestidlecallback.ts b/src/requestidlecallback.ts index cf94818..e12e8c8 100644 --- a/src/requestidlecallback.ts +++ b/src/requestidlecallback.ts @@ -8,6 +8,9 @@ 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} @@ -77,15 +80,17 @@ 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(): void { +function post(delay = 0): void { if (frame !== undefined) cancelAnimationFrame(frame) clearTimeout(frameFallback) frame = undefined frameFallback = undefined - task = setTimeout(openPeriod, 0) + task = setTimeout(openPeriod, delay) } function schedule(): void { @@ -106,6 +111,8 @@ function schedule(): void { }, frameFallbackMs) return } + post(hiddenGapMs) + return } post() } diff --git a/test/requestidlecallback.js b/test/requestidlecallback.js index 4300d08..23b0c96 100644 --- a/test/requestidlecallback.js +++ b/test/requestidlecallback.js @@ -606,10 +606,31 @@ describe('requestIdleCallback', () => { stubVisibility('hidden') const received = [] requestIdleCallback(deadline => received.push(deadline.didTimeout)) - await sleep(100) + 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))