Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom element support #2548

Merged
merged 5 commits into from Feb 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 4 additions & 4 deletions lib/api.js
Expand Up @@ -10,7 +10,7 @@ const { URL } = require("whatwg-url");
const MIMEType = require("whatwg-mimetype");
const idlUtils = require("./jsdom/living/generated/utils.js");
const VirtualConsole = require("./jsdom/virtual-console.js");
const Window = require("./jsdom/browser/Window.js");
const { createWindow } = require("./jsdom/browser/Window.js");
const { parseIntoDocument } = require("./jsdom/browser/parser");
const { fragmentSerialization } = require("./jsdom/living/domparsing/serialization.js");
const ResourceLoader = require("./jsdom/browser/resources/resource-loader.js");
Expand All @@ -33,7 +33,7 @@ class JSDOM {

options = transformOptions(options, encoding, mimeType);

this[window] = new Window(options.windowOptions);
this[window] = createWindow(options.windowOptions);

const documentImpl = idlUtils.implForWrapper(this[window]._document);

Expand All @@ -45,8 +45,8 @@ class JSDOM {
}

get window() {
// It's important to grab the global proxy, instead of just the result of `new Window(...)`, since otherwise things
// like `window.eval` don't exist.
// It's important to grab the global proxy, instead of just the result of `createWindow(...)`, since otherwise
// things like `window.eval` don't exist.
return this[window]._globalProxy;
}

Expand Down
13 changes: 9 additions & 4 deletions lib/jsdom/browser/Window.js
Expand Up @@ -4,6 +4,7 @@ const webIDLConversions = require("webidl-conversions");
const { CSSStyleDeclaration } = require("cssstyle");
const { Performance: RawPerformance } = require("w3c-hr-time");
const notImplemented = require("./not-implemented");
const { installInterfaces } = require("../living/interfaces");
const { define, mixin } = require("../utils");
const Element = require("../living/generated/Element");
const EventTarget = require("../living/generated/EventTarget");
Expand All @@ -27,15 +28,15 @@ const { fireAnEvent } = require("../living/helpers/events");
const SessionHistory = require("../living/window/SessionHistory");
const { forEachMatchingSheetRuleOfElement, getResolvedValue, propertiesWithResolvedValueImplemented } =
require("../living/helpers/style-rules");
const CustomElementRegistry = require("../living/generated/CustomElementRegistry");
const jsGlobals = require("./js-globals.json");

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;
const { installInterfaces } = require("../living/interfaces");
exports.createWindow = function (options) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary to avoid newly introduced circular references.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to add this comment from #2771:

Suggested change
exports.createWindow = function (options) {
// We can't re-assign `module.exports`, as there's the following cycle:
// /lib/jsdom/browser/Window
// -> /lib/jsdom/living/interfaces
// -> /lib/jsdom/living/generated/HTMLFrameElement
// -> /lib/jsdom/living/nodes/HTMLFrameElement-impl
// -> /lib/jsdom/browser/Window
exports.createWindow = function (options) {

return new Window(options);
};

const jsGlobalEntriesToInstall = Object.entries(jsGlobals).filter(([name]) => name in global);

Expand Down Expand Up @@ -223,6 +224,7 @@ function Window(options) {
const navigator = Navigator.create(window, [], { userAgent: this._resourceLoader._userAgent });
const performance = Performance.create(window, [], { rawPerformance });
const screen = Screen.create(window);
const customElementRegistry = CustomElementRegistry.create(window);

define(this, {
get length() {
Expand Down Expand Up @@ -316,6 +318,9 @@ function Window(options) {
}

return this._sessionStorage;
},
get customElements() {
return customElementRegistry;
}
});

Expand Down
57 changes: 48 additions & 9 deletions lib/jsdom/browser/parser/html.js
Expand Up @@ -2,6 +2,8 @@

const parse5 = require("parse5");

const { createElement } = require("../../living/helpers/create-element");

const DocumentType = require("../../living/generated/DocumentType");
const DocumentFragment = require("../../living/generated/DocumentFragment");
const Text = require("../../living/generated/Text");
Expand All @@ -11,6 +13,9 @@ const attributes = require("../../living/attributes");
const nodeTypes = require("../../living/node-type");

const serializationAdapter = require("../../living/domparsing/parse5-adapter-serialization");
const {
customElementReactionsStack, invokeCEReactions, lookupCEDefinition
} = require("../../living/helpers/custom-elements");

// Horrible monkey-patch to implement https://github.com/inikulin/parse5/issues/237 and
// https://github.com/inikulin/parse5/issues/285.
Expand Down Expand Up @@ -40,19 +45,27 @@ OpenElementStack.prototype.pop = function (...args) {
};

class JSDOMParse5Adapter {
constructor(documentImpl) {
constructor(documentImpl, options = {}) {
this._documentImpl = documentImpl;
this._globalObject = documentImpl._globalObject;
this._fragment = options.fragment || false;

// Since the createElement hook doesn't provide the parent element, we keep track of this using _currentElement:
// https://github.com/inikulin/parse5/issues/285. See above horrible monkey-patch for how this is maintained.
this._currentElement = undefined;
}

_ownerDocument() {
// The _currentElement is undefined when parsing elements at the root of the document. In this case we would
// fallback to the global _documentImpl.
return this._currentElement ? this._currentElement._ownerDocument : this._documentImpl;
const { _currentElement } = this;

// The _currentElement is undefined when parsing elements at the root of the document.
if (_currentElement) {
return _currentElement.localName === "template" ?
_currentElement.content._ownerDocument :
_currentElement._ownerDocument;
}

return this._documentImpl;
}

createDocument() {
Expand All @@ -64,16 +77,38 @@ class JSDOMParse5Adapter {
}

createDocumentFragment() {
return DocumentFragment.createImpl(this._globalObject, [], { ownerDocument: this._currentElement._ownerDocument });
const ownerDocument = this._ownerDocument();
return DocumentFragment.createImpl(this._globalObject, [], { ownerDocument });
}

// https://html.spec.whatwg.org/#create-an-element-for-the-token
createElement(localName, namespace, attrs) {
const ownerDocument = this._ownerDocument();

const element = ownerDocument._createElementWithCorrectElementInterface(localName, namespace);
element._namespaceURI = namespace;
const isAttribute = attrs.find(attr => attr.name === "is");
const isValue = isAttribute ? isAttribute.value : null;

const definition = lookupCEDefinition(ownerDocument, namespace, localName);

let willExecuteScript = false;
if (definition !== null && !this._fragment) {
willExecuteScript = true;
}

if (willExecuteScript) {
ownerDocument._throwOnDynamicMarkupInsertionCounter++;
customElementReactionsStack.push([]);
}

const element = createElement(ownerDocument, localName, namespace, null, isValue, willExecuteScript);
this.adoptAttributes(element, attrs);

if (willExecuteScript) {
const queue = customElementReactionsStack.pop();
invokeCEReactions(queue);
ownerDocument._throwOnDynamicMarkupInsertionCounter--;
}

if ("_parserInserted" in element) {
element._parserInserted = true;
}
Expand Down Expand Up @@ -160,10 +195,14 @@ class JSDOMParse5Adapter {
Object.assign(JSDOMParse5Adapter.prototype, serializationAdapter);

function parseFragment(markup, contextElement) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes look independently valuable. Would it make sense to split them out? Hopefully there are some targeted tests they fix, separate from custom elements, although we might have to write them...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can extract the <template> parsing related changes if you feel that's really important. That being said I will need to write additional tests since all the tests covering this case are using custom elements.

const ownerDocument = contextElement._ownerDocument;
const ownerDocument = contextElement.localName === "template" ?
contextElement.content._ownerDocument :
contextElement._ownerDocument;

const config = Object.assign({}, ownerDocument._parseOptions, {
treeAdapter: new JSDOMParse5Adapter(ownerDocument)
treeAdapter: new JSDOMParse5Adapter(ownerDocument, {
fragment: true
})
});

return parse5.parseFragment(contextElement, markup, config);
Expand Down
15 changes: 9 additions & 6 deletions lib/jsdom/browser/parser/xml.js
Expand Up @@ -3,6 +3,8 @@
const { SaxesParser } = require("saxes");
const DOMException = require("domexception/webidl2js-wrapper");

const { createElement } = require("../../living/helpers/create-element");

const DocumentFragment = require("../../living/generated/DocumentFragment");
const DocumentType = require("../../living/generated/DocumentType");
const CDATASection = require("../../living/generated/CDATASection");
Expand Down Expand Up @@ -99,17 +101,18 @@ function createParser(rootNode, globalObject, saxesOptions) {
};

parser.onopentag = tag => {
const { local: tagLocal, uri: tagURI, prefix: tagPrefix, attributes: tagAttributes } = tag;
const ownerDocument = getOwnerDocument();
const { local: tagLocal, attributes: tagAttributes } = tag;

const elem = ownerDocument._createElementWithCorrectElementInterface(tagLocal, tagURI);
const ownerDocument = getOwnerDocument();
const tagNamespace = tag.uri === "" ? null : tag.uri;
const tagPrefix = tag.prefix === "" ? null : tag.prefix;
const isValue = tagAttributes.is === undefined ? null : tagAttributes.is.value;

elem._prefix = tagPrefix || null;
elem._namespaceURI = tagURI || null;
const elem = createElement(ownerDocument, tagLocal, tagNamespace, tagPrefix, isValue, true);

// We mark a script element as "parser-inserted", which prevents it from
// being immediately executed.
if (tagLocal === "script" && tagURI === HTML_NS) {
if (tagLocal === "script" && tagNamespace === HTML_NS) {
elem._parserInserted = true;
}

Expand Down
50 changes: 45 additions & 5 deletions lib/jsdom/living/attributes.js
@@ -1,8 +1,10 @@
"use strict";
const DOMException = require("domexception/webidl2js-wrapper");
const { asciiLowercase } = require("./helpers/strings");

const { HTML_NS } = require("./helpers/namespaces");
const { asciiLowercase } = require("./helpers/strings");
const { queueAttributeMutationRecord } = require("./helpers/mutation-observers");
const { enqueueCECallbackReaction } = require("./helpers/custom-elements");

// The following three are for https://dom.spec.whatwg.org/#concept-element-attribute-has. We don't just have a
// predicate tester since removing that kind of flexibility gives us the potential for better future optimizations.
Expand All @@ -26,20 +28,38 @@ exports.hasAttributeByNameNS = function (element, namespace, localName) {
// https://dom.spec.whatwg.org/#concept-element-attributes-change
exports.changeAttribute = (element, attribute, value) => {
const { _localName, _namespace, _value } = attribute;

queueAttributeMutationRecord(element, _localName, _namespace, _value);

const oldValue = attribute._value;
if (element._ceState === "custom") {
enqueueCECallbackReaction(element, "attributeChangedCallback", [
_localName,
_value,
value,
_namespace
]);
}

attribute._value = value;

// Run jsdom hooks; roughly correspond to spec's "An attribute is set and an attribute is changed."
element._attrModified(attribute._qualifiedName, value, oldValue);
element._attrModified(attribute._qualifiedName, value, _value);
};

// https://dom.spec.whatwg.org/#concept-element-attributes-append
exports.appendAttribute = function (element, attribute) {
const { _localName, _namespace } = attribute;
const { _localName, _namespace, _value } = attribute;
queueAttributeMutationRecord(element, _localName, _namespace, null);

if (element._ceState === "custom") {
enqueueCECallbackReaction(element, "attributeChangedCallback", [
_localName,
null,
_value,
_namespace
]);
}

const attributeList = element._attributeList;

attributeList.push(attribute);
Expand All @@ -56,15 +76,25 @@ exports.appendAttribute = function (element, attribute) {
entry.push(attribute);

// Run jsdom hooks; roughly correspond to spec's "An attribute is set and an attribute is added."
element._attrModified(name, attribute._value, null);
element._attrModified(name, _value, null);
};

exports.removeAttribute = function (element, attribute) {
// https://dom.spec.whatwg.org/#concept-element-attributes-remove

const { _localName, _namespace, _value } = attribute;

queueAttributeMutationRecord(element, _localName, _namespace, _value);

if (element._ceState === "custom") {
enqueueCECallbackReaction(element, "attributeChangedCallback", [
_localName,
_value,
null,
_namespace
]);
}

const attributeList = element._attributeList;

for (let i = 0; i < attributeList.length; ++i) {
Expand Down Expand Up @@ -93,8 +123,18 @@ exports.replaceAttribute = function (element, oldAttr, newAttr) {
// https://dom.spec.whatwg.org/#concept-element-attributes-replace

const { _localName, _namespace, _value } = oldAttr;

queueAttributeMutationRecord(element, _localName, _namespace, _value);

if (element._ceState === "custom") {
enqueueCECallbackReaction(element, "attributeChangedCallback", [
_localName,
_value,
newAttr._value,
_namespace
]);
}

const attributeList = element._attributeList;

for (let i = 0; i < attributeList.length; ++i) {
Expand Down
2 changes: 1 addition & 1 deletion lib/jsdom/living/attributes/Attr.webidl
Expand Up @@ -6,7 +6,7 @@ interface Attr : Node {
readonly attribute DOMString? prefix;
readonly attribute DOMString localName;
readonly attribute DOMString name;
attribute DOMString value;
[CEReactions] attribute DOMString value;

readonly attribute Element? ownerElement;

Expand Down