From a37d2239bfd90fe79f9dc5ebb24c98b3e03d4f55 Mon Sep 17 00:00:00 2001 From: Brian Grider Date: Thu, 21 Nov 2024 12:35:19 -0800 Subject: [PATCH] Rework of DOM shim, using parse5 to allow for element properties in the future --- package-lock.json | 4 +- src/dom-shim.js | 204 ++++++++++++++++++++++++++++++-------- src/wcc.js | 246 ++++++++++++++++++++-------------------------- 3 files changed, 270 insertions(+), 184 deletions(-) diff --git a/package-lock.json b/package-lock.json index 733ea30..bf83399 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wc-compiler", - "version": "0.15.0", + "version": "0.15.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "wc-compiler", - "version": "0.15.0", + "version": "0.15.1", "license": "MIT", "dependencies": { "@projectevergreen/acorn-jsx-esm": "~0.1.0", diff --git a/src/dom-shim.js b/src/dom-shim.js index be289a3..d189223 100644 --- a/src/dom-shim.js +++ b/src/dom-shim.js @@ -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 @@ -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; } } @@ -34,40 +141,37 @@ 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) { @@ -75,7 +179,7 @@ class Document extends Node { return new HTMLTemplateElement(); default: - return new HTMLElement(); + return new HTMLElement(tagName); } } @@ -88,21 +192,40 @@ 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('${html}`; + this.childNodes = parseFragment(html).childNodes; + } } // https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement @@ -110,22 +233,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 = ` - - `; - } - } - - 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 +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 @@ -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; \ No newline at end of file +globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? CSSStyleSheet; diff --git a/src/wcc.js b/src/wcc.js index 68c7a2e..c770139 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 @@ -37,9 +19,30 @@ function getParse(html) { function isCustomElementDefinitionNode(node) { const { expression } = node; - return expression.type === 'CallExpression' && expression.callee && expression.callee.object - && expression.callee.property && expression.callee.object.name === 'customElements' - && expression.callee.property.name === 'define'; + return ( + expression.type === 'CallExpression' && + expression.callee && + expression.callee.object && + expression.callee.property && + expression.callee.object.name === 'customElements' && + expression.callee.property.name === 'define' + ); +} + +function markHydrate(tree, definitions) { + const childNodes = tree.shadowRoot?.childNodes?.[0]?.content?.childNodes ?? tree.childNodes; + if (childNodes) { + for (const node of childNodes) { + if (node.tagName && node.tagName.indexOf('-') > 0) { + const { attrs, tagName } = node; + attrs.forEach((attr) => { + if (attr.name === 'hydrate') { + definitions[tagName].hydrate = attr.value; + } + }); + } + } + } } async function renderComponentRoots(tree, definitions) { @@ -53,19 +56,14 @@ 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.`); + 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.`); @@ -82,6 +80,8 @@ async function renderComponentRoots(tree, definitions) { } } + markHydrate(tree.childNodes[0], definitions); + return tree; } @@ -94,46 +94,51 @@ function registerDependencies(moduleURL, definitions, depth = 0) { const nextDepth = depth += 1; const customParser = getParser(moduleURL); const parser = customParser ? customParser.parser : acorn.Parser; - const config = customParser ? customParser.config : { - ...walk.base - }; - - walk.simple(parser.parse(result.code, { - ecmaVersion: 'latest', - sourceType: 'module' - }), { - ImportDeclaration(node) { - const specifier = node.source.value; - const isBareSpecifier = specifier.indexOf('.') !== 0 && specifier.indexOf('/') !== 0; - const extension = specifier.split('.').pop(); - - // would like to decouple .jsx from the core, ideally - // https://github.com/ProjectEvergreen/wcc/issues/122 - if (!isBareSpecifier && ['js', 'jsx', 'ts'].includes(extension)) { - const dependencyModuleURL = new URL(node.source.value, moduleURL); - - registerDependencies(dependencyModuleURL, definitions, nextDepth); + const config = customParser + ? customParser.config + : { ...walk.base }; + + walk.simple( + parser.parse(result.code, { + ecmaVersion: 'latest', + sourceType: 'module' + }), + { + ImportDeclaration(node) { + const specifier = node.source.value; + const isBareSpecifier = specifier.indexOf('.') !== 0 && specifier.indexOf('/') !== 0; + const extension = specifier.split('.').pop(); + + // would like to decouple .jsx from the core, ideally + // https://github.com/ProjectEvergreen/wcc/issues/122 + if (!isBareSpecifier && ['js', 'jsx', 'ts'].includes(extension)) { + const dependencyModuleURL = new URL(node.source.value, moduleURL); + + registerDependencies(dependencyModuleURL, definitions, nextDepth); + } + }, + ExpressionStatement(node) { + if (isCustomElementDefinitionNode(node)) { + const { arguments: args } = node.expression; + const tagName = + args[0].type === 'Literal' + ? args[0].value // single and double quotes + : args[0].quasis[0].value.raw; // template literal + const tree = parseJsx(moduleURL); + const isEntry = nextDepth - 1 === 1; + + definitions[tagName] = { + instanceName: args[1].name, + moduleURL, + source: generate(tree), + url: moduleURL, + isEntry + }; + } } }, - ExpressionStatement(node) { - if (isCustomElementDefinitionNode(node)) { - const { arguments: args } = node.expression; - const tagName = args[0].type === 'Literal' - ? args[0].value // single and double quotes - : args[0].quasis[0].value.raw; // template literal - const tree = parseJsx(moduleURL); - const isEntry = nextDepth - 1 === 1; - - definitions[tagName] = { - instanceName: args[1].name, - moduleURL, - source: generate(tree), - url: moduleURL, - isEntry - }; - } - } - }, config); + config + ); } async function getTagName(moduleURL) { @@ -144,57 +149,29 @@ async function getTagName(moduleURL) { }); const customParser = getParser(moduleURL); const parser = customParser ? customParser.parser : acorn.Parser; - const config = customParser ? customParser.config : { - ...walk.base - }; + const config = customParser + ? customParser.config + : { ...walk.base }; let tagName; - walk.simple(parser.parse(result.code, { - ecmaVersion: 'latest', - sourceType: 'module' - }), { - ExpressionStatement(node) { - if (isCustomElementDefinitionNode(node)) { - tagName = node.expression.arguments[0].value; + walk.simple( + parser.parse(result.code, { + ecmaVersion: 'latest', + sourceType: 'module' + }), + { + ExpressionStatement(node) { + if (isCustomElementDefinitionNode(node)) { + tagName = node.expression.arguments[0].value; + } } - } - }, config); + }, + config + ); 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) - ? '' - : ``; - } else if (nodeName === '#text') { - innerHTML += value; - } - }); - - return innerHTML; -} - async function initializeCustomElement(elementURL, tagName, node = {}, definitions = [], isEntry, props = {}) { const { attrs = [], childNodes = [] } = node; @@ -208,28 +185,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 +211,19 @@ 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({ nodeName: '#document-fragment', childNodes: [elementInstance] }, definitions); - html = wrappingEntryTag && elementTagName ? ` + html = + wrappingEntryTag && elementTagName + ? ` <${elementTagName}> - ${serialize(finalTree)} + ${serialize(elementInstance)} ` - : serialize(finalTree); + : serialize(elementInstance); } else { console.warn('WARNING: No custom element class found for this entry point.'); } @@ -281,7 +250,4 @@ async function renderFromHTML(html, elements = []) { }; } -export { - renderToString, - renderFromHTML -}; \ No newline at end of file +export { renderToString, renderFromHTML };