Skip to content

Commit

Permalink
Fix <template> document owner and adoption semantics
Browse files Browse the repository at this point in the history
Fixes jsdom#2041.

This also introduces a breaking change to the JSDOM.fragment() API, where its document does not have a browsing context.
  • Loading branch information
pmdartus authored and domenic committed Mar 10, 2019
1 parent 16d3913 commit 75a921e
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 54 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,9 +409,9 @@ frag.querySelector("strong").textContent = "Why hello there!";
// etc.
```

Here `frag` is a [`DocumentFragment`](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment) instance, whose contents are created by parsing the provided string. The parsing is done using a `<template>` element, so you can include any element there (including ones with weird parsing rules like `<td>`).
Here `frag` is a [`DocumentFragment`](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment) instance, whose contents are created by parsing the provided string. The parsing is done using a `<template>` element, so you can include any element there (including ones with weird parsing rules like `<td>`). It's also important to note that the resulting `DocumentFragment` will not have [an associated browsing context](https://html.spec.whatwg.org/multipage/#concept-document-bc): that is, elements' `ownerDocument` will have a null `defaultView` property, resources will not load, etc.

All invocations of the `fragment()` factory result in `DocumentFragment`s that share the same owner `Document` and `Window`. This allows many calls to `fragment()` with no extra overhead. But it also means that calls to `fragment()` cannot be customized with any options.
All invocations of the `fragment()` factory result in `DocumentFragment`s that share the same template owner `Document`. This allows many calls to `fragment()` with no extra overhead. But it also means that calls to `fragment()` cannot be customized with any options.

Note that serialization is not as easy with `DocumentFragment`s as it is with full `JSDOM` objects. If you need to serialize your DOM, you should probably use the `JSDOM` constructor more directly. But for the special case of a fragment containing a single element, it's pretty easy to do through normal means:

Expand Down
20 changes: 0 additions & 20 deletions lib/jsdom/browser/htmltodom.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,6 @@ const DocumentType = require("../living/generated/DocumentType");
const JSDOMParse5Adapter = require("./parse5-adapter-parsing");
const { HTML_NS } = require("../living/helpers/namespaces");

// Horrible monkey-patch to implement https://github.com/inikulin/parse5/issues/237
const OpenElementStack = require("parse5/lib/parser/open-element-stack");
const originalPop = OpenElementStack.prototype.pop;
OpenElementStack.prototype.pop = function (...args) {
const before = this.items[this.stackTop];
originalPop.apply(this, args);
if (before._poppedOffStackOfOpenElements) {
before._poppedOffStackOfOpenElements();
}
};

const originalPush = OpenElementStack.prototype.push;
OpenElementStack.prototype.push = function (...args) {
originalPush.apply(this, args);
const after = this.items[this.stackTop];
if (after._pushedOnStackOfOpenElements) {
after._pushedOnStackOfOpenElements();
}
};

module.exports = class HTMLToDOM {
constructor(parsingMode) {
this.parser = parsingMode === "xml" ? saxes : parse5;
Expand Down
71 changes: 63 additions & 8 deletions lib/jsdom/browser/parse5-adapter-parsing.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"use strict";

const DocumentType = require("../living/generated/DocumentType");
const DocumentFragment = require("../living/generated/DocumentFragment");
const Text = require("../living/generated/Text");
Expand All @@ -7,9 +8,46 @@ const attributes = require("../living/attributes");
const nodeTypes = require("../living/node-type");
const serializationAdapter = require("../living/domparsing/parse5-adapter-serialization");

const OpenElementStack = require("parse5/lib/parser/open-element-stack");

const OpenElementStackOriginalPop = OpenElementStack.prototype.pop;
const OpenElementStackOriginalPush = OpenElementStack.prototype.push;

module.exports = class JSDOMParse5Adapter {
constructor(documentImpl) {
this._documentImpl = documentImpl;

// Since the createElement hook doesn't provide the parent element, we keep track of this using _currentElement:
// https://github.com/inikulin/parse5/issues/285
this._currentElement = undefined;

// Horrible monkey-patch to implement https://github.com/inikulin/parse5/issues/237
const adapter = this;
OpenElementStack.prototype.push = function (...args) {
OpenElementStackOriginalPush.apply(this, args);
adapter._currentElement = this.current;

const after = this.items[this.stackTop];
if (after._pushedOnStackOfOpenElements) {
after._pushedOnStackOfOpenElements();
}
};
OpenElementStack.prototype.pop = function (...args) {
const before = this.items[this.stackTop];

OpenElementStackOriginalPop.apply(this, args);
adapter._currentElement = this.current;

if (before._poppedOffStackOfOpenElements) {
before._poppedOffStackOfOpenElements();
}
};
}

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

createDocument() {
Expand All @@ -21,11 +59,13 @@ module.exports = class JSDOMParse5Adapter {
}

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

createElement(localName, namespace, attrs) {
const element = this._documentImpl._createElementWithCorrectElementInterface(localName, namespace);
const ownerDocument = this._ownerDocument();

const element = ownerDocument._createElementWithCorrectElementInterface(localName, namespace);
element._namespaceURI = namespace;
this.adoptAttributes(element, attrs);

Expand All @@ -37,7 +77,8 @@ module.exports = class JSDOMParse5Adapter {
}

createCommentNode(data) {
return Comment.createImpl([], { data, ownerDocument: this._documentImpl });
const ownerDocument = this._ownerDocument();
return Comment.createImpl([], { data, ownerDocument });
}

appendChild(parentNode, newNode) {
Expand All @@ -49,11 +90,25 @@ module.exports = class JSDOMParse5Adapter {
}

setTemplateContent(templateElement, contentFragment) {
// This code makes the glue between jsdom and parse5 HTMLTemplateElement parsing:
//
// * jsdom during the construction of the HTMLTemplateElement (for example when create via
// `document.createElement("template")`), creates a DocumentFragment and set it into _templateContents.
// * parse5 when parsing a <template> tag creates an HTMLTemplateElement (`createElement` adapter hook) and also
// create a DocumentFragment (`createDocumentFragment` adapter hook).
//
// At this point we now have to replace the one created in jsdom with one created by parse5.
const { _ownerDocument, _host } = templateElement._templateContents;
contentFragment._ownerDocument = _ownerDocument;
contentFragment._host = _host;

templateElement._templateContents = contentFragment;
}

setDocumentType(document, name, publicId, systemId) {
const documentType = DocumentType.createImpl([], { name, publicId, systemId, ownerDocument: this._documentImpl });
const ownerDocument = this._ownerDocument();
const documentType = DocumentType.createImpl([], { name, publicId, systemId, ownerDocument });

document.appendChild(documentType);
}

Expand All @@ -71,8 +126,8 @@ module.exports = class JSDOMParse5Adapter {
if (lastChild && lastChild.nodeType === nodeTypes.TEXT_NODE) {
lastChild.data += text;
} else {
const textNode = Text.createImpl([], { data: text, ownerDocument: this._documentImpl });

const ownerDocument = this._ownerDocument();
const textNode = Text.createImpl([], { data: text, ownerDocument });
parentNode.appendChild(textNode);
}
}
Expand All @@ -82,8 +137,8 @@ module.exports = class JSDOMParse5Adapter {
if (previousSibling && previousSibling.nodeType === nodeTypes.TEXT_NODE) {
previousSibling.data += text;
} else {
const textNode = Text.createImpl([], { data: text, ownerDocument: this._documentImpl });

const ownerDocument = this._ownerDocument();
const textNode = Text.createImpl([], { data: text, ownerDocument });
parentNode.insertBefore(textNode, referenceNode);
}
}
Expand Down
29 changes: 28 additions & 1 deletion lib/jsdom/living/helpers/shadow-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,30 @@ function signalSlotChange(slot) {
queueMutationObserverMicrotask();
}

// https://dom.spec.whatwg.org/#concept-shadow-including-descendant
function* shadowIncludingInclusiveDescendantsIterator(node) {
yield node;

if (node._shadowRoot) {
yield* shadowIncludingInclusiveDescendantsIterator(node._shadowRoot);
}

for (const child of domSymbolTree.childrenIterator(node)) {
yield* shadowIncludingInclusiveDescendantsIterator(child);
}
}

// https://dom.spec.whatwg.org/#concept-shadow-including-descendant
function* shadowIncludingDescendantsIterator(node) {
if (node._shadowRoot) {
yield* shadowIncludingInclusiveDescendantsIterator(node._shadowRoot);
}

for (const child of domSymbolTree.childrenIterator(node)) {
yield* shadowIncludingInclusiveDescendantsIterator(child);
}
}

module.exports = {
isValidHostElementName,

Expand All @@ -264,5 +288,8 @@ module.exports = {
findSlot,
findFlattenedSlotables,

signalSlotChange
signalSlotChange,

shadowIncludingInclusiveDescendantsIterator,
shadowIncludingDescendantsIterator
};
33 changes: 26 additions & 7 deletions lib/jsdom/living/nodes/Document-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const NodeList = require("../generated/NodeList");
const validateName = require("../helpers/validate-names").name;
const { validateAndExtract } = require("../helpers/validate-names");
const { fireAnEvent } = require("../helpers/events");
const { shadowIncludingInclusiveDescendantsIterator } = require("../helpers/shadow-dom");

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

Expand Down Expand Up @@ -777,24 +778,42 @@ class DocumentImpl extends NodeImpl {
return clone(node, this, deep);
}

// https://dom.spec.whatwg.org/#dom-document-adoptnode
adoptNode(node) {
if (node.nodeType === NODE_TYPE.DOCUMENT_NODE) {
throw new DOMException("Cannot adopt a document node", "NotSupportedError");
} else if (ShadowRoot.isImpl(node)) {
throw new DOMException("Cannot adopt a shadow root", "HierarchyRequestError");
}

if (node.parentNode) {
node.parentNode._remove(node);
}
node._ownerDocument = this;
for (const descendant of domSymbolTree.treeIterator(node)) {
descendant._ownerDocument = this;
}
this._adoptNode(node);

return node;
}

// https://dom.spec.whatwg.org/#concept-node-adopt
_adoptNode(node) {
const newDocument = this;
const oldDocument = node._ownerDocument;

const parent = domSymbolTree.parent(node);
if (parent) {
parent._remove(node);
}

if (oldDocument !== newDocument) {
for (const inclusiveDescendant of shadowIncludingInclusiveDescendantsIterator(node)) {
inclusiveDescendant._ownerDocument = newDocument;
}

for (const inclusiveDescendant of shadowIncludingInclusiveDescendantsIterator(node)) {
if (inclusiveDescendant._adoptingSteps) {
inclusiveDescendant._adoptingSteps(oldDocument);
}
}
}
}

get cookie() {
return this._cookieJar.getCookieStringSync(this.URL, { http: false });
}
Expand Down
3 changes: 3 additions & 0 deletions lib/jsdom/living/nodes/DocumentFragment-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ class DocumentFragmentImpl extends NodeImpl {
constructor(args, privateData) {
super(args, privateData);

const { host } = privateData;
this._host = host;

this.nodeType = NODE_TYPE.DOCUMENT_FRAGMENT_NODE;
}

Expand Down
37 changes: 36 additions & 1 deletion lib/jsdom/living/nodes/HTMLTemplateElement-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,48 @@

const HTMLElementImpl = require("./HTMLElement-impl").implementation;

const Document = require("../generated/Document");
const DocumentFragment = require("../generated/DocumentFragment");

const { cloningSteps, domSymbolTree } = require("../helpers/internal-constants");
const { clone } = require("../node");

class HTMLTemplateElementImpl extends HTMLElementImpl {
constructor(args, privateData) {
super(args, privateData);
this._templateContents = this._ownerDocument.createDocumentFragment();

const doc = this._appropriateTemplateContentsOwnerDocument(this._ownerDocument);
this._templateContents = DocumentFragment.createImpl([], {
ownerDocument: doc,
host: this
});
}

// https://html.spec.whatwg.org/multipage/scripting.html#appropriate-template-contents-owner-document
_appropriateTemplateContentsOwnerDocument(doc) {
if (!doc._isInertTemplateDocument) {
if (doc._associatedInertTemplateDocument === undefined) {
const newDoc = Document.createImpl([], {
options: {
parsingMode: doc._parsingMode,
encoding: doc._encoding
}
});
newDoc._isInertTemplateDocument = true;

doc._associatedInertTemplateDocument = newDoc;
}

doc = doc._associatedInertTemplateDocument;
}

return doc;
}

// https://html.spec.whatwg.org/multipage/scripting.html#template-adopting-steps
_adoptingSteps() {
const doc = this._appropriateTemplateContentsOwnerDocument(this._ownerDocument);
doc._adoptNode(this._templateContents);
}

get content() {
Expand Down
6 changes: 3 additions & 3 deletions lib/jsdom/living/nodes/Node-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ class NodeImpl extends EventTargetImpl {
referenceChildImpl = domSymbolTree.nextSibling(nodeImpl);
}

this._ownerDocument.adoptNode(nodeImpl);
this._ownerDocument._adoptNode(nodeImpl);

this._insert(nodeImpl, referenceChildImpl);

Expand Down Expand Up @@ -839,7 +839,7 @@ class NodeImpl extends EventTargetImpl {

const previousSiblingImpl = domSymbolTree.previousSibling(childImpl);

this._ownerDocument.adoptNode(nodeImpl);
this._ownerDocument._adoptNode(nodeImpl);

let removedNodesImpl = [];

Expand All @@ -862,7 +862,7 @@ class NodeImpl extends EventTargetImpl {
// https://dom.spec.whatwg.org/#concept-node-replace-all
_replaceAll(nodeImpl) {
if (nodeImpl !== null) {
this._ownerDocument.adoptNode(nodeImpl);
this._ownerDocument._adoptNode(nodeImpl);
}

const removedNodesImpl = domSymbolTree.childrenToArray(this);
Expand Down
3 changes: 1 addition & 2 deletions lib/jsdom/living/nodes/ShadowRoot-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ class ShadowRootImpl extends DocumentFragment {
constructor(args, privateData) {
super(args, privateData);

const { mode, host } = privateData;
const { mode } = privateData;
this._mode = mode;
this._host = host;
}

_getTheParent(event) {
Expand Down
10 changes: 4 additions & 6 deletions test/api/fragment.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,10 @@ describe("API: JSDOM.fragment()", () => {
assert.strictEqual(frag1.ownerDocument, frag2.ownerDocument);
});

it("should return fragments with shared Windows each time", () => {
const frag1 = JSDOM.fragment(``);
const frag2 = JSDOM.fragment(``);
it("should return a fragment with no associated browsing context", () => {
const frag = JSDOM.fragment(``);

assert.strictEqual(frag1.ownerDocument.defaultView, frag2.ownerDocument.defaultView);
assert.isNull(frag.ownerDocument.defaultView);
});

it("should allow basic DOM querying", () => {
Expand All @@ -45,7 +44,7 @@ describe("API: JSDOM.fragment()", () => {
assert.strictEqual(frag.firstChild.textContent, "Hi");
});

it("should respect ignore any options passed in", () => {
it("should ignore any options passed in", () => {
const frag = JSDOM.fragment(``, {
url: "https://example.org",
referrer: "https://example.com",
Expand All @@ -56,6 +55,5 @@ describe("API: JSDOM.fragment()", () => {
assert.strictEqual(frag.ownerDocument.URL, "about:blank");
assert.strictEqual(frag.ownerDocument.referrer, "");
assert.strictEqual(frag.ownerDocument.contentType, "text/html");
assert.notStrictEqual(frag.ownerDocument.defaultView.navigator.userAgent, "Mellblomenator/9000");
});
});
Loading

0 comments on commit 75a921e

Please sign in to comment.