Repository: ProjectEvergreen/wcc (js)

Issue:
Issue Setting Element Properties
## Type of Change
Enhancement/Bug

## Summary
First of all, I just want to say what you're doing here is fantastic... this is awesome! I've got wcc working with SSR for completely native web components, using a custom renderer or lit-ssr, both with working client-side hydration.

My main issue is when setting properties using a custom renderer or lit-html's render (not LitElement) with template literals, element properties in rendered templates aren't passed down to child elements. This happens with both the light dom and shadow dom.

## Why is it happening?

I'm sure I'm missing a lot here, but I think this is happening because wcc is using .innerHTML instead of .appendChild/.childNodes when adding/retrieving child nodes which causes all element properties to be discarded. I notice the dom-shim for appendChild also uses innerHTML. Is there a way to shim appendChild that retains the element's properties? This is just a guess, but I believe that by using appendChild/childNodes with a true appendChild shim instead of innerHTML, you can retain all of the properties set by renderers like lit-ssr or custom renderers like the one below. It looks like with parse 5, you can just use childNodes.push to append children. Additionally, I put some chatGPT generated shims for appendChild, removeChild, etc. at the bottom of this request in case it's any help.

**This would be huge for web components because you could SSR or SSG entire intricate render templates, passing down complex objects, maps, sets, etc. to child components, rendering the initial state on the server, without setting dozens of attributes. This would allow for creating really interesting, reusable light dom and shadow dom web component systems that allow for a complex initial render that can be cached for performance but that become deeply reactive when the client side renderer hydrates the templates. It also makes the idea of complex, _isomorphic_, vanilla web components a reality which may seem impossible otherwise.**

Studying the wcc codebase, I'm sure this goes deeper than I understand but man, this would be really powerful. I'm hoping you can fill in the gaps and see a way to make this happen!

## Details
Here is a custom ssr renderer I'm using (with everything not related to this issue removed) but this can be tested with lit-ssr's render as well:

```javascript
import { parse } from 'node-html-parser';

const isFalsy = (str) => ['false', 'null', 'undefined', '0', '-0', 'NaN', '0n', '-0n'].includes(str);

export const render = (content, container, deps) => {
  const parsedContent = parse(content());
  const elements = parsedContent.getElementsByTagName('*');

  elements.forEach((element) => {
    const attributes = element.attributes;

    Object.entries(attributes).forEach(([attribute, value]) => {
      if (attribute.startsWith('.')) {
        const propName = attribute.substring(1);
        element.removeAttribute(attribute);
        element[propName] = deps?.[value] ?? value;
      }
    });
  });

  // This is using the wcc appendChild shim which I'm sure is part of the 
  // problem here since it just uses innerHTML
  if (container.shadowRoot) {
    const template = document.createElement('template');
    template.appendChild(parsedContent);
    container.shadowRoot.appendChild(template.content.cloneNode(true));
  } else {
    container.appendChild(parsedContent);
  }
};
```

Example components:

```javascript
customElements.define(
  'parent-element',
  class extends HTMLElement {
    connectedCallback() {
      const item = { message: 'Hello World' };
      this.test = 'This works';
      render(() => `<child-element .message="${item.message}"></child-element><p>${this.test}</p>`, this);
    }
  }
);

customElements.define(
  'child-element',
  class extends HTMLElement {
    connectedCallback() {
      render(() => `<p>${this.message}</p>`, this);
    }
  }
);

```

Here's how this renderer works passing down an object. Again, this doesn't work server side with wcc, but works once hydration kicks in:

```javascript
customElements.define(
  'parent-element',
  class extends HTMLElement {
    connectedCallback() {
      const item = { message: 'Hello World' };
      this.test = 'This works';
      render(() => `<child-element .item="item"></child-element><p>${this.test}</p>`, this, { item });
    }
  }
);

customElements.define(
  'child-element',
  class extends HTMLElement {
    connectedCallback() {
      render(() => `<p>${this.item?.message}</p>`, this);
    }
  }
);
```

## What do I expect?

I would expect the paragraph tag to display "Hello World" on the server side render in both scenarios. The console.log in the render function shows that the element's message property is successfully being set server side.


## What actually happens?

The paragraph element in child-element displays "undefined" 

With the above components, "This works" does render server side with wcc which shows that element properties are renderable, they're just being lost somewhere when being passed to child elements.

Once the client renderer kicks in and the component is hydrated, things display as you would expect ("Hello World").

What are your thoughts? Thank you in advance!

## Some chatGPT shims
Btw, here are some quick chatGPT shims for parentNode, childNodes, appendChild, and removeChild if it's any help:

```javascript
// parentNode shim
if (!Element.prototype.parentNode) {
  Object.defineProperty(Element.prototype, 'parentNode', {
    get: function () {
      return this._parentNode || null;
    },
    set: function (newParent) {
      this._parentNode = newParent;
    },
    configurable: true
  });
}

// childNodes shim
if (!Element.prototype.childNodes) {
  Object.defineProperty(Element.prototype, 'childNodes', {
    get: function () {
      this._childNodes = this._childNodes || [];
      return this._childNodes;
    },
    configurable: true
  });
}

// appendChild shim
if (!Element.prototype.appendChild) {
  Element.prototype.appendChild = function (child) {
    if (child.parentNode) {
      child.parentNode.removeChild(child); // Remove from current parent
    }

    // Ensure childNodes exists
    this.childNodes = this.childNodes || [];

    // Add child to childNodes array
    this.childNodes.push(child);

    // Update the child's parentNode
    child.parentNode = this;

    return child;
  };
}

// removeChild shim
if (!Element.prototype.removeChild) {
  Element.prototype.removeChild = function (child) {
    if (!this.childNodes || !this.childNodes.length) return null;

    const index = this.childNodes.indexOf(child);
    if (index === -1) return null;

    // Remove the child from the array
    this.childNodes.splice(index, 1);

    // Clear the child's parentNode reference
    child.parentNode = null;

    return child;
  };
}
```


Pull request description:
bug/issue 170 handle support for element properties
## Related Issue
Resolves #170 

## Summary of Changes
- Simplified how node properties are assigned to a fresh initialized element which allows for element properties without having to change the API
- Added a test case for setting element properties with a custom renderer

### Loosely related
- Removed excessive commenting in dom-shim
- Added a test case for setAttribute

Patch:
diff --git a/src/dom-shim.js b/src/dom-shim.js
index b25255d..a53190d 100644
--- a/src/dom-shim.js
+++ b/src/dom-shim.js
@@ -14,7 +14,7 @@ function isShadowRoot(element) {
 
 function deepClone(obj, map = new WeakMap()) {
   if (obj === null || typeof obj !== 'object') {
-    return obj; // Return primitives or functions as-is
+    return obj;
   }
 
   if (typeof obj === 'function') {
@@ -90,7 +90,7 @@ class Node extends EventTarget {
     const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;
 
     if (node.parentNode) {
-      node.parentNode?.removeChild?.(node); // Remove from current parent
+      node.parentNode?.removeChild?.(node);
     }
 
     if (node.nodeName === 'template') {
@@ -130,24 +130,21 @@ class Node extends EventTarget {
 
   get textContent() {
     if (this.nodeName === '#text') {
-      return this.value || ''; // Text nodes should return their value
+      return this.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.value = value;
       textNode.parentNode = this;
       this.childNodes.push(textNode);
     }
@@ -171,29 +168,25 @@ class Element extends Node {
     return this.shadowRoot && serializableShadowRoots && this.shadowRoot.serializable ? this.shadowRoot.innerHTML : '';
   }
 
-  // Serialize the content of the DocumentFragment when getting innerHTML
   get innerHTML() {
     const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes;
     return childNodes ? serialize({ childNodes }) : '';
   }
 
   set innerHTML(html) {
-    (this.nodeName === 'template' ? this.content : this).childNodes = getParse(html)(html).childNodes; // Replace content's child nodes
+    (this.nodeName === 'template' ? this.content : this).childNodes = getParse(html)(html).childNodes;
   }
 
   hasAttribute(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) {
diff --git a/src/wcc.js b/src/wcc.js
index b328cf3..d394f17 100644
--- a/src/wcc.js
+++ b/src/wcc.js
@@ -144,7 +144,6 @@ async function getTagName(moduleURL) {
 }
 
 async function initializeCustomElement(elementURL, tagName, node = {}, definitions = [], isEntry, props = {}) {
-  const { attrs = [], childNodes = [] } = node;
 
   if (!tagName) {
     const depth = isEntry ? 1 : 0;
@@ -161,11 +160,7 @@ async function initializeCustomElement(elementURL, tagName, node = {}, definitio
   if (element) {
     const elementInstance = new element(data); // eslint-disable-line new-cap
 
-    elementInstance.childNodes = childNodes;
-
-    attrs.forEach((attr) => {
-      elementInstance.setAttribute(attr.name, attr.value);
-    });
+    Object.assign(elementInstance, node);
 
     await elementInstance.connectedCallback();
 


Test patch:
diff --git a/test/cases/element-props/element-props.spec.js b/test/cases/element-props/element-props.spec.js
new file mode 100644
index 0000000..c66b6e8
--- /dev/null
+++ b/test/cases/element-props/element-props.spec.js
@@ -0,0 +1,37 @@
+/*
+ * Use Case
+ * Run wcc against a component that passes properties to a child component.
+ *
+ * User Result
+ * Should return the expected HTML output based on the content of the passed property.
+ *
+ * User Workspace
+ * src/
+ *   index.js
+ *   renderer.js
+ *   components/
+ *     prop-passer.js
+ *     prop-receiver.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/ Element Properties';
+  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 prop-receiver component with a heading tag with text content equal to "bar"', function () {
+      expect(dom.window.document.querySelector('prop-receiver h2').textContent).to.equal('bar');
+    });
+  });
+});
diff --git a/test/cases/element-props/src/components/prop-passer.js b/test/cases/element-props/src/components/prop-passer.js
new file mode 100644
index 0000000..d7f2c7b
--- /dev/null
+++ b/test/cases/element-props/src/components/prop-passer.js
@@ -0,0 +1,10 @@
+import { html, render } from '../renderer.js';
+
+export default class PropPasser extends HTMLElement {
+  connectedCallback() {
+    const data = { foo: 'bar' };
+    render(html`<prop-receiver .data=${data}></prop-receiver>`, this);
+  }
+}
+
+customElements.define('prop-passer', PropPasser);
\ No newline at end of file
diff --git a/test/cases/element-props/src/components/prop-receiver.js b/test/cases/element-props/src/components/prop-receiver.js
new file mode 100644
index 0000000..ade7717
--- /dev/null
+++ b/test/cases/element-props/src/components/prop-receiver.js
@@ -0,0 +1,9 @@
+import { html, render } from '../renderer.js';
+
+export default class ProperReceiver extends HTMLElement {
+  connectedCallback() {
+    render(html`<h2>${this.data.foo}</h2>`, this);
+  }
+}
+
+customElements.define('prop-receiver', ProperReceiver);
\ No newline at end of file
diff --git a/test/cases/element-props/src/index.js b/test/cases/element-props/src/index.js
new file mode 100644
index 0000000..229586c
--- /dev/null
+++ b/test/cases/element-props/src/index.js
@@ -0,0 +1,10 @@
+import './components/prop-passer.js';
+import './components/prop-receiver.js';
+
+export default class ElementProps extends HTMLElement {
+  connectedCallback() {
+    this.innerHTML = '<prop-passer></prop-passer>';
+  }
+}
+
+customElements.define('element-props', ElementProps);
\ No newline at end of file
diff --git a/test/cases/element-props/src/renderer.js b/test/cases/element-props/src/renderer.js
new file mode 100644
index 0000000..22f0e98
--- /dev/null
+++ b/test/cases/element-props/src/renderer.js
@@ -0,0 +1,90 @@
+import { parseFragment } from 'parse5';
+
+function generateUUID() {
+  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+    const r = Math.floor(Math.random() * 16);
+    const v = c === 'x' ? r : (r % 4) + 8;
+    return v.toString(16);
+  });
+}
+
+function removeAttribute(element, attribute) {
+  element.attrs = element.attrs.filter((attr) => attr.name !== attribute);
+}
+
+function handlePropertyAttribute(element, attribute, value, deps) {
+  const propName = attribute.substring(1);
+  removeAttribute(element, attribute);
+  if (!element.props) { element.props = {}; }
+  element[propName] = deps[value] ?? value;
+}
+
+function buildStringFromTemplate(template) {
+  const { strings, values } = template;
+
+  if (!strings || !values) { 
+    return { string: '', deps: {} };
+  }
+
+  const stringParts = [];
+  const deps = {};
+  let isElement = false;
+
+  strings.reduce((acc, stringAtIndex, index) => {
+    acc.push(stringAtIndex);
+
+    isElement =
+      stringAtIndex.includes('<') || stringAtIndex.includes('>')
+        ? stringAtIndex.lastIndexOf('<') > stringAtIndex.lastIndexOf('>')
+        : isElement;
+
+    const valueAtIndex = values[index];
+
+    if (valueAtIndex != null) {
+      const isPrimitive = typeof valueAtIndex === 'string' || typeof valueAtIndex === 'number';
+      const valueKey = isPrimitive ? null : generateUUID() + index;
+      const lastPart = acc[acc.length - 1];
+      const needsQuotes = isElement && !lastPart.endsWith('"');
+      acc.push(`${needsQuotes ? '"' : ''}${valueKey !== null ? valueKey : valueAtIndex}${needsQuotes ? '"' : ''}`);
+
+      if (valueKey) {
+        deps[valueKey] = valueAtIndex;
+      }
+    }
+    return acc;
+  }, stringParts);
+
+  return { string: stringParts.join(''), deps };
+}
+
+function setAttributes(childNodes, deps) {
+  childNodes.forEach((element, index)=>{
+    const { attrs, nodeName } = element;
+    if (nodeName === '#comment') { return; }
+    attrs?.forEach(({ name, value }) => {
+      if (name.startsWith('.')) {
+        handlePropertyAttribute(childNodes[index], name, value, deps);
+      }
+    });
+    if (element.childNodes) { 
+      setAttributes(element.childNodes, deps);
+    }
+  });
+}
+
+export function render(content, container) {
+  const { string, deps } = buildStringFromTemplate(content);
+  const parsedContent = parseFragment(string);
+
+  setAttributes(parsedContent.childNodes, deps);
+  const template = document.createElement('template');
+  template.content.childNodes = parsedContent.childNodes;
+  container.appendChild(template.content.cloneNode(true));
+}
+
+export const html = (strings, ...values) => {
+  return {
+    strings,
+    values
+  };
+};
\ No newline at end of file
diff --git a/test/cases/set-attribute/set-attribute.spec.js b/test/cases/set-attribute/set-attribute.spec.js
new file mode 100644
index 0000000..a15957e
--- /dev/null
+++ b/test/cases/set-attribute/set-attribute.spec.js
@@ -0,0 +1,33 @@
+/*
+ * Use Case
+ * Run wcc against a component which sets an attribute of a child heading.
+ *
+ * User Result
+ * Should return a component with a child h2 with the expected attribute and attribute value.
+ *
+ * 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 using setAttribute';
+  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 the "foo" attribute equal to "bar"', function () {
+      expect(dom.window.document.querySelector('set-attribute-element h2').getAttribute('foo')).to.equal('bar');
+    });
+  });
+});
diff --git a/test/cases/set-attribute/src/index.js b/test/cases/set-attribute/src/index.js
new file mode 100644
index 0000000..9dca477
--- /dev/null
+++ b/test/cases/set-attribute/src/index.js
@@ -0,0 +1,10 @@
+export default class SetAttributeElement extends HTMLElement {
+
+  connectedCallback() {
+    const heading = document.createElement('h2');
+    heading.setAttribute('foo', 'bar');
+    this.appendChild(heading);
+  }
+}
+
+customElements.define('set-attribute-element', SetAttributeElement);
\ No newline at end of file