Skip to content

Commit

Permalink
Merge pull request #1452 from capricorn86/1451-make-it-possible-to-co…
Browse files Browse the repository at this point in the history
…nfigure-waituntilcomplete

feat: [#1451] Configure timer settings
  • Loading branch information
capricorn86 authored May 29, 2024
2 parents fa03438 + e6e52eb commit ef2dfea
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 50 deletions.
82 changes: 54 additions & 28 deletions packages/happy-dom/src/async-task-manager/AsyncTaskManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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();
}
}

Expand All @@ -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);
}

Expand All @@ -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();
}
}

Expand All @@ -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++;
Expand All @@ -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();
}
}

Expand All @@ -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();
}
}
});
}

/**
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: 12 additions & 0 deletions packages/happy-dom/src/browser/utilities/BrowserFrameFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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) {
Expand Down
112 changes: 92 additions & 20 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 @@ -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;
Expand All @@ -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,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 <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 +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(<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 +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;
}
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/happy-dom/test/nodes/document/Document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1255,7 +1255,7 @@ describe('Document', () => {
expect((<Event>readyChangeEvent).target).toBe(document);
expect(document.readyState).toBe(DocumentReadyStateEnum.complete);
resolve(null);
}, 1);
}, 20);
});
});

Expand Down
Loading

0 comments on commit ef2dfea

Please sign in to comment.