Skip to content

Commit

Permalink
feat: [#1451] Makes it possible to configure timer settings to improv…
Browse files Browse the repository at this point in the history
…e performance
  • Loading branch information
capricorn86 committed May 29, 2024
1 parent bacf3cc commit 9ad642c
Show file tree
Hide file tree
Showing 8 changed files with 114 additions and 35 deletions.
6 changes: 3 additions & 3 deletions packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -187,7 +187,7 @@ export default class AsyncTaskManager {
resolver();
}
}
}, 10);
});
}

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/happy-dom/src/browser/BrowserSettingsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export default class BrowserSettingsFactory {
...DefaultBrowserSettings.navigator,
...settings?.navigator
},
timer: {
...DefaultBrowserSettings.timer,
...settings?.timer
},
device: {
...DefaultBrowserSettings.device,
...settings?.device
Expand Down
5 changes: 5 additions & 0 deletions packages/happy-dom/src/browser/DefaultBrowserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export default <IBrowserSettings>{
disableErrorCapturing: false,
errorCapture: BrowserErrorCaptureEnum.tryAndCatch,
enableFileSystemHttpRequests: false,
timer: {
maxTimeout: -1,
maxIntervalTime: -1,
maxIntervalIterations: -1
},
navigation: {
disableMainFrameNavigation: false,
disableChildFrameNavigation: false,
Expand Down
7 changes: 7 additions & 0 deletions packages/happy-dom/src/browser/types/IBrowserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
12 changes: 0 additions & 12 deletions packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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) {
Expand Down
106 changes: 87 additions & 19 deletions packages/happy-dom/src/window/BrowserWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Timeout> | null = null;

/**
* Constructor.
Expand Down Expand Up @@ -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 <NodeJS.Timeout>(<unknown>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;
}
Expand All @@ -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(<Timeout>(<unknown>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')) {
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/happy-dom/test/window/DetachedWindowAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ describe('DetachedWindowAPI', () => {
expect(isFirstWhenAsyncCompleteCalled).toBe(true);
expect(isSecondWhenAsyncCompleteCalled).toBe(true);
resolve(null);
}, 50);
}, 10);
});
});
});
Expand Down

0 comments on commit 9ad642c

Please sign in to comment.