From 9ad642c5e258adb400b3e90f488ad76d51ea5ec0 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Thu, 30 May 2024 00:58:01 +0200 Subject: [PATCH] feat: [#1451] Makes it possible to configure timer settings to improve performance --- .../async-task-manager/AsyncTaskManager.ts | 6 +- .../src/browser/BrowserSettingsFactory.ts | 4 + .../src/browser/DefaultBrowserSettings.ts | 5 + .../src/browser/types/IBrowserSettings.ts | 7 ++ .../browser/types/IOptionalBrowserSettings.ts | 7 ++ .../browser/utilities/BrowserFrameFactory.ts | 12 -- .../happy-dom/src/window/BrowserWindow.ts | 106 ++++++++++++++---- .../test/window/DetachedWindowAPI.test.ts | 2 +- 8 files changed, 114 insertions(+), 35 deletions(-) diff --git a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts index faa54253a..e3a8c686d 100644 --- a/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts +++ b/packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts @@ -175,9 +175,9 @@ export default class AsyncTaskManager { this.waitUntilCompleteTimer = null; } - // In some cases, microtasks are used by transformed code and waitUntilComplete() is then resolved too early. + // 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. - // "10ms" is an arbitrary value, but it seem to be enough when performing many manual tests. + // @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) { @@ -187,7 +187,7 @@ export default class AsyncTaskManager { resolver(); } } - }, 10); + }); } /** 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 7400b8ee9..2fb4bd18e 100644 --- a/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts @@ -42,12 +42,6 @@ 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(() => { @@ -66,12 +60,6 @@ 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 50bdb0032..3aa2dc683 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. @@ -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,53 @@ 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. + 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 +1089,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 +1120,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; } 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); }); }); });