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 29, 2020
1 parent 7581dba commit 1ebb1fc
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 136 deletions.
238 changes: 144 additions & 94 deletions lib/jsdom/browser/Window.js
Expand Up @@ -118,9 +118,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 @@ -332,55 +329,157 @@ 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;

this.setTimeout = function (fn, ms) {
const args = [];
for (let i = 2; i < arguments.length; ++i) {
args[i - 2] = arguments[i];
this.setTimeout = function (handler, timeout = 0, ...args) {
if (typeof handler !== "function") {
handler = webIDLConversions.DOMString(handler);
}
return startTimer(window, setTimeout, clearTimeout, ++latestTimerId, fn, ms, timers, args);
timeout = webIDLConversions.long(timeout);

return timerInitializationSteps(handler, timeout, args, { methodContext: window, repeat: false });
};
this.setInterval = function (fn, ms) {
const args = [];
for (let i = 2; i < arguments.length; ++i) {
args[i - 2] = arguments[i];
this.setInterval = function (handler, timeout = 0, ...args) {
if (typeof handler !== "function") {
handler = webIDLConversions.DOMString(handler);
}
return startTimer(window, setInterval, clearInterval, ++latestTimerId, fn, ms, timers, args);
timeout = webIDLConversions.long(timeout);

return timerInitializationSteps(handler, timeout, args, { methodContext: window, repeat: true });
};
this.clearInterval = stopTimer.bind(this, timers);
this.clearTimeout = stopTimer.bind(this, timers);

this.clearTimeout = function (handle = 0) {
handle = webIDLConversions.long(handle);

const nodejsTimer = listOfActiveTimers.get(handle);
if (nodejsTimer) {
clearTimeout(nodejsTimer);
listOfActiveTimers.delete(handle);
}
};
this.clearInterval = function (handle = 0) {
handle = webIDLConversions.long(handle);

const nodejsTimer = listOfActiveTimers.get(handle);
if (nodejsTimer) {
// We use setTimeout() in timerInitializationSteps even for this.setInterval().
clearTimeout(nodejsTimer);
listOfActiveTimers.delete(handle);
}
};

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;
}

// TODO: implement timer nesting level behavior.

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

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

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

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

if (timeout < 0) {
timeout = 0;
}

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;

// Unlike the spec, where an animation frame happens every 60 Hz regardless, we optimize so that if there are no
// requestAnimationFrame() calls outstanding, we don't fire the timer. This helps us track that.
let numberOfOngoingAnimationFrameCallbacks = 0;

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);

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

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;
if (mapOfAnimationFrameCallbacks.has(handle)) {
--numberOfOngoingAnimationFrameCallbacks;
if (numberOfOngoingAnimationFrameCallbacks === 0) {
clearInterval(animationFrameNodejsInterval);
}
}

timers = Object.create(null);
animationFrameCallbacks = Object.create(null);
};
mapOfAnimationFrameCallbacks.delete(handle);
};

function runAnimationFrameCallbacks(now) {
// Converting to an array is important to get a sync snapshot and thus match spec semantics.
const callbackHandles = [...mapOfAnimationFrameCallbacks.keys()];
for (const handle of callbackHandles) {
// This has() can be false if a callback calls cancelAnimationFrame().
if (mapOfAnimationFrameCallbacks.has(handle)) {
const callback = mapOfAnimationFrameCallbacks.get(handle);
mapOfAnimationFrameCallbacks.delete(handle);
try {
callback(now);
} catch (e) {
reportException(window, e, window.location.href);
}
}
}
}
}

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 @@ -506,18 +605,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 @@ -540,7 +631,7 @@ function Window(options) {
delete this._document;
}

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

Expand Down Expand Up @@ -673,47 +764,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
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);
});
});
2 changes: 0 additions & 2 deletions test/web-platform-tests/to-run.yaml
Expand Up @@ -866,8 +866,6 @@ serializing-html-fragments/serializing.html: [fail, https://github.com/inikulin/

DIR: html/webappapis/animation-frames

same-dispatch-time.html: [fail, Probably https://github.com/jsdom/jsdom/pull/1994#discussion_r147567274]

---

DIR: html/webappapis/atob
Expand Down
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>requestAnimationFrame from inside requestAnimationFrame must be delayed, not sync</title>
<link rel="help" href="https://html.spec.whatwg.org/multipage/#dom-window-requestanimationframe">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>

<script>
"use strict";

async_test(t => {
let calledSync = false;
requestAnimationFrame(t.step_func(() => {
requestAnimationFrame(t.step_func(() => {
calledSync = true;
}));

// The failure mode we're testing is where the inner requestAnimationFrame call above inserts onto the queue,
// and the queue is drained completely this "run animation frame callbacks" cycle, instead of snapshotted.
// So, we need to run code after this outer requestAnimationFrame callback completes and the queue would have
// finished. setTimeout(, 0) is the best way to do that, under the assumption that 0 milliseconds is sooner than
// the next frame.
setTimeout(t.step_func_done(() => {
assert_false(calledSync);
}), 0);
}));
});
</script>

0 comments on commit 1ebb1fc

Please sign in to comment.