Skip to content

Commit

Permalink
Rework of DOM shim, using parse5 to allow for element properties in t…
Browse files Browse the repository at this point in the history
…he future
  • Loading branch information
Brian Grider committed Nov 21, 2024
1 parent 22078ee commit a37d223
Show file tree
Hide file tree
Showing 3 changed files with 270 additions and 184 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

204 changes: 162 additions & 42 deletions src/dom-shim.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,65 @@
function noop() { }
/* eslint-disable no-warning-comments */

import { parseFragment, serialize } from 'parse5';

// TODO Should go into utils file?
function isShadowRoot(element) {
return Object.getPrototypeOf(element).constructor.name === 'ShadowRoot';
}

// Deep clone for cloneNode(deep) - TODO should this go into a utils file?
// structuredClone doesn't work with functions. TODO This works with
// all current tests but would it be worth considering a lightweight
// library here to better cover edge cases?
function deepClone(obj, map = new WeakMap()) {
if (obj === null || typeof obj !== 'object') {
return obj; // Return primitives or functions as-is
}

if (typeof obj === 'function') {
const clonedFn = obj.bind({});
Object.assign(clonedFn, obj);
return clonedFn;
}

if (map.has(obj)) {
return map.get(obj);
}

const result = Array.isArray(obj) ? [] : {};
map.set(obj, result);

for (const key of Object.keys(obj)) {
result[key] = deepClone(obj[key], map);
}

return result;
}

// Creates an empty parse5 element without the parse5 overhead. Results in 2-10x better performance.
// TODO Should this go into a utils files?
function getParse5ElementDefaults(element, tagName) {
return {
addEventListener: noop,
attrs: [],
parentNode: element.parentNode,
childNodes: [],
nodeName: tagName,
tagName: tagName,
namespaceURI: 'http://www.w3.org/1999/xhtml',
// eslint-disable-next-line no-extra-parens
...(tagName === 'template' ? { content: { nodeName: '#document-fragment', childNodes: [] } } : {})
};
}

function noop() {}

// https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet
class CSSStyleSheet {
insertRule() { }
deleteRule() { }
replace() { }
replaceSync() { }
insertRule() {}
deleteRule() {}
replace() {}
replaceSync() {}
}

// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget
Expand All @@ -19,13 +73,66 @@ class EventTarget {
// EventTarget <- Node
// TODO should be an interface?
class Node extends EventTarget {
// eslint-disable-next-line
constructor() {
super();
this.attrs = [];
this.parentNode = null;
this.childNodes = [];
}

cloneNode(deep) {
return this;
return deep ? deepClone(this) : Object.assign({}, this);
}

set innerHTML(html) {
(this.nodeName === 'template' ? this.content : this).childNodes = parseFragment(html).childNodes; // Replace content's child nodes
}

// Serialize the content of the DocumentFragment when getting innerHTML
get innerHTML() {
const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;

return childNodes ? serialize({ childNodes }) : '';
}

appendChild(node) {
this.innerHTML = this.innerHTML ? this.innerHTML += node.innerHTML : node.innerHTML;
const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;

if (node.parentNode) {
node.parentNode?.removeChild?.(node); // Remove from current parent
}

if (node.nodeName === 'template') {
if (isShadowRoot(this) && this.mode) {
node.attrs = [{ name: 'shadowrootmode', value: this.mode }];
childNodes.push(node);
node.parentNode = this;
} else {
this.childNodes = [...this.childNodes, ...node.content.childNodes];
}
} else {
childNodes.push(node);
node.parentNode = this;
}

return node;
}

removeChild(node) {
const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;
if (!childNodes || !childNodes.length) {
return null;
}

const index = childNodes.indexOf(node);
if (index === -1) {
return null;
}

childNodes.splice(index, 1);
node.parentNode = null;

return node;
}
}

Expand All @@ -34,48 +141,45 @@ class Node extends EventTarget {
class Element extends Node {
constructor() {
super();
this.shadowRoot = null;
this.innerHTML = '';
this.attributes = {};
}

attachShadow(options) {
this.shadowRoot = new ShadowRoot(options);

this.shadowRoot.parentNode = this;
return this.shadowRoot;
}

// https://github.com/mfreed7/declarative-shadow-dom/blob/master/README.md#serialization
// eslint-disable-next-line
getInnerHTML() {
return this.shadowRoot ? this.shadowRoot.innerHTML : this.innerHTML;
}

setAttribute(name, value) {
this.attributes[name] = value;
const attr = this.attrs?.find((attr) => attr.name === name);

if (attr) {
attr.value = value;
} else {
this.attrs?.push({ name, value });
}
}

getAttribute(name) {
return this.attributes[name];
const attr = this.attrs.find((attr) => attr.name === name);
return attr ? attr.value : null;
}

hasAttribute(name) {
return !!this.attributes[name];
return this.attrs.some((attr) => attr.name === name);
}
}

// https://developer.mozilla.org/en-US/docs/Web/API/Document
// EventTarget <- Node <- Document
class Document extends Node {

createElement(tagName) {
switch (tagName) {

case 'template':
return new HTMLTemplateElement();

default:
return new HTMLElement();
return new HTMLElement(tagName);

}
}
Expand All @@ -88,44 +192,52 @@ class Document extends Node {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
// EventTarget <- Node <- Element <- HTMLElement
class HTMLElement extends Element {
connectedCallback() { }
constructor(tagName) {
super();
Object.assign(this, getParse5ElementDefaults(this, tagName));
}
connectedCallback() {}
}

// https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment
// EventTarget <- Node <- DocumentFragment
class DocumentFragment extends Node { }
class DocumentFragment extends Node {}

// https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot
// EventTarget <- Node <- DocumentFragment <- ShadowRoot
// ShadowRoot implementation
class ShadowRoot extends DocumentFragment {
constructor(options) {
super();
this.mode = options.mode || 'closed';
this.adoptedStyleSheets = [];
}

// eslint-disable-next-line accessor-pairs
set innerHTML(html) {
// Replaces auto wrapping functionality that was previously done
// in HTMLTemplateElement. This allows parse5 to add declarative
// shadow roots when necessary. To pass tests that wrap innerHTML
// in a template, we only wrap when if a template isn't found at the
// start of the html string (this can be removed if those tests are
// changed)
html = html.trim().toLowerCase().startsWith('<template')
? html
: `<template shadowrootmode="${this.mode}">${html}</template>`;
this.childNodes = parseFragment(html).childNodes;
}
}

// https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement
// EventTarget <- Node <- Element <- HTMLElement <- HTMLTemplateElement
class HTMLTemplateElement extends HTMLElement {
constructor() {
super();
this.content = new DocumentFragment();
}

// TODO open vs closed shadow root
set innerHTML(html) {
if (this.content) {
this.content.innerHTML = `
<template shadowrootmode="open">
${html}
</template>
`;
}
}

get innerHTML() {
return this.content && this.content.innerHTML ? this.content.innerHTML : undefined;
// Gets element defaults for template element instead of parsing a
// <template></template> with parse5. Results in 2-5x better performance
// when creating templates
Object.assign(this, getParse5ElementDefaults(this, 'template'));
this.content.cloneNode = this.cloneNode.bind(this);
}
}

Expand All @@ -138,6 +250,14 @@ class CustomElementsRegistry {
}

define(tagName, BaseClass) {
// TODO Should we throw an error here when a tagName is already defined?
// Would require altering tests
// if (this.customElementsRegistry.has(tagName)) {
// throw new Error(
// `Custom element with tag name ${tagName} is already defined.`
// );
// }

// TODO this should probably fail as per the spec...
// e.g. if(this.customElementsRegistry.get(tagName))
// https://github.com/ProjectEvergreen/wcc/discussions/145
Expand All @@ -156,4 +276,4 @@ globalThis.addEventListener = globalThis.addEventListener ?? noop;
globalThis.document = globalThis.document ?? new Document();
globalThis.customElements = globalThis.customElements ?? new CustomElementsRegistry();
globalThis.HTMLElement = globalThis.HTMLElement ?? HTMLElement;
globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? CSSStyleSheet;
globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? CSSStyleSheet;
Loading

0 comments on commit a37d223

Please sign in to comment.