Repository: ProjectEvergreen/wcc
Pull request #178 (2024-11-21 21:54:35)

enhancement/rework of DOM shim (using parse5) to allow for element properties in the future
## Related Issue
[First step to resolving issue 170](https://github.com/ProjectEvergreen/wcc/issues/170)

## Summary of Changes

dom-shim.js:
1. [x] Rework DOM shim to use parse5 for serializing children and parsing strings for innerHTML getter and setter
2. [x] Get ShadowDom "closed" mode working
3. [x] Create declarative shadow DOM to mimic the browser functionality when content is appended to a shadowRoot
4. [x] Remove template auto-wrapping functionality from HTMLTemplateElement
5. [x] Add isShadowRoot utility function to check prototype when assessing whether shadowrootmode attribute needs to be added to appended template/content
6. [x] Add deepClone utility function to be used with cloneDeep(deep) functionality (this just returned the actual element before)
7. [x] Add getParse5ElementDefaults utility function to generate empty parse5 elements with much less overhead. At least 2-5x performance gain compared to parsing an empty element in a string (ie. '\<template>\</template>')
8. [x] Add various "TODO" comments for considerations for improvement

wcc.js
1. [x] Simplify renderComponentRoots - no need for getInnerHTML, innerHTML, elementHTML, elementTree, hasLight anymore
2. [x]  Remove renderLightDomChildren (passes all tests without)
3. [x] Remove VOID_ELEMENTS (no longer needed by renderLightDomChildren)
4. [x]  Simplify renderToString, similar to renderComponentRoots

briangrider: > Will try and give this a more thorough testing soon, but just a couple quick observations:
> 
> * Since there are no dependency changes, _package-lock.json_ should probably not be changing
Ah, my bad. I must have had it accidentally checked on Sourcetree, just reset the branch and pushed just the relevant files

> * It looks like the tests failed only because of coverage thresholds, and so I'm fine bumping down that as needed (in this case to 85% would be ok)
Yes, I believe on my initial commit I was at around 91+% on function coverage but it looks like it dipped to 89.28% with some of the additional changes. Yes, if we can temporarily drop the threshold to 85% that would be great


briangrider: > 3. [ ]  preserve `getHTML` in the DOM shim and make sure no other APIs are being removed (and we should probably add some test cases here, which I can help with)
I think we should be good here but let me know.

> 4. [ ]  I am OK to let the mode fix come in as part of this PR, but let's make sure by adding a test case - [support configurable `shadowrootmode` attribute for `<template>` tags #65](https://github.com/ProjectEvergreen/wcc/issues/65)
This seems to be the last step here on my end. Will have this done soon.

> 5. [ ]  Looks like this PR would also help resolve or invalidate [verify / ensure proper serialization of shadow roots excluding closed shadow roots from `getInnerHTML` #16](https://github.com/ProjectEvergreen/wcc/issues/16) ?
Yes, I believe so.



Patch:
diff --git a/src/dom-shim.js b/src/dom-shim.js
index cbf5398..b25255d 100644
--- a/src/dom-shim.js
+++ b/src/dom-shim.js
@@ -1,3 +1,57 @@
+/* eslint-disable no-warning-comments */
+
+import { parse, parseFragment, serialize } from 'parse5';
+
+export function getParse(html) {
+  return html.indexOf('<html>') >= 0 || html.indexOf('<body>') >= 0 || html.indexOf('<head>') >= 0
+    ? parse
+    : parseFragment;
+}
+
+function isShadowRoot(element) {
+  return Object.getPrototypeOf(element).constructor.name === 'ShadowRoot';
+}
+
+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 resulting in better performance
+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,84 @@ 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 = [];
+    this.nodeName = '';
+  }
+
   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 if (node instanceof DocumentFragment) {
+      this.childNodes = [...this.childNodes, ...node.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;
+  }
+
+  get textContent() {
+    if (this.nodeName === '#text') {
+      return this.value || ''; // Text nodes should return their value
+    }
+
+    // Compute textContent for elements by concatenating text of all descendants
+    return this.childNodes
+      .map((child) => child.nodeName === '#text' ? child.value : child.textContent)
+      .join('');
+  }
+
+  set textContent(value) {
+    // Remove all current child nodes
+    this.childNodes = [];
+
+    if (value) {
+      // Create a single text node with the given value
+      const textNode = new Node();
+      textNode.nodeName = '#text';
+      textNode.value = value; // Text node content
+      textNode.parentNode = this;
+      this.childNodes.push(textNode);
+    }
   }
 }
 
@@ -34,32 +159,48 @@ 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;
   }
 
   getHTML({ serializableShadowRoots = false }) {
-    return this.shadowRoot && serializableShadowRoots ?
-      `<template shadowrootmode="open">${this.shadowRoot.innerHTML}</template>` : this.innerHTML;
+    return this.shadowRoot && serializableShadowRoots && this.shadowRoot.serializable ? this.shadowRoot.innerHTML : '';
   }
 
-  setAttribute(name, value) {
-    this.attributes[name] = value;
+  // Serialize the content of the DocumentFragment when getting innerHTML
+  get innerHTML() {
+    const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;
+    return childNodes ? serialize({ childNodes }) : '';
   }
 
-  getAttribute(name) {
-    return this.attributes[name];
+  set innerHTML(html) {
+    (this.nodeName === 'template' ? this.content : this).childNodes = getParse(html)(html).childNodes; // Replace content's child nodes
   }
 
   hasAttribute(name) {
-    return !!this.attributes[name];
+    // Modified attribute handling to work with parse5
+    return this.attrs.some((attr) => attr.name === name);
+  }
+
+  getAttribute(name) {
+    // Modified attribute handling to work with parse5
+    const attr = this.attrs.find((attr) => attr.name === name);
+    return attr ? attr.value : null;
+  }
+
+  setAttribute(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 });
+    }
   }
 }
 
@@ -74,7 +215,7 @@ class Document extends Node {
         return new HTMLTemplateElement();
 
       default:
-        return new HTMLElement();
+        return new HTMLElement(tagName);
 
     }
   }
@@ -87,6 +228,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() { }
 }
 
@@ -99,9 +244,18 @@ class DocumentFragment extends Node { }
 class ShadowRoot extends DocumentFragment {
   constructor(options) {
     super();
-    this.mode = options.mode || 'closed';
+    this.mode = options.mode ?? 'closed';
+    this.serializable = options.serializable ?? false;
     this.adoptedStyleSheets = [];
   }
+
+  get innerHTML() {
+    return this.childNodes?.[0]?.content?.childNodes ? serialize({ childNodes: this.childNodes[0].content.childNodes }) : '';
+  }
+
+  set innerHTML(html) {
+    this.childNodes = getParse(html)(`<template shadowrootmode="${this.mode}">${html}</template>`).childNodes;
+  }
 }
 
 // https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement
@@ -109,18 +263,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
+    // <template></template> with parse5. Results in better performance
+    // when creating templates
+    Object.assign(this, getParse5ElementDefaults(this, 'template'));
+    this.content.cloneNode = this.cloneNode.bind(this);
   }
 }
 
@@ -151,4 +298,5 @@ globalThis.addEventListener = globalThis.addEventListener ?? noop;
 globalThis.document = globalThis.document ?? new Document();
 globalThis.customElements = globalThis.customElements ?? new CustomElementsRegistry();
 globalThis.HTMLElement = globalThis.HTMLElement ?? HTMLElement;
+globalThis.DocumentFragment = globalThis.DocumentFragment ?? DocumentFragment;
 globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? CSSStyleSheet;
\ No newline at end of file
diff --git a/src/wcc.js b/src/wcc.js
index c687239..b328cf3 100644
--- a/src/wcc.js
+++ b/src/wcc.js
@@ -1,39 +1,15 @@
 /* eslint-disable max-depth */
 // this must come first
-import './dom-shim.js';
+import { getParse } from './dom-shim.js';
 
 import * as acorn from 'acorn';
 import * as walk from 'acorn-walk';
 import { generate } from 'astring';
 import { getParser, parseJsx } from './jsx-loader.js';
-import { parse, parseFragment, serialize } from 'parse5';
+import { 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('<html>') >= 0 || html.indexOf('<body>') >= 0 || html.indexOf('<head>') >= 0
-    ? parse
-    : parseFragment;
-}
-
 function isCustomElementDefinitionNode(node) {
   const { expression } = node;
 
@@ -45,7 +21,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 +29,35 @@ async function renderComponentRoots(tree, definitions) {
 
         if (elementInstance) {
           const hasShadow = elementInstance.shadowRoot;
-          const elementHtml = hasShadow
-            ? elementInstance.getHTML({ serializableShadowRoots: 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 `<template>` 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 +143,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,24 +156,15 @@ 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();
@@ -239,22 +178,31 @@ async function renderToString(elementURL, wrappingEntryTag = true, props = {}) {
   const elementTagName = wrappingEntryTag && await getTagName(elementURL);
   const isEntry = !!elementTagName;
   const elementInstance = await initializeCustomElement(elementURL, undefined, undefined, definitions, isEntry, props);
+
   let html;
 
   // in case the entry point isn't valid
   if (elementInstance) {
-    const elementHtml = elementInstance.shadowRoot
-      ? elementInstance.getHTML({ serializableShadowRoots: 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.');
   }


Test patch:
diff --git a/test/cases/create-document-fragment/create-document-fragment.spec.js b/test/cases/create-document-fragment/create-document-fragment.spec.js
new file mode 100644
index 0000000..2a83c8b
--- /dev/null
+++ b/test/cases/create-document-fragment/create-document-fragment.spec.js
@@ -0,0 +1,39 @@
+/*
+ * Use Case
+ * Run wcc against a component which creates two document fragments and appends them with appendChild.
+ *
+ * User Result
+ * Should return the expected HTML output based on the content of the appended fragments.
+ *
+ * User Workspace
+ * src/
+ *   index.js
+ */
+
+import chai from 'chai';
+import { JSDOM } from 'jsdom';
+import { renderToString } from '../../../src/wcc.js';
+
+const expect = chai.expect;
+
+describe('Run WCC For ', function () {
+  const LABEL = 'Custom Element w/ Document Fragments';
+  let dom;
+
+  before(async function () {
+    const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
+    dom = new JSDOM(html);
+  });
+
+  describe(LABEL, function () {
+    it('should have a heading tag with text content equal to "document.createDocumentFragment()"', function () {
+      expect(dom.window.document.querySelectorAll('h2')[0].textContent).to.equal('document.createDocumentFragment()');
+    });
+  });
+
+  describe(LABEL, function () {
+    it('should have a heading tag with text content equal to new "DocumentFragment()"', function () {
+      expect(dom.window.document.querySelectorAll('h2')[1].textContent).to.equal('new DocumentFragment()');
+    });
+  });
+});
diff --git a/test/cases/create-document-fragment/src/index.js b/test/cases/create-document-fragment/src/index.js
new file mode 100644
index 0000000..38f2d6d
--- /dev/null
+++ b/test/cases/create-document-fragment/src/index.js
@@ -0,0 +1,20 @@
+export default class DocumentFragmentComponent extends HTMLElement {
+
+  connectedCallback() {
+    const fragment1 = document.createDocumentFragment();
+    const fragment2 = new DocumentFragment();
+
+    const h1 = document.createElement('h2');
+    h1.textContent = 'document.createDocumentFragment()';
+    fragment1.appendChild(h1);
+
+    const h2 = document.createElement('h2');
+    h2.textContent = 'new DocumentFragment()';
+    fragment2.appendChild(h2);
+
+    this.appendChild(fragment1);
+    this.appendChild(fragment2);
+  }
+}
+
+customElements.define('document-fragment-component', DocumentFragmentComponent);
\ No newline at end of file
diff --git a/test/cases/full-document-component/full-document-component.spec.js b/test/cases/full-document-component/full-document-component.spec.js
new file mode 100644
index 0000000..62b5c2f
--- /dev/null
+++ b/test/cases/full-document-component/full-document-component.spec.js
@@ -0,0 +1,37 @@
+/*
+ * Use Case
+ * Run wcc against a component which sets innerHTML to a full HTML document.
+ *
+ * User Result
+ * Should return the expected HTML output with a component containing a full HTML document.
+ *
+ * User Workspace
+ * src/
+ *   index.js
+ */
+
+import chai from 'chai';
+import { renderToString } from '../../../src/wcc.js';
+
+const expect = chai.expect;
+
+describe('Run WCC For ', function () {
+  const LABEL = 'Custom Element w/ Document Fragments';
+  let renderedContent;
+
+  before(async function () {
+    const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
+    renderedContent = html.replace(/\s+/g, '');
+  });
+
+  describe(LABEL, function () {
+    it('should have a heading tag with text renderedContent equal to "document.createDocumentFragment()"', function () {
+      expect(renderedContent).to.equal(
+        '<full-document-component><!DOCTYPE html><html><head><title>App Layout</title></head><body><h1>App Layout</h1></body></html></full-document-component>'.replace(
+          /\s+/g,
+          ''
+        )
+      );
+    });
+  });
+});
diff --git a/test/cases/full-document-component/src/index.js b/test/cases/full-document-component/src/index.js
new file mode 100644
index 0000000..821c8cd
--- /dev/null
+++ b/test/cases/full-document-component/src/index.js
@@ -0,0 +1,19 @@
+export default class FullDocumentComponent extends HTMLElement {
+
+  connectedCallback() {
+    this.innerHTML = `
+      <!DOCTYPE html>
+      <html>
+        <head>
+          <title>App Layout</title>
+        </head>
+
+        <body>
+          <h1>App Layout</h1>
+        </body>
+      </html>
+    `;
+  }
+}
+
+customElements.define('full-document-component', FullDocumentComponent);
\ No newline at end of file
diff --git a/test/cases/serializable-shadow-roots/serializable-shadow-roots.spec.js b/test/cases/serializable-shadow-roots/serializable-shadow-roots.spec.js
new file mode 100644
index 0000000..4f0d98a
--- /dev/null
+++ b/test/cases/serializable-shadow-roots/serializable-shadow-roots.spec.js
@@ -0,0 +1,64 @@
+/*
+ * Use Case
+ * Run wcc using getHTML in a component with a variety of serializable and serializableShadowRoots values.
+ *
+ * User Result
+ * Should return an element with no content and an element with content.
+ *
+ * User Workspace
+ * src/
+ *   index.js
+ *   components/
+ *     serializable-non-ssr-component.js
+ *     serializable-ssr-component.js
+ *     unserializable-non-ssr-component.js
+ *     unserializable-ssr-component.js
+ */
+
+import chai from 'chai';
+import { JSDOM } from 'jsdom';
+import { renderToString } from '../../../src/wcc.js';
+
+const expect = chai.expect;
+
+describe('Run WCC For ', function () {
+  const LABEL = 'Custom Elements w/ Serializable Shadow Roots';
+  let dom;
+
+  before(async function () {
+    const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
+    dom = new JSDOM(html);
+  });
+
+  describe(LABEL, function () {
+    it('should have a serializable-ssr-component with an h2 tag with textContent equal to "Serializable Component with serializableShadowRoots"', function () {
+      expect(
+        dom.window.document.querySelector('serializable-ssr-component template[shadowrootmode="open"]').innerHTML.trim()
+      ).to.equal('<h2>Serializable Component with serializableShadowRoots</h2>');
+    });
+
+    it('should have a serializable-non-ssr-component with no innerHTML', function () {
+      expect(
+        dom.window.document
+          .querySelector('serializable-non-ssr-component template[shadowrootmode="open"]')
+          .innerHTML.trim()
+      ).to.equal('');
+    });
+
+    it('should have an unserializable-ssr-component with no innerHTML', function () {
+      expect(
+        dom.window.document
+          .querySelector('unserializable-ssr-component template[shadowrootmode="open"]')
+          .innerHTML.trim()
+      ).to.equal('');
+    });
+
+    it('should have an unserializable-non-ssr-component with no innerHTML', function () {
+      expect(
+        dom.window.document
+          .querySelector('unserializable-non-ssr-component template[shadowrootmode="open"]')
+          .innerHTML.trim()
+      ).to.equal('');
+    });
+  });
+});
diff --git a/test/cases/serializable-shadow-roots/src/components/serializable-non-ssr-component.js b/test/cases/serializable-shadow-roots/src/components/serializable-non-ssr-component.js
new file mode 100644
index 0000000..f630689
--- /dev/null
+++ b/test/cases/serializable-shadow-roots/src/components/serializable-non-ssr-component.js
@@ -0,0 +1,20 @@
+export default class SerializableNonSSRComponent extends HTMLElement {
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    if (!this.shadowRoot) {
+      this.attachShadow({ mode: 'open', serializable: true });
+      const template = document.createElement('template');
+      template.innerHTML = `
+        <h2>Serializable Component w/o serializableShadowRoots</h2>
+      `;
+      this.shadowRoot.appendChild(template.content.cloneNode(true));
+
+      this.shadowRoot.innerHTML = this.getHTML({ serializableShadowRoots: false });
+    }
+  }
+}
+
+customElements.define('serializable-non-ssr-component', SerializableNonSSRComponent);
\ No newline at end of file
diff --git a/test/cases/serializable-shadow-roots/src/components/serializable-ssr-component.js b/test/cases/serializable-shadow-roots/src/components/serializable-ssr-component.js
new file mode 100644
index 0000000..ee6d559
--- /dev/null
+++ b/test/cases/serializable-shadow-roots/src/components/serializable-ssr-component.js
@@ -0,0 +1,20 @@
+export default class SerializableSSRComponent extends HTMLElement {
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    if (!this.shadowRoot) {
+      this.attachShadow({ mode: 'open', serializable: true });
+      const template = document.createElement('template');
+      template.innerHTML = `
+        <h2>Serializable Component with serializableShadowRoots</h2>
+      `;
+      this.shadowRoot.appendChild(template.content.cloneNode(true));
+
+      this.shadowRoot.innerHTML = this.getHTML({ serializableShadowRoots: true });
+    }
+  }
+}
+
+customElements.define('serializable-ssr-component', SerializableSSRComponent);
\ No newline at end of file
diff --git a/test/cases/serializable-shadow-roots/src/components/unserializable-non-ssr-component.js b/test/cases/serializable-shadow-roots/src/components/unserializable-non-ssr-component.js
new file mode 100644
index 0000000..79d7c06
--- /dev/null
+++ b/test/cases/serializable-shadow-roots/src/components/unserializable-non-ssr-component.js
@@ -0,0 +1,20 @@
+export default class UnserializableNonSSRComponent extends HTMLElement {
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    if (!this.shadowRoot) {
+      this.attachShadow({ mode: 'open', serializable: false });
+      const template = document.createElement('template');
+      template.innerHTML = `
+        <h2>Unserializable Component w/o serializableShadowRoots</h2>
+      `;
+      this.shadowRoot.appendChild(template.content.cloneNode(true));
+
+      this.shadowRoot.innerHTML = this.getHTML({ serializableShadowRoots: false });
+    }
+  }
+}
+
+customElements.define('unserializable-non-ssr-component', UnserializableNonSSRComponent);
\ No newline at end of file
diff --git a/test/cases/serializable-shadow-roots/src/components/unserializable-ssr-component.js b/test/cases/serializable-shadow-roots/src/components/unserializable-ssr-component.js
new file mode 100644
index 0000000..ab802f9
--- /dev/null
+++ b/test/cases/serializable-shadow-roots/src/components/unserializable-ssr-component.js
@@ -0,0 +1,20 @@
+export default class UnserializableSSRComponent extends HTMLElement {
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    if (!this.shadowRoot) {
+      this.attachShadow({ mode: 'open', serializable: false });
+      const template = document.createElement('template');
+      template.innerHTML = `
+        <h2>Unserializable Component with serializableShadowRoots</h2>
+      `;
+      this.shadowRoot.appendChild(template.content.cloneNode(true));
+
+      this.shadowRoot.innerHTML = this.getHTML({ serializableShadowRoots: true });
+    }
+  }
+}
+
+customElements.define('unserializable-ssr-component', UnserializableSSRComponent);
\ No newline at end of file
diff --git a/test/cases/serializable-shadow-roots/src/index.js b/test/cases/serializable-shadow-roots/src/index.js
new file mode 100644
index 0000000..c333e2c
--- /dev/null
+++ b/test/cases/serializable-shadow-roots/src/index.js
@@ -0,0 +1,17 @@
+import './components/serializable-ssr-component.js';
+import './components/serializable-non-ssr-component.js';
+import './components/unserializable-ssr-component.js';
+import './components/unserializable-non-ssr-component.js';
+
+export default class HomePage extends HTMLElement {
+  connectedCallback() {
+    this.innerHTML = `
+      <serializable-ssr-component></serializable-ssr-component>
+      <serializable-non-ssr-component></serializable-non-ssr-component>
+      <unserializable-ssr-component></unserializable-ssr-component>
+      <unserializable-non-ssr-component></unserializable-non-ssr-component>
+    `;
+  }
+}
+
+customElements.define('wcc-home', HomePage);
\ No newline at end of file
diff --git a/test/cases/shadowrootmode/shadowrootmode.spec.js b/test/cases/shadowrootmode/shadowrootmode.spec.js
new file mode 100644
index 0000000..5133b9f
--- /dev/null
+++ b/test/cases/shadowrootmode/shadowrootmode.spec.js
@@ -0,0 +1,44 @@
+/*
+ * Use Case
+ * Run wcc against two components, one with an open shadow root and one with a closed shadow root.
+ *
+ * User Result
+ * Should return the expected attribute value for shadowrootmode on the template tag based on the shadow root mode of each component.
+ *
+ * User Workspace
+ * src/
+ *   index.js
+ *   components/
+ *     closed-shadow-component.js
+ *     open-shadow-component.js
+ */
+
+import chai from 'chai';
+import { JSDOM } from 'jsdom';
+import { renderToString } from '../../../src/wcc.js';
+
+const expect = chai.expect;
+
+describe('Run WCC For ', function () {
+  const LABEL = 'Custom Elements w/ Closed and Open Shadowrootmode';
+  let dom;
+
+  before(async function () {
+    const { html } = await renderToString(new URL('./src/index.js', import.meta.url));
+    dom = new JSDOM(html);
+  });
+
+  describe(LABEL, function () {
+    it('should have exactly one open shadowrootmode template', function () {
+      expect(
+        dom.window.document.querySelectorAll('open-shadow-component template[shadowrootmode="open"]').length
+      ).to.equal(1);
+    });
+
+    it('should have exactly one closed shadowrootmode template', function () {
+      expect(
+        dom.window.document.querySelectorAll('closed-shadow-component template[shadowrootmode="closed"]').length
+      ).to.equal(1);
+    });
+  });
+});
diff --git a/test/cases/shadowrootmode/src/components/closed-shadow-component.js b/test/cases/shadowrootmode/src/components/closed-shadow-component.js
new file mode 100644
index 0000000..cae61b6
--- /dev/null
+++ b/test/cases/shadowrootmode/src/components/closed-shadow-component.js
@@ -0,0 +1,19 @@
+export default class ClosedShadowComponent extends HTMLElement {
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+
+    if (!this.shadowRoot) {
+      this.attachShadow({ mode: 'closed' });
+      const template = document.createElement('template');
+      template.innerHTML = `
+        <h2>Shadow Root Closed</h2>
+      `;
+      this.shadowRoot.appendChild(template.content.cloneNode(true));
+    }
+  }
+}
+
+customElements.define('closed-shadow-component', ClosedShadowComponent);
\ No newline at end of file
diff --git a/test/cases/shadowrootmode/src/components/open-shadow-component.js b/test/cases/shadowrootmode/src/components/open-shadow-component.js
new file mode 100644
index 0000000..1968c18
--- /dev/null
+++ b/test/cases/shadowrootmode/src/components/open-shadow-component.js
@@ -0,0 +1,19 @@
+export default class OpenShadowComponent extends HTMLElement {
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+
+    if (!this.shadowRoot) {
+      this.attachShadow({ mode: 'open' });
+      const template = document.createElement('template');
+      template.innerHTML = `
+        <h2>Shadow Root Open</h2>
+      `;
+      this.shadowRoot.appendChild(template.content.cloneNode(true));
+    }
+  }
+}
+
+customElements.define('open-shadow-component', OpenShadowComponent);
diff --git a/test/cases/shadowrootmode/src/index.js b/test/cases/shadowrootmode/src/index.js
new file mode 100644
index 0000000..b25e284
--- /dev/null
+++ b/test/cases/shadowrootmode/src/index.js
@@ -0,0 +1,17 @@
+import './components/open-shadow-component.js';
+import './components/closed-shadow-component.js';
+
+export default class HomePage extends HTMLElement {
+  constructor() {
+    super();
+  }
+
+  connectedCallback() {
+    this.innerHTML = `
+      <open-shadow-component></open-shadow-component>
+      <closed-shadow-component></closed-shadow-component>
+    `;
+  }
+}
+
+customElements.define('wcc-home', HomePage);
\ No newline at end of file