Skip to content

Instantly share code, notes, and snippets.

@treshugart
Last active May 6, 2024 05:01
Show Gist options
  • Save treshugart/e19c2be255448cd31d261f2e8db2127c to your computer and use it in GitHub Desktop.
Save treshugart/e19c2be255448cd31d261f2e8db2127c to your computer and use it in GitHub Desktop.
Pseudo shadow DOM at the custom element level. When element is updated, `childNodes` is set, thus it's a single entry point for updates. Custom distribution is required.

Shadow Node

Instead of patching the entire set of DOM interfaces, this implementation only paches a single custom element. It provides the bare minimum DOM encapsulation for the element it is applied to, allowing for seamless integration with other libraries and frameworks.

Usage

All you have to do is extend the mixin with an optional base class and then implement a childNodes getter / setter which does the necessary changes to your element based on the complete child list. This keeps the implementation dumb and allows you to figure out how you will distribute / diff / whatever using the updated child list.

When using this childNodes is an array of real DOM nodes.

import ShadowNode from './shadow-node';

class CustomElement extends ShadowNode(HTMLElement) {
  get childNodes () {
    return this._childNodes;
  }
  set childNodes (childNodes) {
    this._childNodes = childNodes;
    doSomethingWith(childNodes);
  }
}

customElements.define('custom-element', CustomElement);

With SkateJS:

/* @jsx h */

import { Component, h } from 'skatejs';
import ShadowNode from './shadow-node';

class CustomElement extends ShadowNode(Component) {
  static props = {
    childNodes: null
  }
  renderCallback ({ childNodes }) {
    return <span>{childNodes.map(n => n.textContent).join('')}</span>;
  }
}

customElements.define('custom-element', CustomElement);

Why

This is necessary because if you do something like the following in React:

<div>
  <custom-element>
    <light-dom />
  </custom-element>
</div>

If <custom-element /> has it's own template and distributes <light-dom /> somewhere in its render tree, or removes it altogether, then React (and all other vDOM libs) will complain on subsequent renders because <light-dom /> was moved and the current state doesn't match the previous state.

How

What this mixin does, is make it look like <light-dom /> never moved. The exposed shadowRoot property (or return value from attachShadow()) is a proxy element that renders directly to the host node. Child accessors and mutators work with a fake childlist that maintains the original appearance, thus tricking anything working with your custom element.

What

This doesn't patch anything globally; it's isolated specifically to the elements that inherit the behaviour. This means that you retain native behaviour and performance on nodes that don't use it.

Todo

  • Implement declarative <slot /> for auto distribution (this complicates SSR because you have to convey default slot content and slot state, if emulating native). Maybe the best way here would be to add a declarative way of slotting, that doesn't emulate all of the native behaviour. The question then is, how does default content work, because it is a common use case for dynamic lists / tables.
  • Implement :host selector.
  • See if this enables full server-side rendering support (will probaby have to add a flag so no initial distribution occurs).
  • Find easier way to do manual distribution.
  • Consider using childrenChangedCallback() instead of a childNodes setter (setter works better for Skate / functional prop-based stuff).
  • Use proxy instead of actual element for shadowRoot.
  • Fully implement outerHTML setter (doesn't patch the outer node, only inner content).
  • Peformance test (currently using functional stuff: concat etc, rather than mutators like push).
  • Try and find way to undo re-parenting on light node if it's moved by the ancestor tree to somewhere outside of the custom element (possibly unlikely use-case??).
/** @jsx h */
// You only need custom elements for this!!!
import 'skatejs-web-components/src/native-shim';
import { Component, define, h, prop } from 'skatejs';
import ShadowNode, { scopeCss, scopeTree } from './shadow-node';
// Converts real DOM nodes into Incremental DOM nodes.
//
// This is orthogonal to this gist, but makes it so we can distribute real
// nodes as if they were virtual nodes.
function toVdom (node) {
const { nodeType } = node;
if (nodeType) {
return nodeType === 1
? <node.localName>{toVdom(node.childNodes)}</node.localName>
// Workaround for https://github.com/skatejs/skatejs/issues/1093
: <span>{node.textContent}</span>;
}
if (node.map) {
return node.map(toVdom);
}
if (node.item) {
return Array.from(node).map(toVdom);
}
return node;
}
// This is the base class that provides the default childNodes prop that
// queues a render when set.
class Base extends ShadowNode(Component) {
static props = {
childNodes: {
coerce: v => Array.from(v)
}
}
}
// Abstracts the imperative scoping calls behind a declarative interface.
const Style = define(class extends Base {
static get props () {
return {
...super.props,
...{ root: null }
};
}
renderCallback ({ root, textContent }) {
scopeTree(root);
return <style>{scopeCss(root, textContent)}</style>
}
});
const App = define(class extends Base {
static get props () {
return {
...super.props,
...{
items: prop.array(),
title: prop.string()
}
};
}
renderCallback ({ items, title }) {
return [
<style>{`
body {
font-family: Helvetica;
}
`}</style>,
<Style root={this}>{`
span {
font-size: 1.4rem;
}
`}</Style>,
<div>
<span>{title}</span>
<Todo>
{/* What's really powerful about this method is that <Todo /> can
render a list and each <Item /> can be wrapped in an <li />. You
can't do that with native Shadow DOM and slots. This would also
work for creating table components. */}
{items.map(i => <Item>{i}</Item>)}
</Todo>
</div>
];
}
});
const Todo = define(class extends Base {
renderCallback ({ children }) {
return (
<ul>
{/* Children must be converted back to vDOM for iDOM to render
properly. Also, as noted above, we're wrapping children in proper
list items, which you can't do natively with slots. */}
{children.map(c => <li>{toVdom(c)}</li>)}
</ul>
);
}
});
const Item = define(class extends Base {
renderCallback ({ textContent }) {
return [
<Style root={this}>{`
span {
color: #666;
font-size: 1rem;
}
`}</Style>,
<span>{textContent}</span>
];
}
});
const app = new App();
app.items = ['Item 1', 'Item 2'];
app.title = 'Wat do?'
document.getElementById('root').appendChild(app);
const { document, DocumentFragment, HTMLElement, NodeFilter, NodeList } = window;
const { SHOW_ELEMENT } = NodeFilter;
const parser = document.createElement('div');
const _childNodes = Symbol();
const _scopeName = Symbol();
const _scopeExists = Symbol();
function createTreeWalker (root) {
return document.createTreeWalker(root, SHOW_ELEMENT);
}
function doIfIndex (host, refNode, callback, otherwise) {
const chren = host.childNodes;
const index = chren.indexOf(refNode);
if (index > -1) {
callback(index, chren);
} else if (otherwise) {
otherwise(chren);
}
}
function makeNodeList (nodeList = []) {
if (nodeList instanceof NodeList) {
nodeList = Array.from(nodeList);
}
nodeList.item = function (index) {
return this[index];
};
return nodeList;
}
function ensureArray (refNode) {
return refNode instanceof DocumentFragment
? Array.from(refNode.childNodes)
: [refNode];
}
function reParentOne (refNode, newHost) {
Object.defineProperty(refNode, 'parentNode', { configurable: true, value: newHost });
return refNode;
}
function reParentAll (nodeList, newHost) {
return nodeList.map(n => reParentOne(n, newHost));
}
function getScopeName (host) {
return host[_scopeName] || (host[_scopeName] = 'scoped-' + Math.random().toString(36).substring(2, 8));
}
export default (Base = HTMLElement) => class extends Base {
get children () {
return this.childNodes.filter(n => n.nodeType === 1);
}
get firstChild () {
return this.childNodes[0] || null;
}
get lastChild () {
const chs = this.childNodes;
return chs[chs.length - 1] || null;
}
get innerHTML () {
return this.childNodes.map(n => n.innerHTML || n.textContent).join('');
}
set innerHTML (innerHTML) {
parser.innerHTML = innerHTML;
this.childNodes = reParentAll(makeNodeList(parser.childNodes), this);
}
get outerHTML () {
const { attributes, localName } = this;
const attrsAsString = Array.from(attributes).map(a => ` ${a.name}="${a.value}"`);
return `<${localName}${attrsAsString}>${this.innerHTML}</${localName}>`;
}
set outerHTML (outerHTML) {
// TODO get attributes and apply to custom element host.
parser.outerHTML = outerHTML;
this.childNodes = reParentAll(makeNodeList(parser.childNodes), this);
}
get textContent () {
return this.childNodes.map(n => n.textContent).join('');
}
set textContent (textContent) {
this.childNodes = reParentAll(makeNodeList([document.createTextNode(textContent)]), this);
}
appendChild (newNode) {
this.childNodes = this.childNodes.concat(reParentAll(ensureArray(newNode), this));
return newNode;
}
insertBefore (newNode, refNode) {
newNode = reParentAll(ensureArray(newNode), this);
doIfIndex(this, refNode, (index, chren) => {
this.childNodes = chren.concat(chren.slice(0, index + 1), newNode, chren.slice(index));
}, (chren) => {
this.childNodes = chren.concat(newNode);
});
return newNode;
}
removeChild (refNode) {
doIfIndex(this, refNode, (index, chren) => {
reParentOne(refNode, null);
this.childNodes = chren.splice(index, 1).concat();
});
return refNode;
}
replaceChild (newNode, refNode) {
doIfIndex(this, refNode, (index, chren) => {
reParentOne(refNode, null);
this.childNodes = chren.concat(chren.slice(0, index), reParentAll(ensureArray(newNode)), chren.slice(index));
});
return refNode;
}
attachShadow ({ mode }) {
// Currently we just use an extra element. Working around this involves
// using a proxy element that modifies the host, but re-parents each node
// to look like the parent is the shadow root.
const shadowRoot = document.createElement('shadowroot');
// Remove existing content and add the shadow root to append to. Appending
// the shadow root isn't necessary if using the proxy as noted above, but
// resetting the innerHTML is.
super.innerHTML = '';
super.appendChild(shadowRoot);
// Emulate native { mode }.
if (mode === 'open') {
Object.defineProperty(this, 'shadowRoot', { value: shadowRoot });
}
return shadowRoot;
}
};
// CSS scoping exports.
//
// Special thanks to Jason Miller (@_developit) for this idea.
// Link: https://jsfiddle.net/developit/vLzdhcg0/
// Scopes the CSS to the given root node.
export function scopeCss (root, css) {
const scopeName = getScopeName(root);
const tokenizer = /(?:(\/\*[\s\S]*?\*\/|\burl\([\s\S]*?\)|(['"])[\s\S]*?\2)|(\{)|(\}))/g;
let namespacedCss = '';
let index = 0;
let token, before;
while ((token = tokenizer.exec(css))) {
before = css.substring(index, token.index);
if (token[3]) {
before = before.replace(/((?:^|\s*[,\s>+~]|)\s*)(?:([a-z*][^{,\s>+~]*)|([^{,\s>+~]+))/gi, `$1$2[${scopeName}]$3`);
}
namespacedCss += before + token[0];
index = token.index + token[0].length;
}
return namespacedCss + css.substring(index);
}
// Scopes the tree at the given root.
export function scopeTree (root) {
const scopeName = getScopeName(root);
const walker = createTreeWalker(root);
while (walker.nextNode()) {
const { currentNode } = walker;
if (!currentNode[_scopeExists]) {
currentNode[_scopeExists] = true;
currentNode.setAttribute(scopeName, '');
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment