Skip to content

Commit

Permalink
Re-do animation frame and timer callbacks
Browse files Browse the repository at this point in the history
This setup follows the spec more, is a lot simpler to follow, fixes #2800, and fixes #2617.
  • Loading branch information
domenic committed Jan 18, 2020
1 parent e2dbad8 commit 574e4ca
Show file tree
Hide file tree
Showing 2 changed files with 157 additions and 96 deletions.
223 changes: 129 additions & 94 deletions lib/jsdom/browser/Window.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,6 @@ function Window(options) {
this._globalProxy = this;
Object.defineProperty(idlUtils.implForWrapper(this), idlUtils.wrapperSymbol, { get: () => this._globalProxy });

let timers = Object.create(null);
let animationFrameCallbacks = Object.create(null);

// List options explicitly to be clear which are passed through
this._document = Document.create(window, [], {
options: {
Expand Down Expand Up @@ -330,55 +327,142 @@ function Window(options) {

///// METHODS

// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers

// In the spec the list of active timers is a set of IDs. We make it a map of IDs to Node.js timer objects, so that
// we can call Node.js-side clearTimeout() when clearing, and thus allow process shutdown faster.
const listOfActiveTimers = new Map();
let latestTimerId = 0;
let latestAnimationFrameCallbackId = 0;
let timerNestingLevel = 0;

this.setTimeout = function (handler, timeout = 0, ...args) {
if (typeof handler !== "function") {
handler = webIDLConversions.DOMString(handler);
}
timeout = webIDLConversions.long(timeout);

return timerInitializationSteps(handler, timeout, args, { methodContext: window, repeat: false });
};
this.setInterval = function (handler, timeout = 0, ...args) {
if (typeof handler !== "function") {
handler = webIDLConversions.DOMString(handler);
}
timeout = webIDLConversions.long(timeout);

return timerInitializationSteps(handler, timeout, args, { methodContext: window, repeat: true });
};

this.setTimeout = function (fn, ms) {
const args = [];
for (let i = 2; i < arguments.length; ++i) {
args[i - 2] = arguments[i];
this.clearTimeout = function (handle) {
const nodejsTimer = listOfActiveTimers.get(handle);
if (nodejsTimer) {
clearTimeout(nodejsTimer);
listOfActiveTimers.delete(handle);
}
return startTimer(window, setTimeout, clearTimeout, ++latestTimerId, fn, ms, timers, args);
};
this.setInterval = function (fn, ms) {
const args = [];
for (let i = 2; i < arguments.length; ++i) {
args[i - 2] = arguments[i];
this.clearInterval = function (handle) {
const nodejsTimer = listOfActiveTimers.get(handle);
if (nodejsTimer) {
// We use setTimeout() in timerInitializationSteps even for this.setInterval().
clearTimeout(nodejsTimer);
listOfActiveTimers.delete(handle);
}
return startTimer(window, setInterval, clearInterval, ++latestTimerId, fn, ms, timers, args);
};
this.clearInterval = stopTimer.bind(this, timers);
this.clearTimeout = stopTimer.bind(this, timers);

function timerInitializationSteps(handler, timeout, args, { methodContext, repeat, previousHandle }) {
// This appears to be unspecced, but matches browser behavior for close()ed windows.
if (!methodContext._document) {
return 0;
}

const methodContextProxy = methodContext._globalProxy;
const handle = previousHandle !== undefined ? previousHandle : ++latestTimerId;

function task() {
if (!listOfActiveTimers.has(handle)) {
return;
}

++timerNestingLevel;

try {
if (typeof handler === "function") {
handler.apply(methodContextProxy, args);
} else if (window._runScripts === "") {
vm.runInContext(handler, window, { filename: window.location.href, displayErrors: false });
}
} catch (e) {
reportException(window, e, window.location.href);
}

--timerNestingLevel;

if (repeat) {
timerInitializationSteps(handler, timeout, args, { methodContext, repeat: true, previousHandle: handle });
}
}

if (timeout < 0) {
timeout = 0;
}
if (timerNestingLevel > 5 && timeout < 4) {
timeout = 4;
}

const nodejsTimer = setTimeout(task, timeout);
listOfActiveTimers.set(handle, nodejsTimer);

return handle;
}

// https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#animation-frames

let animationFrameCallbackId = 0;
const mapOfAnimationFrameCallbacks = new Map();
let animationFrameNodejsInterval = null;

if (this._pretendToBeVisual) {
this.requestAnimationFrame = fn => {
const timestamp = rawPerformance.now() - windowInitialized;
const fps = 1000 / 60;

return startTimer(
window,
setTimeout,
clearTimeout,
++latestAnimationFrameCallbackId,
fn,
fps,
animationFrameCallbacks,
[timestamp]
);
this.requestAnimationFrame = function (callback) {
callback = webIDLConversions.Function(callback);

const handle = ++animationFrameCallbackId;
mapOfAnimationFrameCallbacks.set(handle, callback);
return handle;
};
this.cancelAnimationFrame = stopTimer.bind(this, animationFrameCallbacks);
}

this.__stopAllTimers = function () {
stopAllTimers(timers);
stopAllTimers(animationFrameCallbacks);
this.cancelAnimationFrame = function (handle) {
handle = webIDLConversions["unsigned long"](handle);

latestTimerId = 0;
latestAnimationFrameCallbackId = 0;
mapOfAnimationFrameCallbacks.delete(handle);
};

timers = Object.create(null);
animationFrameCallbacks = Object.create(null);
};
function runAnimationFrameCallbacks(now) {
const callbackHandles = mapOfAnimationFrameCallbacks.keys();
for (const handle of callbackHandles) {
if (mapOfAnimationFrameCallbacks.has(handle)) {
const callback = mapOfAnimationFrameCallbacks.get(handle);
mapOfAnimationFrameCallbacks.delete(handle);
try {
callback(now);
} catch (e) {
reportException(window, e, window.location.href);
}
}
}
}

animationFrameNodejsInterval = setInterval(() => {
runAnimationFrameCallbacks(rawPerformance.now() - windowInitialized);
}, 1000 / 60);
}

function stopAllTimers() {
for (const nodejsTimer of listOfActiveTimers.values()) {
clearTimeout(nodejsTimer);
}
listOfActiveTimers.clear();

clearInterval(animationFrameNodejsInterval);
}

function Option(text, value, defaultSelected, selected) {
if (text === undefined) {
Expand Down Expand Up @@ -504,18 +588,10 @@ function Window(options) {
};

this.close = function () {
// Recursively close child frame windows, then ourselves.
const currentWindow = this;
(function windowCleaner(windowToClean) {
for (let i = 0; i < windowToClean.length; i++) {
windowCleaner(windowToClean[i]);
}

// We"re already in our own window.close().
if (windowToClean !== currentWindow) {
windowToClean.close();
}
}(this));
// Recursively close child frame windows, then ourselves (depth-first).
for (let i = 0; i < this.length; ++i) {
this[i].close();
}

// Clear out all listeners. Any in-flight or upcoming events should not get delivered.
idlUtils.implForWrapper(this)._eventListeners = Object.create(null);
Expand All @@ -538,7 +614,7 @@ function Window(options) {
delete this._document;
}

this.__stopAllTimers();
stopAllTimers();
WebSocketImpl.cleanUpWindow(this);
};

Expand Down Expand Up @@ -671,47 +747,6 @@ function Window(options) {
});
}

function startTimer(window, startFn, stopFn, timerId, callback, ms, timerStorage, args) {
if (!window || !window._document) {
return undefined;
}
if (typeof callback !== "function") {
const code = String(callback);
callback = window._globalProxy.eval.bind(window, code + `\n//# sourceURL=${window.location.href}`);
}

const oldCallback = callback;
callback = () => {
try {
oldCallback.apply(window._globalProxy, args);
} catch (e) {
reportException(window, e, window.location.href);
}
delete timerStorage[timerId];
};

const res = startFn(callback, ms);
timerStorage[timerId] = [res, stopFn];
return timerId;
}

function stopTimer(timerStorage, id) {
const timer = timerStorage[id];
if (timer) {
// Need to .call() with undefined to ensure the thisArg is not timer itself
timer[1].call(undefined, timer[0]);
delete timerStorage[id];
}
}

function stopAllTimers(timers) {
Object.keys(timers).forEach(key => {
const timer = timers[key];
// Need to .call() with undefined to ensure the thisArg is not timer itself
timer[1].call(undefined, timer[0]);
});
}

function contextifyWindow(window) {
if (vm.isContext(window)) {
return;
Expand Down
30 changes: 28 additions & 2 deletions test/api/from-outside.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,41 @@
const { assert } = require("chai");
const { describe, it } = require("mocha-sugar-free");
const { JSDOM } = require("../..");
const { delay } = require("../util");

describe("Test cases only possible to test from the outside", () => {
it("should not register timer after window.close() called", () => {
it("window.close() should prevent timers from registering and cause them to return 0", async () => {
const { window } = new JSDOM();

assert.notEqual(window.setTimeout(() => {}, 100), undefined);

window.close();

assert.equal(window.setTimeout(() => {}), undefined);
let ran = false;
assert.equal(window.setTimeout(() => {
ran = true;
}), 0);

await delay(10);

assert.equal(ran, false);
});

it("window.close() should stop a setInterval()", async () => {
const { window } = new JSDOM(`<script>
window.counter = 0;
setInterval(() => window.counter++, 10);
</script>`, { runScripts: "dangerously" });

await delay(55);
window.close();

// We can't assert it's equal to 5, because the event loop might have been busy and not fully executed all 5.
assert.isAtLeast(window.counter, 1);
const counterBeforeSecondDelay = window.counter;

await delay(50);

assert.equal(window.counter, counterBeforeSecondDelay);
});
});

0 comments on commit 574e4ca

Please sign in to comment.