diff --git a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts index e14997256..e3a8c686d 100644 --- a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts +++ b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts @@ -49,6 +49,10 @@ export default class AsyncTaskManager { * @param timerID Timer ID. */ public startTimer(timerID: NodeJS.Timeout): void { + if (this.waitUntilCompleteTimer) { + TIMER.clearTimeout(this.waitUntilCompleteTimer); + this.waitUntilCompleteTimer = null; + } this.runningTimers.push(timerID); } @@ -58,12 +62,16 @@ export default class AsyncTaskManager { * @param timerID Timer ID. */ public endTimer(timerID: NodeJS.Timeout): void { + if (this.waitUntilCompleteTimer) { + TIMER.clearTimeout(this.waitUntilCompleteTimer); + this.waitUntilCompleteTimer = null; + } const index = this.runningTimers.indexOf(timerID); if (index !== -1) { this.runningTimers.splice(index, 1); - if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { - this.resolveWhenComplete(); - } + } + if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { + this.resolveWhenComplete(); } } @@ -73,6 +81,10 @@ export default class AsyncTaskManager { * @param immediateID Immediate ID. */ public startImmediate(immediateID: NodeJS.Immediate): void { + if (this.waitUntilCompleteTimer) { + TIMER.clearTimeout(this.waitUntilCompleteTimer); + this.waitUntilCompleteTimer = null; + } this.runningImmediates.push(immediateID); } @@ -82,12 +94,16 @@ export default class AsyncTaskManager { * @param immediateID Immediate ID. */ public endImmediate(immediateID: NodeJS.Immediate): void { + if (this.waitUntilCompleteTimer) { + TIMER.clearTimeout(this.waitUntilCompleteTimer); + this.waitUntilCompleteTimer = null; + } const index = this.runningImmediates.indexOf(immediateID); if (index !== -1) { this.runningImmediates.splice(index, 1); - if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { - this.resolveWhenComplete(); - } + } + if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { + this.resolveWhenComplete(); } } @@ -98,6 +114,10 @@ export default class AsyncTaskManager { * @returns Task ID. */ public startTask(abortHandler?: () => void): number { + if (this.waitUntilCompleteTimer) { + TIMER.clearTimeout(this.waitUntilCompleteTimer); + this.waitUntilCompleteTimer = null; + } const taskID = this.newTaskID(); this.runningTasks[taskID] = abortHandler ? abortHandler : () => {}; this.runningTaskCount++; @@ -110,27 +130,16 @@ export default class AsyncTaskManager { * @param taskID Task ID. */ public endTask(taskID: number): void { + if (this.waitUntilCompleteTimer) { + TIMER.clearTimeout(this.waitUntilCompleteTimer); + this.waitUntilCompleteTimer = null; + } if (this.runningTasks[taskID]) { delete this.runningTasks[taskID]; this.runningTaskCount--; - if (this.waitUntilCompleteTimer) { - TIMER.clearTimeout(this.waitUntilCompleteTimer); - } - if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { - // In some cases, microtasks are used by transformed code and waitUntilComplete() is then resolved too early. - // To cater for this we use setTimeout() which has the lowest priority and will be executed last. - // "10ms" is an arbitrary value, but it seem to be enough when performing many manual tests. - this.waitUntilCompleteTimer = TIMER.setTimeout(() => { - this.waitUntilCompleteTimer = null; - if ( - !this.runningTaskCount && - !this.runningTimers.length && - !this.runningImmediates.length - ) { - this.resolveWhenComplete(); - } - }, 10); - } + } + if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { + this.resolveWhenComplete(); } } @@ -157,11 +166,28 @@ export default class AsyncTaskManager { * Resolves when complete. */ private resolveWhenComplete(): void { - const resolvers = this.waitUntilCompleteResolvers; - this.waitUntilCompleteResolvers = []; - for (const resolver of resolvers) { - resolver(); + if (this.runningTaskCount || this.runningTimers.length || this.runningImmediates.length) { + return; + } + + if (this.waitUntilCompleteTimer) { + TIMER.clearTimeout(this.waitUntilCompleteTimer); + this.waitUntilCompleteTimer = null; } + + // It is not possible to detect when all microtasks are complete (such as process.nextTick() or promises). + // To cater for this we use setTimeout() which has the lowest priority and will be executed last. + // @see https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick + this.waitUntilCompleteTimer = TIMER.setTimeout(() => { + this.waitUntilCompleteTimer = null; + if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) { + const resolvers = this.waitUntilCompleteResolvers; + this.waitUntilCompleteResolvers = []; + for (const resolver of resolvers) { + resolver(); + } + } + }); } /** diff --git a/packages/happy-dom/src/browser/BrowserSettingsFactory.ts b/packages/happy-dom/src/browser/BrowserSettingsFactory.ts index ce6a1b516..0c5b3a1ee 100644 --- a/packages/happy-dom/src/browser/BrowserSettingsFactory.ts +++ b/packages/happy-dom/src/browser/BrowserSettingsFactory.ts @@ -25,6 +25,10 @@ export default class BrowserSettingsFactory { ...DefaultBrowserSettings.navigator, ...settings?.navigator }, + timer: { + ...DefaultBrowserSettings.timer, + ...settings?.timer + }, device: { ...DefaultBrowserSettings.device, ...settings?.device diff --git a/packages/happy-dom/src/browser/DefaultBrowserSettings.ts b/packages/happy-dom/src/browser/DefaultBrowserSettings.ts index 808d3f347..02a372ad4 100644 --- a/packages/happy-dom/src/browser/DefaultBrowserSettings.ts +++ b/packages/happy-dom/src/browser/DefaultBrowserSettings.ts @@ -12,6 +12,11 @@ export default { disableErrorCapturing: false, errorCapture: BrowserErrorCaptureEnum.tryAndCatch, enableFileSystemHttpRequests: false, + timer: { + maxTimeout: -1, + maxIntervalTime: -1, + maxIntervalIterations: -1 + }, navigation: { disableMainFrameNavigation: false, disableChildFrameNavigation: false, diff --git a/packages/happy-dom/src/browser/types/IBrowserSettings.ts b/packages/happy-dom/src/browser/types/IBrowserSettings.ts index 2c0105bb5..7d5ddf2c1 100644 --- a/packages/happy-dom/src/browser/types/IBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IBrowserSettings.ts @@ -20,6 +20,13 @@ export default interface IBrowserSettings { /** Handle disabled resource loading as success */ handleDisabledFileLoadingAsSuccess: boolean; + /** Settings for timers */ + timer: { + maxTimeout: number; + maxIntervalTime: number; + maxIntervalIterations: number; + }; + /** * Disables error capturing. * diff --git a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts index b7ae0856e..33d314eea 100644 --- a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts @@ -17,6 +17,13 @@ export default interface IOptionalBrowserSettings { /** Handle disabled file loading as success */ handleDisabledFileLoadingAsSuccess?: boolean; + /** Settings for timers */ + timer?: { + maxTimeout?: number; + maxIntervalTime?: number; + maxIntervalIterations?: number; + }; + /** * Disables error capturing. * diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts index 2fb4bd18e..7400b8ee9 100644 --- a/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts @@ -42,6 +42,12 @@ export default class BrowserFrameFactory { } if (!frame.childFrames.length) { + if (frame.window && frame.window[PropertySymbol.mutationObservers]) { + for (const mutationObserver of frame.window[PropertySymbol.mutationObservers]) { + mutationObserver.disconnect(); + } + frame.window[PropertySymbol.mutationObservers] = []; + } return frame[PropertySymbol.asyncTaskManager] .destroy() .then(() => { @@ -60,6 +66,12 @@ export default class BrowserFrameFactory { Promise.all(frame.childFrames.slice().map((childFrame) => this.destroyFrame(childFrame))) .then(() => { + if (frame.window && frame.window[PropertySymbol.mutationObservers]) { + for (const mutationObserver of frame.window[PropertySymbol.mutationObservers]) { + mutationObserver.disconnect(); + } + frame.window[PropertySymbol.mutationObservers] = []; + } return frame[PropertySymbol.asyncTaskManager].destroy().then(() => { frame[PropertySymbol.exceptionObserver]?.disconnect(); if (frame.window) { diff --git a/packages/happy-dom/src/window/BrowserWindow.ts b/packages/happy-dom/src/window/BrowserWindow.ts index 8dc923d63..ede934cc1 100644 --- a/packages/happy-dom/src/window/BrowserWindow.ts +++ b/packages/happy-dom/src/window/BrowserWindow.ts @@ -165,6 +165,19 @@ const TIMER = { clearImmediate: globalThis.clearImmediate.bind(globalThis) }; const IS_NODE_JS_TIMEOUT_ENVIRONMENT = setTimeout.toString().includes('new Timeout'); +/** + * Zero Timeout. + */ +class Timeout { + public callback: () => void; + /** + * Constructor. + * @param callback Callback. + */ + constructor(callback: () => void) { + this.callback = callback; + } +} /** * Browser window. @@ -494,7 +507,7 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal // Used for tracking capture event listeners to improve performance when they are not used. // See EventTarget class. public [PropertySymbol.captureEventListenerCount]: { [eventType: string]: number } = {}; - public readonly [PropertySymbol.mutationObservers]: MutationObserver[] = []; + public [PropertySymbol.mutationObservers]: MutationObserver[] = []; public readonly [PropertySymbol.readyStateManager] = new DocumentReadyStateManager(this); public [PropertySymbol.asyncTaskManager]: AsyncTaskManager | null = null; public [PropertySymbol.location]: Location; @@ -511,6 +524,7 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal #outerWidth: number | null = null; #outerHeight: number | null = null; #devicePixelRatio: number | null = null; + #zeroTimeouts: Array | null = null; /** * Constructor. @@ -1018,19 +1032,55 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal * @returns Timeout ID. */ public setTimeout(callback: Function, delay = 0, ...args: unknown[]): NodeJS.Timeout { + // We can group timeouts with a delay of 0 into one timeout to improve performance. + // Grouping timeouts will also improve the performance of the async task manager. + // It may also make the async task manager to stable as many timeouts may cause waitUntilComplete() to be resolved to early. + if (!delay) { + if (!this.#zeroTimeouts) { + const settings = this.#browserFrame.page?.context?.browser?.settings; + const useTryCatch = + !settings || + !settings.disableErrorCapturing || + settings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch; + const id = TIMER.setTimeout(() => { + const zeroTimeouts = this.#zeroTimeouts; + this.#zeroTimeouts = null; + for (const zeroTimeout of zeroTimeouts) { + if (useTryCatch) { + WindowErrorUtility.captureError(this, () => zeroTimeout.callback()); + } else { + zeroTimeout.callback(); + } + } + this.#browserFrame[PropertySymbol.asyncTaskManager].endTimer(id); + }); + this.#zeroTimeouts = []; + this.#browserFrame[PropertySymbol.asyncTaskManager].startTimer(id); + } + const zeroTimeout = new Timeout(() => callback(...args)); + this.#zeroTimeouts.push(zeroTimeout); + return (zeroTimeout); + } + const settings = this.#browserFrame.page?.context?.browser?.settings; const useTryCatch = !settings || !settings.disableErrorCapturing || settings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch; - const id = TIMER.setTimeout(() => { - if (useTryCatch) { - WindowErrorUtility.captureError(this, () => callback(...args)); - } else { - callback(...args); - } - this.#browserFrame[PropertySymbol.asyncTaskManager].endTimer(id); - }, delay); + + const id = TIMER.setTimeout( + () => { + if (useTryCatch) { + WindowErrorUtility.captureError(this, () => callback(...args)); + } else { + callback(...args); + } + this.#browserFrame[PropertySymbol.asyncTaskManager].endTimer(id); + }, + settings?.timer.maxTimeout !== -1 && delay && delay > settings?.timer.maxTimeout + ? settings?.timer.maxTimeout + : delay + ); this.#browserFrame[PropertySymbol.asyncTaskManager].startTimer(id); return id; } @@ -1041,6 +1091,14 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal * @param id ID of the timeout. */ public clearTimeout(id: NodeJS.Timeout): void { + if (id && id instanceof Timeout) { + const zeroTimeouts = this.#zeroTimeouts || []; + const index = zeroTimeouts.indexOf((id)); + if (index !== -1) { + zeroTimeouts.splice(index, 1); + } + return; + } // We need to make sure that the ID is a Timeout object, otherwise Node.js might throw an error. // This is only necessary if we are in a Node.js environment. if (IS_NODE_JS_TIMEOUT_ENVIRONMENT && (!id || id.constructor.name !== 'Timeout')) { @@ -1064,17 +1122,29 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal !settings || !settings.disableErrorCapturing || settings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch; - const id = TIMER.setInterval(() => { - if (useTryCatch) { - WindowErrorUtility.captureError( - this, - () => callback(...args), - () => this.clearInterval(id) - ); - } else { - callback(...args); - } - }, delay); + let iterations = 0; + const id = TIMER.setInterval( + () => { + if (useTryCatch) { + WindowErrorUtility.captureError( + this, + () => callback(...args), + () => this.clearInterval(id) + ); + } else { + callback(...args); + } + if (settings?.timer.maxIntervalIterations !== -1) { + if (iterations >= settings?.timer.maxIntervalIterations) { + this.clearInterval(id); + } + iterations++; + } + }, + settings?.timer.maxIntervalTime !== -1 && delay && delay > settings?.timer.maxIntervalTime + ? settings?.timer.maxIntervalTime + : delay + ); this.#browserFrame[PropertySymbol.asyncTaskManager].startTimer(id); return id; } @@ -1319,6 +1389,8 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal mutationObserver.disconnect(); } + this[PropertySymbol.mutationObservers] = []; + // Disconnects nodes from the document, so that they can be garbage collected. for (const node of this.document[PropertySymbol.childNodes].slice()) { // Makes sure that something won't be triggered by the disconnect. diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts index 97c67d4cc..0a726b051 100644 --- a/packages/happy-dom/test/nodes/document/Document.test.ts +++ b/packages/happy-dom/test/nodes/document/Document.test.ts @@ -1255,7 +1255,7 @@ describe('Document', () => { expect((readyChangeEvent).target).toBe(document); expect(document.readyState).toBe(DocumentReadyStateEnum.complete); resolve(null); - }, 1); + }, 20); }); }); diff --git a/packages/happy-dom/test/window/DetachedWindowAPI.test.ts b/packages/happy-dom/test/window/DetachedWindowAPI.test.ts index ab36aaaf1..32520e273 100644 --- a/packages/happy-dom/test/window/DetachedWindowAPI.test.ts +++ b/packages/happy-dom/test/window/DetachedWindowAPI.test.ts @@ -271,7 +271,7 @@ describe('DetachedWindowAPI', () => { expect(isFirstWhenAsyncCompleteCalled).toBe(true); expect(isSecondWhenAsyncCompleteCalled).toBe(true); resolve(null); - }, 50); + }, 10); }); }); });