Skip to content

Commit

Permalink
Properly implement event handler properties/attributes
Browse files Browse the repository at this point in the history
This exposes getters/setters for all the on[event] properties on the prototype, thus closing #1354. It also slots them into the correct place in the attribute list upon setting, thus closing #696. Along the way, it generally makes all the event handler processing per-spec; we pass many more web platform tests in this area.
  • Loading branch information
Sebmaster authored and domenic committed May 21, 2017
1 parent 3eb19e7 commit 5f3cbd6
Show file tree
Hide file tree
Showing 15 changed files with 356 additions and 279 deletions.
8 changes: 8 additions & 0 deletions lib/jsdom/browser/Window.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const Navigator = require("../living/generated/Navigator");
const reportException = require("../living/helpers/runtime-script-errors");
const { contextifyWindow } = require("./documentfeatures.js");

const GlobalEventHandlersImpl = require("../living/nodes/GlobalEventHandlers-impl").implementation;
const WindowEventHandlersImpl = require("../living/nodes/WindowEventHandlers-impl").implementation;

// NB: the require() must be after assigning `module.exports` because this require() is circular
// TODO: this above note might not even be true anymore... figure out the cycle and document it, or clean up.
module.exports = Window;
Expand All @@ -38,6 +41,8 @@ dom.Window = Window;
function Window(options) {
EventTarget.setup(this);

this._initGlobalEvents();

const window = this;

///// INTERFACES FROM THE DOM
Expand Down Expand Up @@ -501,6 +506,9 @@ function Window(options) {
});
}

idlUtils.mixin(Window.prototype, GlobalEventHandlersImpl.prototype);
idlUtils.mixin(Window.prototype, WindowEventHandlersImpl.prototype);

Object.setPrototypeOf(Window, EventTarget.interface);
Object.setPrototypeOf(Window.prototype, EventTarget.interface.prototype);
Object.defineProperty(Window.prototype, Symbol.toStringTag, {
Expand Down
10 changes: 8 additions & 2 deletions lib/jsdom/living/events/EventTarget-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const reportException = require("../helpers/runtime-script-errors");
const domSymbolTree = require("../helpers/internal-constants").domSymbolTree;
const idlUtils = require("../generated/utils");

const GlobalEventHandlers = require("../generated/GlobalEventHandlers");
const Event = require("../generated/Event").interface;

class EventTargetImpl {
Expand Down Expand Up @@ -151,11 +152,16 @@ module.exports = {
};

function invokeInlineListeners(object, event) {
// Note: for Window, object is an EventTargetImpl
const wrapper = idlUtils.wrapperForImpl(object);
if ((wrapper.constructor.name === "Window" && wrapper._document) || GlobalEventHandlers.isImpl(object)) {
return; // elements have been upgraded to use a better path
}

const inlineListener = getListenerForInlineEventHandler(wrapper, event.type);
if (inlineListener) {
// Will be falsy for windows that have closed
const document = object._ownerDocument || (wrapper && (wrapper._document || wrapper._ownerDocument));
const document = object._ownerDocument;

const runScripts = document && document._defaultView && document._defaultView._runScripts === "dangerously";
if (!object.nodeName || runScripts) {
Expand All @@ -177,7 +183,7 @@ function invokeEventListeners(listeners, target, eventImpl) {

// workaround for events emitted on window (window-proxy)
// the wrapper is the root window instance, but we only want to expose the vm proxy at all times
if (wrapper._document) {
if (wrapper._document && wrapper.constructor.name === "Window") {
target = idlUtils.implForWrapper(wrapper._document)._defaultView;
}
eventImpl.currentTarget = target;
Expand Down
171 changes: 171 additions & 0 deletions lib/jsdom/living/helpers/create-event-accessor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"use strict";

const vm = require("vm");
const conversions = require("webidl-conversions");
const idlUtils = require("../generated/utils");
const ErrorEvent = require("../generated/ErrorEvent");
const reportException = require("./runtime-script-errors");

exports.appendHandler = function appendHandler(el, eventName) {
el.addEventListener(eventName, event => {
// https://html.spec.whatwg.org/#the-event-handler-processing-algorithm
event = idlUtils.implForWrapper(event);

const callback = el["on" + eventName];
if (callback === null) {
return;
}

const specialError = ErrorEvent.isImpl(event) && event.type === "error" &&
event.currentTarget.constructor.name === "Window";

let returnValue = null;
const thisValue = idlUtils.tryWrapperForImpl(event.currentTarget);
if (specialError) {
returnValue = callback.call(
thisValue, event.message,
event.filename, event.lineno, event.colno, event.error
);
} else {
const eventWrapper = idlUtils.wrapperForImpl(event);
returnValue = callback.call(thisValue, eventWrapper);
}

if (event.type === "beforeunload") { // TODO: we don't implement BeforeUnloadEvent so we can't brand-check here
// Perform conversion which in the spec is done by the event handler return type being DOMString?
returnValue = returnValue === undefined || returnValue === null ? null : conversions.DOMString(returnValue);

if (returnValue !== null) {
event._canceledFlag = true;
if (event.returnValue === "") {
event.returnValue = returnValue;
}
}
} else if (specialError) {
if (returnValue === true) {
event._canceledFlag = true;
}
} else if (returnValue === false) {
event._canceledFlag = true;
}
});
};

// https://html.spec.whatwg.org/#event-handler-idl-attributes
exports.createEventAccessor = function createEventAccessor(obj, event) {
Object.defineProperty(obj, "on" + event, {
configurable: true,
get() { // https://html.spec.whatwg.org/#getting-the-current-value-of-the-event-handler
const value = this._getEventHandlerFor(event);
if (!value) {
return null;
}

if (value.body !== undefined) {
let element;
let document;
if (this.constructor.name === "Window") {
element = null;
document = idlUtils.implForWrapper(this.document);
} else {
element = this;
document = element.ownerDocument;
}
const body = value.body;

const formOwner = element !== null && element.form ? element.form : null;
const window = this.constructor.name === "Window" && this._document ? this : document.defaultView;

try {
// eslint-disable-next-line no-new-func
Function(body); // properly error out on syntax errors
// Note: this won't execute body; that would require `Function(body)()`.
} catch (e) {
if (window) {
reportException(window, e);
}
this._setEventHandlerFor(event, null);
return null;
}

// Note: the with (window) { } is not necessary in Node, but is necessary in a browserified environment.

let fn;
const createFunction = vm.isContext(document._global) ? document.defaultView._globalProxy.Function : Function;
if (event === "error" && element === null) {
const wrapperBody = document ? body + `\n//# sourceURL=${document.URL}` : body;

// eslint-disable-next-line no-new-func
fn = createFunction("window", `with (window) { return function onerror(event, source, lineno, colno, error) {
${wrapperBody}
}; }`)(window);
} else {
const argNames = [];
const args = [];

argNames.push("window");
args.push(window);

if (element !== null) {
argNames.push("document");
args.push(idlUtils.wrapperForImpl(document));
}
if (formOwner !== null) {
argNames.push("formOwner");
args.push(idlUtils.wrapperForImpl(formOwner));
}
if (element !== null) {
argNames.push("element");
args.push(idlUtils.wrapperForImpl(element));
}
let wrapperBody = `
return function on${event}(event) {
${body}
};`;
for (let i = argNames.length - 1; i >= 0; --i) {
wrapperBody = `with (${argNames[i]}) { ${wrapperBody} }`;
}
if (document) {
wrapperBody += `\n//# sourceURL=${document.URL}`;
}
argNames.push(wrapperBody);
fn = createFunction(...argNames)(...args);
}

this._setEventHandlerFor(event, fn);
}
return this._getEventHandlerFor(event);
},
set(val) {
val = eventHandlerArgCoercion(val);
this._setEventHandlerFor(event, val);
}
});
};

function typeIsObject(v) {
return (typeof v === "object" && v !== null) || typeof v === "function";
}

// Implements:
// [TreatNonObjectAsNull]
// callback EventHandlerNonNull = any (Event event);
// typedef EventHandlerNonNull? EventHandler;
// Also implements the part of https://heycam.github.io/webidl/#es-invoking-callback-functions which treats
// non-callable callback functions as callback functions that return undefined.
// TODO: replace with webidl2js typechecking when it has sufficient callback support
function eventHandlerArgCoercion(val) {
if (!typeIsObject(val)) {
return null;
}

if (val === null || val === undefined) {
return null;
}

if (typeof val !== "function") {
return () => {};
}

return val;
}
11 changes: 0 additions & 11 deletions lib/jsdom/living/helpers/proxied-window-event-handlers.js

This file was deleted.

7 changes: 7 additions & 0 deletions lib/jsdom/living/nodes/Document-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const firstChildWithHTMLLocalNames = require("../helpers/traversal").firstChildW
const firstDescendantWithHTMLLocalName = require("../helpers/traversal").firstDescendantWithHTMLLocalName;
const whatwgURL = require("whatwg-url");
const domSymbolTree = require("../helpers/internal-constants").domSymbolTree;
const eventAccessors = require("../helpers/create-event-accessor");
const stripAndCollapseASCIIWhitespace = require("../helpers/strings").stripAndCollapseASCIIWhitespace;
const DOMException = require("../../web-idl/DOMException");
const HTMLToDOM = require("../../browser/htmltodom");
Expand All @@ -22,6 +23,8 @@ const validateName = require("../helpers/validate-names").name;
const validateAndExtract = require("../helpers/validate-names").validateAndExtract;
const resourceLoader = require("../../browser/resource-loader");

const GlobalEventHandlersImpl = require("./GlobalEventHandlers-impl").implementation;

const clone = require("../node").clone;
const generatedAttr = require("../generated/Attr");
const listOfElementsWithQualifiedName = require("../node").listOfElementsWithQualifiedName;
Expand Down Expand Up @@ -198,6 +201,8 @@ class DocumentImpl extends NodeImpl {
constructor(args, privateData) {
super(args, privateData);

this._initGlobalEvents();

this._ownerDocument = this;
this.nodeType = NODE_TYPE.DOCUMENT_NODE;
if (!privateData.options) {
Expand Down Expand Up @@ -794,6 +799,8 @@ class DocumentImpl extends NodeImpl {
}
}

eventAccessors.createEventAccessor(DocumentImpl.prototype, "readystatechange");
idlUtils.mixin(DocumentImpl.prototype, GlobalEventHandlersImpl.prototype);
idlUtils.mixin(DocumentImpl.prototype, ParentNodeImpl.prototype);

DocumentImpl._removingSteps = [];
Expand Down
71 changes: 0 additions & 71 deletions lib/jsdom/living/nodes/Element-impl.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"use strict";
const vm = require("vm");
const nwmatcher = require("nwmatcher/src/nwmatcher-noqsa");
const idlUtils = require("../generated/utils");
const NodeImpl = require("./Node-impl").implementation;
Expand All @@ -20,7 +19,6 @@ const validateNames = require("../helpers/validate-names");
const listOfElementsWithQualifiedName = require("../node").listOfElementsWithQualifiedName;
const listOfElementsWithNamespaceAndLocalName = require("../node").listOfElementsWithNamespaceAndLocalName;
const listOfElementsWithClassNames = require("../node").listOfElementsWithClassNames;
const proxiedWindowEventHandlers = require("../helpers/proxied-window-event-handlers");
const NonDocumentTypeChildNode = require("./NonDocumentTypeChildNode-impl").implementation;

// nwmatcher gets `document.documentElement` at creation-time, so we have to initialize lazily, since in the initial
Expand Down Expand Up @@ -129,75 +127,6 @@ class ElementImpl extends NodeImpl {
attachId(value, this, doc);
}

const w = this._ownerDocument._global;

// TODO event handlers:
// The correct way to do this is lazy, and a bit more complicated; see
// https://html.spec.whatwg.org/multipage/webappapis.html#event-handler-content-attributes
// It would only be possible if we had proper getters/setters for every event handler, which we don't right now.
if (name.length > 2 && name[0] === "o" && name[1] === "n") {
// If this document does not have a window, set IDL attribute to null
// step 2: https://html.spec.whatwg.org/multipage/webappapis.html#getting-the-current-value-of-the-event-handler
if (value && w) {
const self = proxiedWindowEventHandlers.has(name) && this._localName === "body" ? w : this;
const vmOptions = { filename: this._ownerDocument.URL, displayErrors: false };

// The handler code probably refers to functions declared globally on the window, so we need to run it in
// that context. In fact, it's worse; see
// https://code.google.com/p/chromium/codesearch#chromium/src/third_party/WebKit/Source/bindings/core/v8/V8LazyEventListener.cpp
// plus the spec, which show how multiple nested scopes are technically required. We won't implement that
// until someone asks for it, though.

// https://html.spec.whatwg.org/multipage/webappapis.html#the-event-handler-processing-algorithm

if (name === "onerror" && self === w) {
// https://html.spec.whatwg.org/multipage/webappapis.html#getting-the-current-value-of-the-event-handler
// step 10

self[name] = function (event, source, lineno, colno, error) {
w.__tempEventHandlerThis = this;
w.__tempEventHandlerEvent = event;
w.__tempEventHandlerSource = source;
w.__tempEventHandlerLineno = lineno;
w.__tempEventHandlerColno = colno;
w.__tempEventHandlerError = error;

try {
return vm.runInContext(`
(function (event, source, lineno, colno, error) {
${value}
}).call(__tempEventHandlerThis, __tempEventHandlerEvent, __tempEventHandlerSource,
__tempEventHandlerLineno, __tempEventHandlerColno, __tempEventHandlerError)`, w, vmOptions);
} finally {
delete w.__tempEventHandlerThis;
delete w.__tempEventHandlerEvent;
delete w.__tempEventHandlerSource;
delete w.__tempEventHandlerLineno;
delete w.__tempEventHandlerColno;
delete w.__tempEventHandlerError;
}
};
} else {
self[name] = function (event) {
w.__tempEventHandlerThis = this;
w.__tempEventHandlerEvent = event;

try {
return vm.runInContext(`
(function (event) {
${value}
}).call(__tempEventHandlerThis, __tempEventHandlerEvent)`, w, vmOptions);
} finally {
delete w.__tempEventHandlerThis;
delete w.__tempEventHandlerEvent;
}
};
}
} else {
this[name] = null;
}
}

// update classList
if (name === "class") {
resetDOMTokenList(this.classList, value);
Expand Down
Loading

0 comments on commit 5f3cbd6

Please sign in to comment.