From de267858b069db52ce031d10dade6248aedc2951 Mon Sep 17 00:00:00 2001 From: Harsh Choudhary Date: Thu, 29 Feb 2024 23:54:18 +0530 Subject: [PATCH] chore: refactors queueMicroTask code and adds utils (#6) * chore: refactors queueMicroTask code and adds utils * chore: changes necessary comments and error handling --------- Co-authored-by: Harsh Kumar Choudhary --- src/idleCbWithPolyfill.ts | 49 ++++++++------------- src/idleQueue.ts | 88 ++++++++++++++----------------------- src/index.ts | 4 +- src/queueMicrotask.ts | 41 ----------------- src/utils/env.ts | 16 +++++++ src/utils/now.ts | 3 ++ src/utils/queueMicrotask.ts | 41 +++++++++++++++++ 7 files changed, 114 insertions(+), 128 deletions(-) delete mode 100644 src/queueMicrotask.ts create mode 100644 src/utils/env.ts create mode 100644 src/utils/now.ts create mode 100644 src/utils/queueMicrotask.ts diff --git a/src/idleCbWithPolyfill.ts b/src/idleCbWithPolyfill.ts index 047cb43..d09730b 100644 --- a/src/idleCbWithPolyfill.ts +++ b/src/idleCbWithPolyfill.ts @@ -1,8 +1,7 @@ -function now() { - return Date.now() -} +import { now } from './utils/now' +import { isBrowser } from './utils/env' -const supportsRequestIdleCallback_ = typeof requestIdleCallback === 'function' +const supportsRequestIdleCallback_ = isBrowser && typeof requestIdleCallback === 'function' /** * A minimal shim of the native IdleDeadline class. @@ -24,44 +23,34 @@ class IdleDeadline { } /** - * A minimal shim for the requestIdleCallback function. This accepts a - * callback function and runs it at the next idle period, passing in an - * object with a `timeRemaining()` method. + * Provides a cross-browser compatible shim for `requestIdleCallback` and + * `cancelIdleCallback` if native support is not available. Note that the + * shim's `timeRemaining` calculation is an approximation. */ function requestIdleCallbackShim(callback: (deadline: IdleDeadline) => void) { const deadline = new IdleDeadline(now()) return setTimeout(() => callback(deadline), 0) } -/** - * A minimal shim for the cancelIdleCallback function. This accepts a - * handle identifying the idle callback to cancel. - */ function cancelIdleCallbackShim(handle: number | null) { if (handle) clearTimeout(handle) } -/** - * The native `requestIdleCallback()` function or `cancelIdleCallbackShim()` - *.if the browser doesn't support it. +/* + The bind is used to ensure that the context of + the requestIdleCallback and cancelIdleCallback methods is always the window object, + regardless of how or where these functions are called, + This is necessary because these functions are native browser APIs and + are expected to be called with window as their context. */ -const _rIC = supportsRequestIdleCallback_ ? requestIdleCallback : requestIdleCallbackShim -export const rIC = typeof window !== 'undefined' ? _rIC.bind(window) : _rIC +const rIC = supportsRequestIdleCallback_ + ? requestIdleCallback.bind(window) + : requestIdleCallbackShim -/** - * The bind method is used in this context to ensure that the this context of - * the requestIdleCallback and cancelIdleCallback functions is always the window object, - * regardless of how or where these functions are called. - * This is necessary because these functions are native browser APIs and - * are expected to be called with window as their context. - */ - -/** - * The native `cancelIdleCallback()` function or `cancelIdleCallbackShim()` - * if the browser doesn't support it. - */ -const _cIC = supportsRequestIdleCallback_ ? cancelIdleCallback : cancelIdleCallbackShim +const cIC = supportsRequestIdleCallback_ + ? cancelIdleCallback.bind(window) + : cancelIdleCallbackShim -export const cIC = typeof window !== 'undefined' ? _cIC.bind(window) : _cIC +export { rIC, cIC } diff --git a/src/idleQueue.ts b/src/idleQueue.ts index f0d12ec..4422c16 100644 --- a/src/idleQueue.ts +++ b/src/idleQueue.ts @@ -1,26 +1,16 @@ import { cIC, rIC } from './idleCbWithPolyfill' -import { createQueueMicrotask } from './queueMicrotask' +import { createQueueMicrotask } from './utils/queueMicrotask' +import { isBrowser, isSafari } from './utils/env' +import { now } from './utils/now' -declare global { - interface Window { - safari: any - } -} -type DocumentVisibilityState = 'hidden' | 'visible' | 'prerender' | 'unloaded' - -const DEFAULT_MIN_TASK_TIME = 0 -function now() { - return Date.now() +interface State { + time: number + visibilityState: 'hidden' | 'visible' | 'prerender' | 'unloaded' } -const isBrowser = typeof window !== 'undefined' && window.document && window.document.createElement -const isSafari_ = !!( - isBrowser - && 'safari' in window - && typeof window.safari === 'object' - && 'pushNotification' in (window.safari as any) -) +type Task = (state: State) => void +const DEFAULT_MIN_TASK_TIME = 0 /** * Returns true if the IdleDeadline object exists and the remaining time is * less or equal to than the minTaskTime. Otherwise returns false. @@ -33,26 +23,20 @@ function shouldYield(deadline?: IdleDeadline, minTaskTime?: number) { return false } -interface State { - time: number - visibilityState: DocumentVisibilityState -} -type Task = (state: State) => void - /** - * A class wraps a queue of requestIdleCallback functions for two reasons: - * 1. So other callers can know whether or not the queue is empty. - * 2. So we can provide some guarantees that the queued functions will - * run in unload-type situations. + * This class manages a queue of tasks designed to execute during idle browser time. + * + * It allows checking whether tasks are pending and ensures task execution even + * in situations like page unload. */ export class IdleQueue { - private idleCallbackHandle_: number | null - private taskQueue_: { state: State, task: Task, minTaskTime: number }[] + private idleCallbackHandle_: number | null = null + private taskQueue_: { state: State, task: Task, minTaskTime: number }[] = [] private isProcessing_ = false - private state_: State | null - private defaultMinTaskTime_: number - private ensureTasksRun_: boolean + private state_: State | null = null + private defaultMinTaskTime_: number = DEFAULT_MIN_TASK_TIME + private ensureTasksRun_: boolean = false private queueMicrotask?: (callback: VoidFunction) => void /** @@ -60,9 +44,6 @@ export class IdleQueue { * run the queue if the page is hidden (with fallback behavior for Safari). */ constructor({ ensureTasksRun = false, defaultMinTaskTime = DEFAULT_MIN_TASK_TIME } = {}) { - this.idleCallbackHandle_ = null - this.taskQueue_ = [] - this.state_ = null this.defaultMinTaskTime_ = defaultMinTaskTime this.ensureTasksRun_ = ensureTasksRun @@ -74,15 +55,13 @@ export class IdleQueue { if (isBrowser && this.ensureTasksRun_) { addEventListener('visibilitychange', this.onVisibilityChange_, true) - // Safari does not reliably fire the `pagehide` or `visibilitychange` - // events when closing a tab, so we have to use `beforeunload` with a - // timeout to check whether the default action was prevented. - // - https://bugs.webkit.org/show_bug.cgi?id=151610 - // - https://bugs.webkit.org/show_bug.cgi?id=151234 - // NOTE: we only add this to Safari because adding it to Firefox would - // prevent the page from being eligible for bfcache. - if (isSafari_) + if (isSafari) { + // Safari workaround: Due to unreliable event behavior, we use 'beforeunload' + // to ensure tasks run if a tab/window is closed unexpectedly. + // NOTE: we only add this to Safari because adding it to Firefox would + // prevent the page from being eligible for bfcache. addEventListener('beforeunload', this.runTasksImmediately, true) + } } } @@ -134,14 +113,7 @@ export class IdleQueue { if (isBrowser && this.ensureTasksRun_) { removeEventListener('visibilitychange', this.onVisibilityChange_, true) - // Safari does not reliably fire the `pagehide` or `visibilitychange` - // events when closing a tab, so we have to use `beforeunload` with a - // timeout to check whether the default action was prevented. - // - https://bugs.webkit.org/show_bug.cgi?id=151610 - // - https://bugs.webkit.org/show_bug.cgi?id=151234 - // NOTE: we only add this to Safari because adding it to Firefox would - // prevent the page from being eligible for bfcache. - if (isSafari_) + if (isSafari) removeEventListener('beforeunload', this.runTasksImmediately, true) } } @@ -156,10 +128,12 @@ export class IdleQueue { visibilityState: isBrowser ? document.visibilityState : 'visible', } + const minTaskTime = Math.max(0, options?.minTaskTime || this.defaultMinTaskTime_) + arrayMethod.call(this.taskQueue_, { state, task, - minTaskTime: options?.minTaskTime || this.defaultMinTaskTime_, + minTaskTime, }) this.scheduleTasksToRun_() @@ -205,7 +179,13 @@ export class IdleQueue { this.state_ = state - task(state) + try { + task(state) + } + catch (error) { + console.error('Error running IdleQueue Task: ', error) + } + this.state_ = null } } diff --git a/src/index.ts b/src/index.ts index e081955..3c480ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1 @@ -import { IdleQueue } from './idleQueue' - -export { IdleQueue } +export { IdleQueue } from './idleQueue' diff --git a/src/queueMicrotask.ts b/src/queueMicrotask.ts deleted file mode 100644 index a7a45d8..0000000 --- a/src/queueMicrotask.ts +++ /dev/null @@ -1,41 +0,0 @@ -const isBrowser = typeof window !== 'undefined' && window.document && window.document.createElement - -export type Microtask = () => void - -function createQueueMicrotaskViaPromises() { - return (microtask: Microtask) => { - Promise.resolve().then(microtask) - } -} - -function createQueueMicrotaskViaMutationObserver() { - let i = 0 - let microtaskQueue: Microtask[] = [] - const observer = new MutationObserver(() => { - microtaskQueue.forEach(microtask => microtask()) - microtaskQueue = [] - }) - const node = document.createTextNode('') - observer.observe(node, { characterData: true }) - - return (microtask: Microtask) => { - microtaskQueue.push(microtask) - - // Trigger a mutation observer callback, which is a microtask. - node.data = String(++i % 2) - } -} - -/** - * Queues a function to be run in the next microtask. If the browser supports - * Promises, those are used. Otherwise it falls back to MutationObserver. - * Note: since Promise polyfills are popular but not all support microtasks, - * we check for native implementation rather than a polyfill. - */ -export function createQueueMicrotask() { - return isBrowser && 'queueMicrotask' in window - ? window.queueMicrotask.bind(window) - : typeof Promise === 'function' && Promise.toString().includes('[native code]') - ? createQueueMicrotaskViaPromises() - : createQueueMicrotaskViaMutationObserver() -} diff --git a/src/utils/env.ts b/src/utils/env.ts new file mode 100644 index 0000000..2b6502b --- /dev/null +++ b/src/utils/env.ts @@ -0,0 +1,16 @@ +declare global { + interface Window { + safari: any + } +} + +export const isBrowser: boolean + = typeof window !== 'undefined' + && typeof document !== 'undefined' + && typeof navigator !== 'undefined' + +export const isSafari: boolean + = isBrowser + && 'safari' in window + && typeof window.safari === 'object' + && 'pushNotification' in window.safari diff --git a/src/utils/now.ts b/src/utils/now.ts new file mode 100644 index 0000000..434963d --- /dev/null +++ b/src/utils/now.ts @@ -0,0 +1,3 @@ +export function now(): number { + return Date.now() +} diff --git a/src/utils/queueMicrotask.ts b/src/utils/queueMicrotask.ts new file mode 100644 index 0000000..760c857 --- /dev/null +++ b/src/utils/queueMicrotask.ts @@ -0,0 +1,41 @@ +import { isBrowser } from './env' + +type Microtask = () => void + +function createQueueMicrotaskViaPromises(): (microtask: Microtask) => void { + return (microtask: Microtask) => { + Promise.resolve().then(microtask) + } +} + +function createQueueMicrotaskViaMutationObserver(): (microtask: Microtask) => void { + let mutationCounter = 0 + let microtaskQueue: Microtask[] = [] + const observer = new MutationObserver(() => { + microtaskQueue.forEach(microtask => microtask()) + microtaskQueue = [] + }) + const node = document.createTextNode('') + observer.observe(node, { characterData: true }) + + return (microtask: Microtask) => { + microtaskQueue.push(microtask) + + // MutationObserver is a fallback for when native microtasks are unavailable + node.data = String(++mutationCounter % 2) + } +} + +/** +/** + * Schedules a microtask using the best available browser method + * (queueMicrotask, Promises, or MutationObserver). + */ +export function createQueueMicrotask(): (microtask: Microtask) => void { + if (isBrowser && typeof queueMicrotask === 'function') + return queueMicrotask.bind(window) + else if (typeof Promise === 'function' && Promise.toString().includes('[native code]')) + return createQueueMicrotaskViaPromises() + else + return createQueueMicrotaskViaMutationObserver() +}