diff --git a/src/dom-shim.js b/src/dom-shim.js index be289a3..9174de4 100644 --- a/src/dom-shim.js +++ b/src/dom-shim.js @@ -1,3 +1,57 @@ +/* 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 @@ -19,13 +73,56 @@ class EventTarget { // EventTarget <- Node // TODO should be an interface? class Node extends EventTarget { - // eslint-disable-next-line + constructor() { + super(); + // Parse5 properties + this.attrs = []; + this.parentNode = null; + this.childNodes = []; + } + cloneNode(deep) { - return this; + return deep ? deepClone(this) : Object.assign({}, this); } 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; } } @@ -34,33 +131,44 @@ 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; + 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 }) : ''; } setAttribute(name, value) { - this.attributes[name] = value; + // Modified attribute handling to work with parse5 + 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]; + // Modified attribute handling to work with parse5 + const attr = this.attrs.find((attr) => attr.name === name); + return attr ? attr.value : null; } hasAttribute(name) { - return !!this.attributes[name]; + // Modified attribute handling to work with parse5 + return this.attrs.some((attr) => attr.name === name); } } @@ -75,7 +183,7 @@ class Document extends Node { return new HTMLTemplateElement(); default: - return new HTMLElement(); + return new HTMLElement(tagName); } } @@ -88,6 +196,10 @@ class Document extends Node { // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement // EventTarget <- Node <- Element <- HTMLElement class HTMLElement extends Element { + constructor(tagName) { + super(); + Object.assign(this, getParse5ElementDefaults(this, tagName)); + } connectedCallback() { } } @@ -103,6 +215,23 @@ class ShadowRoot extends DocumentFragment { this.mode = options.mode || 'closed'; this.adoptedStyleSheets = []; } + + get innerHTML() { + return this.childNodes ? serialize({ childNodes: this.childNodes }) : ''; + } + + 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('${html}`; + this.childNodes = parseFragment(html).childNodes; + } } // https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement @@ -110,22 +239,11 @@ class ShadowRoot extends DocumentFragment { 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 = ` - - ${html} - - `; - } - } - - get innerHTML() { - return this.content && this.content.innerHTML ? this.content.innerHTML : undefined; + // Gets element defaults for template element instead of parsing a + // with parse5. Results in 2-5x better performance + // when creating templates + Object.assign(this, getParse5ElementDefaults(this, 'template')); + this.content.cloneNode = this.cloneNode.bind(this); } } @@ -138,6 +256,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 diff --git a/src/wcc.js b/src/wcc.js index 68c7a2e..9fa7b67 100644 --- a/src/wcc.js +++ b/src/wcc.js @@ -10,24 +10,6 @@ import { parse, parseFragment, serialize } from 'parse5'; import { transform } from 'sucrase'; import fs from 'fs'; -// https://developer.mozilla.org/en-US/docs/Glossary/Void_element -const VOID_ELEMENTS = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', // deprecated - 'source', - 'track', - 'wbr' -]; - function getParse(html) { return html.indexOf('') >= 0 || html.indexOf('
') >= 0 || html.indexOf('') >= 0 ? parse @@ -45,7 +27,7 @@ function isCustomElementDefinitionNode(node) { async function renderComponentRoots(tree, definitions) { for (const node of tree.childNodes) { if (node.tagName && node.tagName.indexOf('-') > 0) { - const { tagName } = node; + const { attrs, tagName } = node; if (definitions[tagName]) { const { moduleURL } = definitions[tagName]; @@ -53,31 +35,35 @@ async function renderComponentRoots(tree, definitions) { if (elementInstance) { const hasShadow = elementInstance.shadowRoot; - const elementHtml = hasShadow - ? elementInstance.getInnerHTML({ includeShadowRoots: true }) - : elementInstance.innerHTML; - const elementTree = parseFragment(elementHtml); - const hasLight = elementTree.childNodes > 0; - - node.childNodes = node.childNodes.length === 0 && hasLight && !hasShadow - ? elementTree.childNodes - : hasShadow - ? [...elementTree.childNodes, ...node.childNodes] - : elementTree.childNodes; + + node.childNodes = hasShadow + ? [...elementInstance.shadowRoot.childNodes, ...node.childNodes] + : elementInstance.childNodes; } else { console.warn(`WARNING: customElement <${tagName}> detected but not serialized. You may not have exported it.`); } } else { console.warn(`WARNING: customElement <${tagName}> is not defined. You may not have imported it.`); } + + attrs.forEach((attr) => { + if (attr.name === 'hydrate') { + definitions[tagName].hydrate = attr.value; + } + }); + } if (node.childNodes && node.childNodes.length > 0) { await renderComponentRoots(node, definitions); } + if (node.shadowRoot && node.shadowRoot.childNodes?.length > 0) { + await renderComponentRoots(node.shadowRoot, definitions); + } + // does this only apply to `` tags? - if (node.content && node.content.childNodes && node.content.childNodes.length > 0) { + if (node.content && node.content.childNodes?.length > 0) { await renderComponentRoots(node.content, definitions); } } @@ -163,38 +149,6 @@ async function getTagName(moduleURL) { return tagName; } -function renderLightDomChildren(childNodes, iHTML = '') { - let innerHTML = iHTML; - - childNodes.forEach((child) => { - const { nodeName, attrs = [], value } = child; - - if (nodeName !== '#text') { - innerHTML += `<${nodeName}`; - - if (attrs.length > 0) { - attrs.forEach(attr => { - innerHTML += ` ${attr.name}="${attr.value}"`; - }); - } - - innerHTML += '>'; - - if (child.childNodes.length > 0) { - innerHTML = renderLightDomChildren(child.childNodes, innerHTML); - } - - innerHTML += VOID_ELEMENTS.includes(nodeName) - ? '' - : `${nodeName}>`; - } else if (nodeName === '#text') { - innerHTML += value; - } - }); - - return innerHTML; -} - async function initializeCustomElement(elementURL, tagName, node = {}, definitions = [], isEntry, props = {}) { const { attrs = [], childNodes = [] } = node; @@ -208,28 +162,19 @@ async function initializeCustomElement(elementURL, tagName, node = {}, definitio const { href } = elementURL; const element = customElements.get(tagName) ?? (await import(href)).default; const dataLoader = (await import(href)).getData; - const data = props - ? props - : dataLoader - ? await dataLoader(props) - : {}; + const data = props ? props : dataLoader ? await dataLoader(props) : {}; if (element) { const elementInstance = new element(data); // eslint-disable-line new-cap - // support for HTML (Light DOM) Web Components - elementInstance.innerHTML = renderLightDomChildren(childNodes); + elementInstance.childNodes = childNodes; attrs.forEach((attr) => { elementInstance.setAttribute(attr.name, attr.value); - - if (attr.name === 'hydrate') { - definitions[tagName].hydrate = attr.value; - } }); - + await elementInstance.connectedCallback(); - + return elementInstance; } } @@ -243,18 +188,26 @@ async function renderToString(elementURL, wrappingEntryTag = true, props = {}) { // in case the entry point isn't valid if (elementInstance) { - const elementHtml = elementInstance.shadowRoot - ? elementInstance.getInnerHTML({ includeShadowRoots: true }) - : elementInstance.innerHTML; - const elementTree = getParse(elementHtml)(elementHtml); - const finalTree = await renderComponentRoots(elementTree, definitions); + elementInstance.nodeName = elementTagName ?? ''; + elementInstance.tagName = elementTagName ?? ''; + + await renderComponentRoots( + elementInstance.shadowRoot + ? + { + nodeName: '#document-fragment', + childNodes: [elementInstance] + } + : elementInstance, + definitions + ); html = wrappingEntryTag && elementTagName ? ` <${elementTagName}> - ${serialize(finalTree)} + ${serialize(elementInstance)} ${elementTagName}> ` - : serialize(finalTree); + : serialize(elementInstance); } else { console.warn('WARNING: No custom element class found for this entry point.'); }