From 8247d96faec3302c089fe90a85d51f6bde7f8566 Mon Sep 17 00:00:00 2001 From: Nev Date: Fri, 29 May 2026 13:52:04 -0700 Subject: [PATCH] refactor(timer): centralize microtask types and queueMicrotask access; expand nextTick and microtask coverage - move MicrotaskFn and ScheduleMicrotaskFn from microtask.ts to types.ts - move getQueueMicrotask and hasQueueMicrotask from microtask.ts to environment.ts - update exports in index.ts to expose moved APIs from their new modules - update internal imports across timer microtask modules and nextTick to use shared helper types/environment access - update scheduleNextTick in nextTick.ts to prefer process.nextTick, then queueMicrotask, then existing fallback resolution - add internal queue scheduler helper in microtaskQueue.ts - extend tests in microtask.test.ts for internal microtask queue rescheduling behavior - extend tests in nextTick.test.ts for Promise fallback ordering and no-Promise/no-microtask edge scenarios - align nextTick tests to explicitly disable queueMicrotask where Promise/timer fallback behavior is being validated --- .size-limit.json | 6 +- CHANGELOG.md | 4 + docs/usage-guide.md | 60 ++++ lib/src/helpers/environment.ts | 40 +++ lib/src/helpers/types.ts | 22 ++ lib/src/index.ts | 12 +- lib/src/timer/microtask.ts | 63 +--- lib/src/timer/microtasks/cancellableTask.ts | 2 +- lib/src/timer/microtasks/microtaskQueue.ts | 87 +++++ lib/src/timer/microtasks/processNextTick.ts | 2 +- lib/src/timer/microtasks/promiseQueue.ts | 2 +- lib/src/timer/microtasks/resolveScheduleFn.ts | 2 +- lib/src/timer/microtasks/runMicrotask.ts | 2 +- lib/src/timer/nextTick.ts | 26 +- lib/test/src/common/timer/microtask.test.ts | 48 ++- lib/test/src/common/timer/nextTick.test.ts | 337 ++++++++++++++++++ 16 files changed, 626 insertions(+), 89 deletions(-) create mode 100644 lib/src/timer/microtasks/microtaskQueue.ts diff --git a/.size-limit.json b/.size-limit.json index c322291b..4c18c11c 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,14 +2,14 @@ { "name": "es5-full", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "37 kb", + "limit": "37.5 kb", "brotli": false, "running": false }, { "name": "es6-full", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "35.5 kb", + "limit": "36 kb", "brotli": false, "running": false }, @@ -23,7 +23,7 @@ { "name": "es6-full-brotli", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "12.5 kb", + "limit": "12.75 kb", "brotli": true, "running": false }, diff --git a/CHANGELOG.md b/CHANGELOG.md index f28afe71..17ec6bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ - Added `arrToMap` helper in the array module and moved callback/type declarations into `iterator/types` - Refactored iterator helper implementation/tests into per-function files and updated root exports - Added NaN regression coverage and switched iterator set-operation membership checks to `arrIncludes` semantics for parity with array helpers +- [#576](https://github.com/nevware21/ts-utils/pull/576) Refactor timer microtask/nextTick shared types and environment helpers, with expanded fallback coverage + - Moved `MicrotaskFn` and `ScheduleMicrotaskFn` to `helpers/types` and updated timer internals to consume shared type definitions + - Moved `getQueueMicrotask` and `hasQueueMicrotask` to `helpers/environment`, updated root exports, and aligned `scheduleNextTick` fallback resolution to prefer `queueMicrotask` when available + - Added internal `microtaskQueue` scheduler helper wiring and expanded tests for queue fallback behavior, Promise ordering, and no-Promise edge cases ### Bug Fixes diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 38f6fff7..d7d9aa07 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -12,6 +12,7 @@ This guide provides practical examples for using the @nevware21/ts-utils library - [String Functions](#string-functions) - [Safe Operations](#safe-operations) - [Runtime Environment Helpers](#runtime-environment-helpers) +- [Scheduling Semantics and Known Limitations](#scheduling-semantics-and-known-limitations) - [Advanced Usage](#advanced-usage) - [Working with Iterators](#working-with-iterators) - [Lazy Evaluation](#lazy-evaluation) @@ -274,6 +275,65 @@ const global = getGlobal(); const doc = hasDocument() ? getDocument() : null; ``` +## Scheduling Semantics and Known Limitations + +When using timer helpers, especially `scheduleNextTick()`, ordering depends on the runtime capabilities and whether you mix direct native APIs with ts-utils wrappers. + +### Fallback priority used by `scheduleNextTick()` + +`scheduleNextTick()` resolves scheduling in this order: + +1. Native `process.nextTick` (Node.js) +2. Native `queueMicrotask` +3. Promise microtask fallback (`Promise.resolve().then(...)`) +4. Timer-backed fallback (`setTimeout(..., 0)`) + +### Mixing direct native calls with `scheduleNextTick()` + +- In modern browser/worker runtimes (where native `process.nextTick` is not available), `scheduleNextTick()` typically uses native `queueMicrotask`. +- If you directly call native `queueMicrotask()` and also call `scheduleNextTick()`, both callbacks are queued in the same microtask queue, so ordering is insertion/FIFO based. +- This means `scheduleNextTick()` does not get special priority over a previously queued native `queueMicrotask()` callback in browser/worker environments. +- For deterministic ordering, prefer one strategy per critical path: either use only native microtask APIs directly or use ts-utils wrappers (`scheduleNextTick()` / `scheduleMicrotask()`) consistently. + +Node.js note: + +- In Node.js, native `process.nextTick` has existed for a long time and `scheduleNextTick()` uses it when available, so the browser/worker `queueMicrotask` mixing concern is generally not the primary issue. + +### Known limitation in timer-backed fallback environments + +In older runtimes where `process.nextTick`, `queueMicrotask`, and Promise are not available, `scheduleNextTick()` falls back to `setTimeout(..., 0)`. + +In this mode: + +- `scheduleNextTick()` is no longer a true microtask. +- It cannot preempt a user's directly scheduled native timers that were already queued. +- Ordering between user timers and `scheduleNextTick()` becomes macrotask queue ordering and may differ from microtask-like expectations. + +Example: + +```typescript +import { scheduleNextTick } from "@nevware21/ts-utils"; + +setTimeout(() => { + console.log("user timeout"); +}, 0); + +scheduleNextTick(() => { + console.log("scheduleNextTick"); +}); + +// In timer-backed fallback environments, output may be: +// "user timeout" +// "scheduleNextTick" +``` + +### Practical guidance + +- only use `scheduleTimeout()`, `scheduleMicrotask()` and `scheduleNextTick()` to ensure the correct execution order. +- Use `scheduleNextTick()` for cross-runtime behavior, but do not assume it always has higher priority than directly queued native `queueMicrotasks` in browser/worker environments. +- Avoid relying on strict microtask-before-timer guarantees in environments that require timer fallback (unless you only use the `scheduleXXXXX()` functions) +- For app-level deterministic ordering, pick one scheduling strategy per critical execution path and avoid mixing native and wrapped scheduling primitives. + ## Advanced Usage ### Working with Iterators diff --git a/lib/src/helpers/environment.ts b/lib/src/helpers/environment.ts index 5a9154ca..dd65abdd 100644 --- a/lib/src/helpers/environment.ts +++ b/lib/src/helpers/environment.ts @@ -11,6 +11,7 @@ import { _getGlobalValue } from "../internal/global"; import { ILazyValue, _globalLazyTestHooks, _initTestHooks, getLazy } from "./lazy"; import { ICachedValue, createCachedValue } from "./cache"; import { safe } from "./safe"; +import { ScheduleMicrotaskFn } from "./types"; const WINDOW = "window"; @@ -238,3 +239,42 @@ export const isNode = (/*#__PURE__*/_getGlobalInstFn(() => { export const isWebWorker = (/*#__PURE__*/_getGlobalInstFn(() => { return !!safe(() => self && self instanceof WorkerGlobalScope).v; })); + + +/** + * Returns the global `queueMicrotask` function if available, or `null` when unavailable. + * + * @function + * @since 0.15.0 + * @group Timer + * @group Environment + * @example + * ```ts + * const queueFn = getQueueMicrotask(); + * if (queueFn) { + * queueFn(() => { + * console.log("microtask"); + * }); + * } + * ``` + */ +export const getQueueMicrotask = (/*#__PURE__*/_getGlobalInstFn(getInst as any, ["queueMicrotask"])); + +/** + * Identifies if the runtime supports the `queueMicrotask` API. + * + * @since 0.15.0 + * @group Timer + * @group Environment + * @returns True if the runtime supports `queueMicrotask` otherwise false. + * @example + * ```ts + * if (hasQueueMicrotask()) { + * console.log("Native queueMicrotask support is available"); + * } + * ``` + */ +/*#__NO_SIDE_EFFECTS__*/ +export function hasQueueMicrotask(): boolean { + return !!( /*#__PURE__*/getQueueMicrotask()); +} diff --git a/lib/src/helpers/types.ts b/lib/src/helpers/types.ts index 30f35789..13719754 100644 --- a/lib/src/helpers/types.ts +++ b/lib/src/helpers/types.ts @@ -167,3 +167,25 @@ export type ValueOf = T[keyof T]; * ``` */ export type NonEmptyArray = [T, ...T[]]; + +/** + * Type alias for a microtask callback function, which is a function that is scheduled to run in the microtask + * queue after the current execution context completes. + * @since 0.15.0 + * @group Timer + * @group Environment + */ +export type MicrotaskFn = () => void; + +/** + * Type alias for a function that is used to schedule a microtask, which is a function + * that takes a callback and schedules it to run + * + * @since 0.15.0 + * @group Timer + * @group Environment + * @param callback - The microtask callback function to schedule. + * @param maxQueuedTasks - Optional, the maximum number of queued tasks allowed before the scheduler + * starts dropping tasks or throwing errors, depending on the implementation. + */ +export type ScheduleMicrotaskFn = (callback: MicrotaskFn, maxQueuedTasks?: number) => void | boolean; diff --git a/lib/src/index.ts b/lib/src/index.ts index 49d888b5..d7d3a00d 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -70,7 +70,7 @@ export { } from "./helpers/enum"; export { getGlobal, getInst, lazySafeGetInst, hasDocument, getDocument, hasWindow, getWindow, hasNavigator, getNavigator, hasHistory, - getHistory, isNode, isWebWorker + getHistory, isNode, isWebWorker, hasQueueMicrotask, getQueueMicrotask } from "./helpers/environment"; export { encodeAsHtml, encodeAsJson, normalizeJsName, encodeAsBase64, decodeBase64, encodeAsBase64Url, @@ -87,7 +87,10 @@ export { safe, ISafeReturn, SafeReturnType } from "./helpers/safe"; export { safeGet } from "./helpers/safe_get"; export { safeGetLazy, safeGetWritableLazy, safeGetDeferred, safeGetWritableDeferred } from "./helpers/safe_lazy"; export { throwError, throwTypeError, throwRangeError } from "./helpers/throw"; -export { ReadonlyRecord, DeepPartial, DeepReadonly, Mutable, DeepRequired, ValueOf, NonEmptyArray } from "./helpers/types"; +export { + ReadonlyRecord, DeepPartial, DeepReadonly, Mutable, DeepRequired, ValueOf, NonEmptyArray, + ScheduleMicrotaskFn, MicrotaskFn +} from "./helpers/types"; export { hasValue } from "./helpers/value"; export { createArrayIterator } from "./iterator/array"; export { CreateIteratorContext, createIterator, createIterable, createIterableIterator, makeIterable } from "./iterator/create"; @@ -180,10 +183,7 @@ export { getIdleCallback, getCancelIdleCallback, RequestIdleCallback, CancelIdleCallback } from "./timer/idle"; export { scheduleInterval } from "./timer/interval"; -export { - hasQueueMicrotask, scheduleMicrotask, getQueueMicrotask, - ScheduleMicrotaskFn, MicroTaskOptions, setMicroTaskFallbackOptions -} from "./timer/microtask"; +export { scheduleMicrotask, MicroTaskOptions, setMicroTaskFallbackOptions } from "./timer/microtask"; export { hasProcessNextTick, scheduleNextTick, getProcessNextTick, NextTickOptions, ProcessNextTickFn, setNextTickFallbackOptions } from "./timer/nextTick"; export { TimeoutOverrideFn, ClearTimeoutOverrideFn, TimeoutOverrideFuncs, scheduleTimeout, scheduleTimeoutWith, diff --git a/lib/src/timer/microtask.ts b/lib/src/timer/microtask.ts index d4f10c55..e798e4cf 100644 --- a/lib/src/timer/microtask.ts +++ b/lib/src/timer/microtask.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import { _getGlobalInstFn, getInst } from "../helpers/environment"; +import { getQueueMicrotask } from "../helpers/environment"; import { ITimerHandler } from "./handler"; import { _createCancellableTask } from "./microtasks/cancellableTask"; import { isArray } from "../helpers/base"; @@ -15,31 +15,10 @@ import { _eTaskQueueType } from "./microtasks/taskQueue"; import { _addMicrotaskQueue } from "./microtasks/timerQueue"; import { fnBindArgs } from "../funcs/fnBindArgs"; import { UNDEF_VALUE } from "../internal/constants"; +import { MicrotaskFn, ScheduleMicrotaskFn } from "../helpers/types"; let _defaultOptions: MicroTaskOptions | undefined; -/** - * Type alias for a microtask callback function, which is a function that is scheduled to run in the microtask - * queue after the current execution context completes. - * @since 0.15.0 - * @group Timer - * @group Environment - */ -export type MicrotaskFn = () => void; - -/** - * Type alias for a function that is used to schedule a microtask, which is a function - * that takes a callback and schedules it to run - * - * @since 0.15.0 - * @group Timer - * @group Environment - * @param callback - The microtask callback function to schedule. - * @param maxQueuedTasks - Optional, the maximum number of queued tasks allowed before the scheduler - * starts dropping tasks or throwing errors, depending on the implementation. - */ -export type ScheduleMicrotaskFn = (callback: MicrotaskFn, maxQueuedTasks?: number) => void | boolean; - /** * Controls how `scheduleMicrotask` chooses fallback behavior when native * `queueMicrotask` is not available. @@ -65,44 +44,6 @@ export interface MicroTaskOptions { useTimeout?: boolean; } -/** - * Returns the global `queueMicrotask` function if available, or `null` when unavailable. - * - * @function - * @since 0.15.0 - * @group Timer - * @group Environment - * @example - * ```ts - * const queueFn = getQueueMicrotask(); - * if (queueFn) { - * queueFn(() => { - * console.log("microtask"); - * }); - * } - * ``` - */ -export const getQueueMicrotask = (/*#__PURE__*/_getGlobalInstFn(getInst as any, ["queueMicrotask"])); - -/** - * Identifies if the runtime supports the `queueMicrotask` API. - * - * @since 0.15.0 - * @group Timer - * @group Environment - * @returns True if the runtime supports `queueMicrotask` otherwise false. - * @example - * ```ts - * if (hasQueueMicrotask()) { - * console.log("Native queueMicrotask support is available"); - * } - * ``` - */ -/*#__NO_SIDE_EFFECTS__*/ -export function hasQueueMicrotask(): boolean { - return !!( /*#__PURE__*/getQueueMicrotask()); -} - /** * Sets the default fallback behavior for {@link scheduleMicrotask} when * `queueMicrotask` is not available. diff --git a/lib/src/timer/microtasks/cancellableTask.ts b/lib/src/timer/microtasks/cancellableTask.ts index a645cc4c..d4295411 100644 --- a/lib/src/timer/microtasks/cancellableTask.ts +++ b/lib/src/timer/microtasks/cancellableTask.ts @@ -6,8 +6,8 @@ * Licensed under the MIT license. */ +import { ScheduleMicrotaskFn } from "../../helpers/types"; import { ITimerHandler, _TimerHandler, _createTimerHandler } from "../handler"; -import { ScheduleMicrotaskFn } from "../microtask"; /** * @internal diff --git a/lib/src/timer/microtasks/microtaskQueue.ts b/lib/src/timer/microtasks/microtaskQueue.ts new file mode 100644 index 00000000..54548c62 --- /dev/null +++ b/lib/src/timer/microtasks/microtaskQueue.ts @@ -0,0 +1,87 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { getQueueMicrotask } from "../../helpers/environment"; +import { ScheduleMicrotaskFn } from "../../helpers/types"; +import { _addToQueue, _eTaskQueueType, _flushQueues } from "./taskQueue"; + +let _microtaskPending = false; +let _pendingMicrotaskFn: ScheduleMicrotaskFn | undefined; +let _microtaskQueueFns: { [key in _eTaskQueueType]?: ScheduleMicrotaskFn } | undefined; + +function _ensureMicrotask(microtaskFn: ScheduleMicrotaskFn): void { + // If the microtask implementation has changed (e.g. lazy bypass tests), clear stale pending state + // so a fresh microtask batch can be scheduled with the current implementation. + if (_microtaskPending && _pendingMicrotaskFn !== microtaskFn) { + _microtaskPending = false; + } + + if (!_microtaskPending) { + _microtaskPending = true; + _pendingMicrotaskFn = microtaskFn; + try { + microtaskFn(() => { + _microtaskPending = false; + _pendingMicrotaskFn = undefined; + _flushQueues(); + }); + } catch (e) { + _microtaskPending = false; + _pendingMicrotaskFn = undefined; + _flushQueues(); + } + } +} + +function _microtaskScheduleFn(microtaskFn: ScheduleMicrotaskFn, queueType: _eTaskQueueType): ScheduleMicrotaskFn { + return function(callback: () => void, maxQueuedTasks?: number): boolean { + let added = _addToQueue(queueType, callback, maxQueuedTasks); + if (added) { + _ensureMicrotask(microtaskFn); + } + + return added; + }; +} + +/** + * @internal + * Returns the queueMicrotask-based scheduler, this is used to ensure that only a single queueMicrotask + * is scheduled at a time for each queue type to avoid unnecessary multiple queueMicrotask calls when + * multiple nextTick (or queueMicrotask) callbacks are scheduled within the same tick, this is important + * when queueMicrotask is used as a fallback for process.nextTick to provide both more efficient scheduling + * and more consistent behavior with the native process.nextTick which also batches callbacks. + * @since 0.15.0 + */ +export function _getMicrotaskQueueFn(queueType: _eTaskQueueType): ScheduleMicrotaskFn | undefined { + let result: ScheduleMicrotaskFn | undefined; + let queueFn = getQueueMicrotask(); + + if (queueFn) { + !_microtaskQueueFns && (_microtaskQueueFns = {}); + result = _microtaskQueueFns[queueType]; + if (!result) { + result = _microtaskScheduleFn(queueFn, queueType); + _microtaskQueueFns[queueType] = result; + } + } + + return result; +} + +/** + * @internal + * Reset helper for tests. + * @since 0.15.0 + */ +export function _resetMicrotaskQueueFns(): void { + _microtaskQueueFns = undefined; + _microtaskPending = false; + _pendingMicrotaskFn = undefined; +} + diff --git a/lib/src/timer/microtasks/processNextTick.ts b/lib/src/timer/microtasks/processNextTick.ts index 90e30246..7c1582bb 100644 --- a/lib/src/timer/microtasks/processNextTick.ts +++ b/lib/src/timer/microtasks/processNextTick.ts @@ -10,8 +10,8 @@ import { isFunction } from "../../helpers/base"; import { createCachedValue, ICachedValue } from "../../helpers/cache"; import { getInst, isNode } from "../../helpers/environment"; import { _globalLazyTestHooks, _initTestHooks } from "../../helpers/lazy"; +import { ScheduleMicrotaskFn } from "../../helpers/types"; import { UNDEF_VALUE } from "../../internal/constants"; -import { ScheduleMicrotaskFn } from "../microtask"; import { _runMicroTask } from "./runMicrotask"; interface IProcessLike { diff --git a/lib/src/timer/microtasks/promiseQueue.ts b/lib/src/timer/microtasks/promiseQueue.ts index 9da39afc..3467109d 100644 --- a/lib/src/timer/microtasks/promiseQueue.ts +++ b/lib/src/timer/microtasks/promiseQueue.ts @@ -10,7 +10,7 @@ import { isFunction } from "../../helpers/base"; import { createCachedValue, ICachedValue } from "../../helpers/cache"; import { getInst } from "../../helpers/environment"; import { _globalLazyTestHooks, _initTestHooks } from "../../helpers/lazy"; -import { ScheduleMicrotaskFn } from "../microtask"; +import { ScheduleMicrotaskFn } from "../../helpers/types"; import { _addToQueue, _eTaskQueueType, _flushQueues } from "./taskQueue"; let _promiseCls: ICachedValue; diff --git a/lib/src/timer/microtasks/resolveScheduleFn.ts b/lib/src/timer/microtasks/resolveScheduleFn.ts index c75f4c82..dab2adb3 100644 --- a/lib/src/timer/microtasks/resolveScheduleFn.ts +++ b/lib/src/timer/microtasks/resolveScheduleFn.ts @@ -7,7 +7,7 @@ */ import { isStrictUndefined } from "../../helpers/base"; -import { ScheduleMicrotaskFn } from "../microtask"; +import { ScheduleMicrotaskFn } from "../../helpers/types"; import { _getPromiseQueueFn } from "./promiseQueue"; import { _eTaskQueueType } from "./taskQueue"; diff --git a/lib/src/timer/microtasks/runMicrotask.ts b/lib/src/timer/microtasks/runMicrotask.ts index 737e844b..ff69e10e 100644 --- a/lib/src/timer/microtasks/runMicrotask.ts +++ b/lib/src/timer/microtasks/runMicrotask.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import { MicrotaskFn } from "../microtask"; +import { MicrotaskFn } from "../../helpers/types"; import { scheduleTimeout } from "../timeout"; /** diff --git a/lib/src/timer/nextTick.ts b/lib/src/timer/nextTick.ts index 494d8d4e..24ecb382 100644 --- a/lib/src/timer/nextTick.ts +++ b/lib/src/timer/nextTick.ts @@ -7,16 +7,17 @@ */ import { isArray, isFunction, isStrictUndefined } from "../helpers/base"; -import { _getGlobalInstFn, getInst, isNode } from "../helpers/environment"; +import { _getGlobalInstFn, getInst, getQueueMicrotask, isNode } from "../helpers/environment"; import { ITimerHandler } from "./handler"; import { _createCancellableTask } from "./microtasks/cancellableTask"; -import { MicrotaskFn, ScheduleMicrotaskFn } from "./microtask"; import { _getProcessNextTickFn } from "./microtasks/processNextTick"; import { _resolveScheduleFn } from "./microtasks/resolveScheduleFn"; import { _addNextTickToQueue } from "./microtasks/timerQueue"; import { _eTaskQueueType } from "./microtasks/taskQueue"; import { fnBindArgs } from "../funcs/fnBindArgs"; import { UNDEF_VALUE } from "../internal/constants"; +import { MicrotaskFn, ScheduleMicrotaskFn } from "../helpers/types"; +import { _getMicrotaskQueueFn } from "./microtasks/microtaskQueue"; const _defaultMaxQueuedTasks = 1000; @@ -129,14 +130,17 @@ export function hasProcessNextTick(): boolean { } /** - * Schedules a callback to run using `process.nextTick` when available and otherwise uses an - * Promise-based fallback before using the timer-backed queue. - * - * When `scheduleFn` is provided, it is used before the Promise and timer-backed fallbacks. - * When `useTimeout` is `true`, the timer-backed queue is used instead of the Promise fallback. - * `maxQueuedTasks` controls the fallback queue depth limit; when omitted the Promise/timer fallback queues - * use a Node-compatible default of 1000. Set `maxQueuedTasks` to `0` to disable the limit (for fallbacks only, - * the native `process.nextTick` is not affected by this limit). + * Schedules a callback to run using `process.nextTick` when available and otherwise uses + * queueMicrotask, a Promise-based fallback or finally a timer-backed queue. + * + * When `scheduleFn` is provided, it is used before the Promise and timer-backed fallbacks, if + * queueMicrotask is available it is used before all fallbacks. + * When `useTimeout` is `true`, the timer-backed queue is used instead of the Promise fallback only + * when native `process.nextTick` and `queueMicrotask` are unavailable. + * `maxQueuedTasks` controls the fallback queue depth limit; when omitted the fallback queue uses + * the default limit (set via `setNextTickFallbackOptions`) or a Node-compatible default of 1000. + * Setting/passing `maxQueuedTasks` to `0` disables the limit (for fallbacks only, the native + * `process.nextTick` is not affected by this limit). * * @since 0.15.0 * @group Timer @@ -194,7 +198,7 @@ export function scheduleNextTick(callback: (...args: TArgs) } let taskCallback = callbackArgs ? fnBindArgs(callback, UNDEF_VALUE, callbackArgs) : (callback as MicrotaskFn); - let nextTickFn = _getProcessNextTickFn(); + let nextTickFn = _getProcessNextTickFn() || _getMicrotaskQueueFn(_eTaskQueueType.nextTick); let maxQueuedTasks = (theOptions && !isStrictUndefined(theOptions.maxQueuedTasks)) ? theOptions.maxQueuedTasks : ((_defaultOptions && !isStrictUndefined(_defaultOptions.maxQueuedTasks)) diff --git a/lib/test/src/common/timer/microtask.test.ts b/lib/test/src/common/timer/microtask.test.ts index 095d2905..39337568 100644 --- a/lib/test/src/common/timer/microtask.test.ts +++ b/lib/test/src/common/timer/microtask.test.ts @@ -8,13 +8,14 @@ import * as sinon from "sinon"; import { assert } from "@nevware21/tripwire-chai"; -import { getGlobal } from "../../../../src/helpers/environment"; +import { getGlobal, getQueueMicrotask, hasQueueMicrotask } from "../../../../src/helpers/environment"; import { setBypassLazyCache } from "../../../../src/helpers/lazy"; -import { getQueueMicrotask, hasQueueMicrotask, scheduleMicrotask, setMicroTaskFallbackOptions } from "../../../../src/timer/microtask"; +import { scheduleMicrotask, setMicroTaskFallbackOptions } from "../../../../src/timer/microtask"; +import { _getMicrotaskQueueFn } from "../../../../src/timer/microtasks/microtaskQueue"; import { _addMicrotaskQueue, _resetSharedTimer } from "../../../../src/timer/microtasks/timerQueue"; import { _runMicroTask } from "../../../../src/timer/microtasks/runMicrotask"; import { scheduleTimeout, setTimeoutOverrides } from "../../../../src/timer/timeout"; -import { _clearTaskQueues } from "../../../../src/timer/microtasks/taskQueue"; +import { _clearTaskQueues, _eTaskQueueType } from "../../../../src/timer/microtasks/taskQueue"; describe("microtask tests", () => { let orgPromise: any; @@ -564,4 +565,45 @@ describe("microtask tests", () => { }, 10); }); }); + + describe("internal microtaskQueue helper", () => { + it("reschedules when pending queueMicrotask implementation changes across queue types", () => { + let theGlobal: any = getGlobal(); + let firstSchedulerBatches: Array<() => void> = []; + let secondSchedulerBatches: Array<() => void> = []; + let events: string[] = []; + + theGlobal.queueMicrotask = (cb: () => void) => { + firstSchedulerBatches.push(cb); + }; + + let nextTickQueueFn = _getMicrotaskQueueFn(_eTaskQueueType.nextTick); + assert.isOk(nextTickQueueFn, "Expected nextTick queue helper to be returned"); + + nextTickQueueFn(() => { + events.push("nextTick"); + }); + + assert.equal(firstSchedulerBatches.length, 1, "Expected first scheduler to receive one batch callback"); + + theGlobal.queueMicrotask = (cb: () => void) => { + secondSchedulerBatches.push(cb); + }; + + let microtaskQueueFn = _getMicrotaskQueueFn(_eTaskQueueType.microtask); + assert.isOk(microtaskQueueFn, "Expected microtask queue helper to be returned"); + + microtaskQueueFn(() => { + events.push("microtask"); + }); + + assert.equal(secondSchedulerBatches.length, 1, "Expected second scheduler to receive a fresh batch callback"); + + secondSchedulerBatches[0](); + assert.deepEqual(events, ["nextTick", "microtask"], "Expected nextTick queue to flush before microtask queue"); + + firstSchedulerBatches[0](); + assert.deepEqual(events, ["nextTick", "microtask"], "Expected stale first scheduler callback to have no additional effect"); + }); + }); }); diff --git a/lib/test/src/common/timer/nextTick.test.ts b/lib/test/src/common/timer/nextTick.test.ts index c9e39786..95748dd6 100644 --- a/lib/test/src/common/timer/nextTick.test.ts +++ b/lib/test/src/common/timer/nextTick.test.ts @@ -11,6 +11,7 @@ import { getGlobal, isNode } from "../../../../src/helpers/environment"; import { setBypassLazyCache } from "../../../../src/helpers/lazy"; import { scheduleMicrotask, setMicroTaskFallbackOptions } from "../../../../src/timer/microtask"; import { getProcessNextTick, hasProcessNextTick, scheduleNextTick, setNextTickFallbackOptions } from "../../../../src/timer/nextTick"; +import { _resetMicrotaskQueueFns } from "../../../../src/timer/microtasks/microtaskQueue"; import { scheduleTimeout } from "../../../../src/timer/timeout"; import { _clearTaskQueues } from "../../../../src/timer/microtasks/taskQueue"; import { _resetSharedTimer } from "../../../../src/timer/microtasks/timerQueue"; @@ -34,6 +35,7 @@ describe("nextTick tests", () => { setBypassLazyCache(true); setMicroTaskFallbackOptions(); setNextTickFallbackOptions(); + _resetMicrotaskQueueFns(); theGlobal.process = orgProcess; theGlobal.Promise = orgPromise; theGlobal.queueMicrotask = orgQueueMicrotask; @@ -46,6 +48,7 @@ describe("nextTick tests", () => { theGlobal.queueMicrotask = orgQueueMicrotask; _clearTaskQueues(); _resetSharedTimer(); + _resetMicrotaskQueueFns(); setMicroTaskFallbackOptions(); setNextTickFallbackOptions(); setBypassLazyCache(false); @@ -229,6 +232,8 @@ describe("nextTick tests", () => { nextTick: null }; + theGlobal.queueMicrotask = null; + let handler = scheduleNextTick(() => { called++; }, { @@ -258,6 +263,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; let handler = scheduleNextTick((name: string, count: number) => { calledName = name; @@ -274,6 +280,39 @@ describe("nextTick tests", () => { }, 10); }); + it("supports callback arguments via queueMicrotask fallback", (done) => { + let theGlobal: any = getGlobal(); + let queueCalls = 0; + let calledName: string; + let calledCount: number; + + theGlobal.process = { + version: orgProcess && orgProcess.version, + versions: orgProcess && orgProcess.versions, + nextTick: null + }; + theGlobal.Promise = null; + theGlobal.queueMicrotask = (callback: () => void) => { + queueCalls++; + orgQueueMicrotask(callback); + }; + + let handler = scheduleNextTick((name: string, count: number) => { + calledName = name; + calledCount = count; + }, ["queueMicrotask", 6]); + + assert.equal(handler.enabled, true, "Check that the handler is running"); + + orgSetTimeout(() => { + assert.equal(queueCalls, 1, "Expected native queueMicrotask to be used once"); + assert.equal(calledName, "queueMicrotask", "Expected callback argument 'name' to be passed"); + assert.equal(calledCount, 6, "Expected callback argument 'count' to be passed"); + assert.equal(handler.enabled, false, "Check that the handler is stopped"); + done(); + }, 10); + }); + it("cancel prevents callback via Promise fallback", (done) => { let theGlobal: any = getGlobal(); let called = 0; @@ -283,6 +322,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; let handler = scheduleNextTick(() => { called++; @@ -298,6 +338,37 @@ describe("nextTick tests", () => { }, 10); }); + it("cancel prevents callback via queueMicrotask fallback", (done) => { + let theGlobal: any = getGlobal(); + let queueCalls = 0; + let called = 0; + + theGlobal.process = { + version: orgProcess && orgProcess.version, + versions: orgProcess && orgProcess.versions, + nextTick: null + }; + theGlobal.Promise = null; + theGlobal.queueMicrotask = (callback: () => void) => { + queueCalls++; + orgQueueMicrotask(callback); + }; + + let handler = scheduleNextTick(() => { + called++; + }); + + assert.equal(handler.enabled, true, "Check that the handler is running"); + handler.cancel(); + assert.equal(handler.enabled, false, "Check that the handler is stopped"); + + orgSetTimeout(() => { + assert.equal(queueCalls, 1, "Expected native queueMicrotask to be used once"); + assert.equal(called, 0, "Expected callback to not run after cancel"); + done(); + }, 10); + }); + it("refresh reschedules callback via Promise fallback", (done) => { let theGlobal: any = getGlobal(); let called = 0; @@ -307,6 +378,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; let handler = scheduleNextTick(() => { called++; @@ -323,6 +395,38 @@ describe("nextTick tests", () => { }, 10); }); + it("refresh reschedules callback via queueMicrotask fallback", (done) => { + let theGlobal: any = getGlobal(); + let queueCalls = 0; + let called = 0; + + theGlobal.process = { + version: orgProcess && orgProcess.version, + versions: orgProcess && orgProcess.versions, + nextTick: null + }; + theGlobal.Promise = null; + theGlobal.queueMicrotask = (callback: () => void) => { + queueCalls++; + orgQueueMicrotask(callback); + }; + + let handler = scheduleNextTick(() => { + called++; + }); + + assert.equal(handler.enabled, true, "Check that the handler is running"); + handler.refresh(); + assert.equal(handler.enabled, true, "Check that the handler is running"); + + orgSetTimeout(() => { + assert.isAtLeast(queueCalls, 1, "Expected native queueMicrotask to be used"); + assert.equal(called, 1, "Expected callback to run once after refresh"); + assert.equal(handler.enabled, false, "Check that the handler is stopped"); + done(); + }, 10); + }); + it("cancel prevents callback via timer-queue fallback", (done) => { let theGlobal: any = getGlobal(); let called = 0; @@ -332,6 +436,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; let handler = scheduleNextTick(() => { called++; @@ -358,6 +463,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; let handler = scheduleNextTick(() => { called++; @@ -387,6 +493,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; let handler = scheduleNextTick((name: string, count: number) => { calledName = name; @@ -419,6 +526,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; let handler = scheduleNextTick((name: string, count: number) => { calledName = name; @@ -446,6 +554,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; setNextTickFallbackOptions({ scheduleFn: (cb) => { @@ -613,6 +722,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; theGlobal.Promise = { resolve: () => ({ then: () => { @@ -657,6 +767,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; theGlobal.Promise = { resolve: () => ({ then: () => { @@ -698,6 +809,7 @@ describe("nextTick tests", () => { versions: orgProcess && orgProcess.versions, nextTick: null }; + theGlobal.queueMicrotask = null; // Simulate a Promise implementation that never resolves callbacks, leaving pending state stuck. theGlobal.Promise = { @@ -727,4 +839,229 @@ describe("nextTick tests", () => { } }, 10); }); + + it("executes nextTick before setTimeout when scheduled via Promise fallback - edge case", (done) => { + let theGlobal: any = getGlobal(); + let events: string[] = []; + + // Force Promise fallback (disable process.nextTick and queueMicrotask) + theGlobal.process = { + version: orgProcess && orgProcess.version, + versions: orgProcess && orgProcess.versions, + nextTick: null + }; + theGlobal.queueMicrotask = null; + + // Schedule direct setTimeout first - this gets queued as a macrotask + orgSetTimeout(() => { + events.push("setTimeout"); + }, 0); + + // Then schedule nextTick - this should run before setTimeout via Promise microtask + scheduleNextTick(() => { + events.push("nextTick"); + }); + + // Verify that nextTick runs before setTimeout when using Promise fallback + orgSetTimeout(() => { + try { + assert.deepEqual(events, ["nextTick", "setTimeout"], "Promise fallback: Expected nextTick (microtask) to execute before direct setTimeout (macrotask)"); + done(); + } catch (e) { + done(e as Error); + } + }, 10); + }); + + it("handles interleaved nextTick and setTimeout scheduling with Promise fallback", (done) => { + let theGlobal: any = getGlobal(); + let events: string[] = []; + + // Force Promise fallback + theGlobal.process = { + version: orgProcess && orgProcess.version, + versions: orgProcess && orgProcess.versions, + nextTick: null + }; + theGlobal.queueMicrotask = null; + + // Rapidly interleave scheduling + orgSetTimeout(() => { + events.push("timer-1"); + }, 0); + + scheduleNextTick(() => { + events.push("nextTick-1"); + }); + + orgSetTimeout(() => { + events.push("timer-2"); + }, 0); + + scheduleNextTick(() => { + events.push("nextTick-2"); + }); + + orgSetTimeout(() => { + events.push("timer-3"); + }, 0); + + scheduleNextTick(() => { + events.push("nextTick-3"); + }); + + // With Promise fallback: all nextTicks (microtasks) execute before any setTimeout (macrotasks) + orgSetTimeout(() => { + try { + assert.deepEqual(events, ["nextTick-1", "nextTick-2", "nextTick-3", "timer-1", "timer-2", "timer-3"], "Promise fallback: all microtasks (nextTick) execute before macrotasks (setTimeout)"); + done(); + } catch (e) { + done(e as Error); + } + }, 15); + }); + + it("executes queued nextTick callbacks through timer-queue fallback", (done) => { + let theGlobal: any = getGlobal(); + let events: string[] = []; + + // Force timer-queue fallback (disable all native microtask support) + theGlobal.process = { + version: orgProcess && orgProcess.version, + versions: orgProcess && orgProcess.versions, + nextTick: null + }; + theGlobal.queueMicrotask = null; + theGlobal.Promise = null; + + // Schedule nextTick callbacks using timer-queue fallback + scheduleNextTick(() => { + events.push("nextTick-1"); + }, { + useTimeout: true + }); + + scheduleNextTick(() => { + events.push("nextTick-2"); + }, { + useTimeout: true + }); + + scheduleTimeout(() => { + events.push("timer"); + }, 0); + + // Verify that nextTick callbacks execute and eventually complete before test ends + orgSetTimeout(() => { + try { + assert.isOk(events.includes("nextTick-1"), "Expected nextTick-1 to execute via timer-queue fallback"); + assert.isOk(events.includes("nextTick-2"), "Expected nextTick-2 to execute via timer-queue fallback"); + assert.isOk(events.includes("timer"), "Expected timer callback to execute"); + done(); + } catch (e) { + done(e as Error); + } + }, 20); + }); + + it("nextTick should fail to execute before native setTimeout with no promise or microtask support", (done) => { + let theGlobal: any = getGlobal(); + let events: string[] = []; + + // Force timer-queue fallback (disable all native microtask support) + // This simulates environments without Promise, queueMicrotask, or process.nextTick + theGlobal.process = { + version: orgProcess && orgProcess.version, + versions: orgProcess && orgProcess.versions, + nextTick: null + }; + theGlobal.queueMicrotask = null; + theGlobal.Promise = null; + + // User schedules their own setTimeout first + orgSetTimeout(() => { + events.push("user-setTimeout"); + }, 0); + + // Then user calls scheduleNextTick. + // In timer-queue fallback mode this is also timer-backed, so it may run after the user timer. + scheduleNextTick(() => { + events.push("scheduleNextTick"); + }, { + useTimeout: true + }); + + // Verify the execution order + orgSetTimeout(() => { + try { + // This intentionally documents the known limitation of the timer-queue fallback: + // direct user timers can run before scheduleNextTick when native microtask APIs are unavailable. + assert.notDeepEqual(events, ["scheduleNextTick", "user-setTimeout"], + "Known limitation: with timer-queue fallback, scheduleNextTick may execute after a directly scheduled native setTimeout."); + done(); + } catch (e) { + // If this starts failing, fallback ordering behavior changed and this limitation may have been improved. + done(e as Error); + } + }, 10); + }); + + it("scheduleNextTick should execute before direct setTimeout with defaults", (done) => { + let events: string[] = []; + + // User schedules their own setTimeout first + orgSetTimeout(() => { + events.push("user-setTimeout"); + }, 0); + + // Then user calls scheduleNextTick expecting it to run first + // This should hold true regardless of fallback implementation + scheduleNextTick(() => { + events.push("scheduleNextTick"); + }); + + // Verify the execution order + orgSetTimeout(() => { + try { + assert.deepEqual(events, ["scheduleNextTick", "user-setTimeout"], + "CRITICAL: scheduleNextTick must always execute before user's direct setTimeout. " + + "This ensures consistent microtask-like semantics across all environments and fallbacks."); + done(); + } catch (e) { + done(e as Error); + } + }, 10); + }); + + + it("No Promise support scheduleNextTick should still execute before direct setTimeout with defaults", (done) => { + let theGlobal: any = getGlobal(); + let events: string[] = []; + + // Disable Promise fallback to force timer-queue fallback, but scheduleNextTick should still execute before direct setTimeout + theGlobal.Promise = null; + + // User schedules their own setTimeout first + orgSetTimeout(() => { + events.push("user-setTimeout"); + }, 0); + + // Then user calls scheduleNextTick expecting it to run first + // This should hold true regardless of fallback implementation + scheduleNextTick(() => { + events.push("scheduleNextTick"); + }); + + // Verify the execution order + orgSetTimeout(() => { + try { + assert.deepEqual(events, ["scheduleNextTick", "user-setTimeout"], + "CRITICAL: scheduleNextTick must always execute before user's direct setTimeout. " + + "This ensures consistent microtask-like semantics across all environments and fallbacks."); + done(); + } catch (e) { + done(e as Error); + } + }, 10); + }); });