-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Issue Setting Element Properties #170
Comments
Ok, I had some time yesterday and today to re-examine this and I've got it figured out! I'm still working out the shadowRoot bit with my custom renderer but it's very close. This is so powerful so I hope you'll consider integrating it into wcc. I had to basically do what I mentioned - remove the innerHTML stuff in wcc, create an appendChild shim that actually appends children, change the innerHTML shim to serialize the children instead of just returning an innerHTML string, and allow the user to pass in a "props" property on nodes that are passed into initializeCustomElement. This allows people to write whatever kind of renderers they want and apply properties to child elements, server-side. But it works! I've got it working with lit-html and my custom renderer. I'll work on this a bit more across the next week and test different use cases. When I have more time, I'll write up exactly what I did (edit: I realize now I pretty much did that) and/or open a pull request if you'd like. So far, I've only modified renderFromHTML and haven't looked at renderToString but I think it should be the same minimal change. Let me know your thoughts! Basically, it comes down to this: In renderComponentRoots, comment out the unnecessary conversion to innerHTML and back to a parsed tree and remove references to elementTree. Instead, we just work directly with the elementInstance: async function renderComponentRoots(tree, definitions) {
for (const node of tree.childNodes) {
if (node.tagName && node.tagName.indexOf('-') > 0) {
const { tagName } = node;
if (definitions[tagName]) {
const { moduleURL } = definitions[tagName];
const elementInstance = await initializeCustomElement(moduleURL, tagName, node, definitions);
if (elementInstance) {
const hasShadow = elementInstance.shadowRoot;
// const elementHtml = hasShadow
// ? elementInstance.getInnerHTML({ includeShadowRoots: true })
// : elementInstance.innerHTML;
// const elementTree = parseFragment(elementHtml);
const hasLight = elementInstance.childNodes > 0;
node.childNodes = node.childNodes.length === 0 && hasLight && !hasShadow
? elementInstance.childNodes
: hasShadow
? [...elementInstance.shadowRoot.childNodes, ...node.childNodes]
// This was elementInstance.childNodes but not sure how that makes sense so I changed it.
// If we get to this point, elementInstance.childNodes is 0, right?
: node.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.`);
}
}
if (node.childNodes && node.childNodes.length > 0) {
await renderComponentRoots(node, definitions);
}
// does this only apply to `<template>` tags?
if (node.content && node.content.childNodes && node.content.childNodes.length > 0) {
await renderComponentRoots(node.content, definitions);
}
}
return tree;
} In initializeCustomElement, change the name of the "props" argument to "dataProps" and then destructure a new value named "props" from the node passed in here. This allows people to pass properties down to childNodes from the props object in their renderer. Then, "props" is assigned to the elementInstance before connectedCallback async function initializeCustomElement(elementURL, tagName, node = {}, definitions = [], isEntry, dataProps = {}) {
const { attrs = [], childNodes = [], props = {} } = node;
if (!tagName) {
const depth = isEntry ? 1 : 0;
registerDependencies(elementURL, definitions, depth);
}
// https://github.com/ProjectEvergreen/wcc/pull/67/files#r902061804
// https://github.com/ProjectEvergreen/wcc/pull/159
const { href } = elementURL;
const element = customElements.get(tagName) ?? (await import(href)).default;
const dataLoader = (await import(href)).getData;
const data = dataProps
? dataProps
: dataLoader
? await dataLoader(dataProps)
: {};
if (element) {
const elementInstance = new element(data); // eslint-disable-line new-cap
// support for HTML (Light DOM) Web Components
elementInstance.innerHTML = renderLightDomChildren(childNodes);
attrs.forEach((attr) => {
elementInstance.setAttribute(attr.name, attr.value);
if (attr.name === 'hydrate') {
definitions[tagName].hydrate = attr.value;
}
});
// Assign props to elementInstance, allowing people to pass properties from parent web components to their childNodes
Object.assign(elementInstance, props);
await elementInstance.connectedCallback();
return elementInstance;
}
} Lastly, modify the shims like so (I'm sure these can be made better but hey, they seem to work): import {parseFragment, serialize} from 'parse5';
const isDocumentFragment = (element) => Object.getPrototypeOf(element).constructor.name === 'DocumentFragment';
function noop() { }
// https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet
class CSSStyleSheet {
insertRule() { }
deleteRule() { }
replace() { }
replaceSync() { }
}
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget
class EventTarget {
constructor() {
this.addEventListener = noop;
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/Node
// EventTarget <- Node
// TODO should be an interface?
class Node extends EventTarget {
constructor(){
super();
// this.innerHTML = '';
this.parentNode = null;
this.childNodes = [];
}
// eslint-disable-next-line
cloneNode(deep) {
return this;
}
set innerHTML(html) {
const parsedFragment = parseFragment(html);
this.childNodes = parsedFragment.childNodes; // Replace content's child nodes
}
// Serialize the content of the DocumentFragment when getting innerHTML
get innerHTML() {
return this.childNodes ? serialize(this.childNodes) : '';
}
appendChild(node) {
if (isDocumentFragment(node)) {
// console.log(node, this);
node.childNodes.forEach((childNode)=>{
this.childNodes.push(childNode);
})
} else {
// console.log("Appending", node);
if (node.parentNode) {
node.parentNode?.removeChild?.(node); // Remove from current parent
}
// Ensure childNodes exists
this.childNodes = this.childNodes || [];
// Add child to childNodes array
this.childNodes.push(node);
// Update the child's parentNode
node.parentNode = this;
return node;
}
}
removeChild(node) {
if (!this.childNodes || !this.childNodes.length) return null;
const index = this.childNodes.indexOf(node);
if (index === -1) return null;
// Remove the child from the array
this.childNodes.splice(index, 1);
// Clear the child's parentNode reference
node.parentNode = null;
return node;
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/Element
// EventTarget <- Node <- Element
class Element extends Node {
constructor() {
super();
this.shadowRoot = null;
this.attributes = {};
}
attachShadow(options) {
this.shadowRoot = new ShadowRoot(options);
return this.shadowRoot;
}
setAttribute(name, value) {
this.attributes[name] = value;
}
getAttribute(name) {
return this.attributes[name];
}
hasAttribute(name) {
return !!this.attributes[name];
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/Document
// EventTarget <- Node <- Document
class Document extends Node {
createElement(tagName) {
switch (tagName) {
case 'template':
return new HTMLTemplateElement();
default:
return new HTMLElement();
}
}
createDocumentFragment(html) {
return new DocumentFragment(html);
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
// EventTarget <- Node <- Element <- HTMLElement
class HTMLElement extends Element {
connectedCallback() { }
}
// https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment
// EventTarget <- Node <- DocumentFragment
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 = [];
}
// Allow appending children to the shadow root
appendChild(node) {
super.appendChild(node);
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement
// EventTarget <- Node <- Element <- HTMLElement <- HTMLTemplateElement
class HTMLTemplateElement extends HTMLElement {
constructor() {
super();
this.content = new DocumentFragment(); // Use DocumentFragment to store content
}
// Handle setting innerHTML by parsing the new HTML and appending it to the content fragment
set innerHTML(html) {
const parsedFragment = parseFragment(html);
this.content.childNodes = parsedFragment.childNodes; // Replace content's child nodes
}
// Serialize the content of the DocumentFragment when getting innerHTML
get innerHTML() {
return this.content ? serialize(this.content) : '';
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry
class CustomElementsRegistry {
constructor() {
// TODO this should probably be a set or otherwise follow the spec?
// https://github.com/ProjectEvergreen/wcc/discussions/145
this.customElementsRegistry = new Map();
}
define(tagName, BaseClass) {
// TODO this should probably fail as per the spec...
// e.g. if(this.customElementsRegistry.get(tagName))
// https://github.com/ProjectEvergreen/wcc/discussions/145
this.customElementsRegistry.set(tagName, BaseClass);
}
get(tagName) {
return this.customElementsRegistry.get(tagName);
}
}
// mock top level aliases (globalThis === window)
// https://developer.mozilla.org/en-US/docs/Web/API/Window
// make this "idempotent" for now until a better idea comes along - https://github.com/ProjectEvergreen/wcc/discussions/145
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; |
Hey @briangrider , thanks for taking the time to put this all together and appreciate you checking out WCC! 👋
Yeah, just thinking about it off the top of my head, this behavior seems to make sense, since I don't think I have invested much time in handling properties here in WCC, mainly focusing on reading off attributes. A quick glance at the test cases would seem to confirm that as well. 😅 Also, do I understand you correctly that you are using WCC under the hood to power I know part of the reason that I created WCC was because Lit's renderer didn't support
I am admittedly a novice when it comes to working with ASTs and the like and so definitely appreciate any keener eyes and minds to share ideas and improvements on the implementation here, but broadly speaking yes, what you are suggesting does seem like a much more robust way of handling the walking here. One thing that I think is valuable in this repo, the compiler and shim aside, is that I have a local dev sandbox and pretty decent test suite for both visualizing and validating the inputs / outputs of components and WCC. Definitely feel free to leverage those while playing around with any changes to see how new changes could impact existing test cases / demos.
Just a quick heads up, but wanted to call out I had landed a feature around handling Light DOM HTML a few days ago, so just wanted to make sure you were testing against the latest and greatest. You can see the changeset here for reference. I'm OK if this new approach supersedes that approach though, assuming all the behavior stays intact.
Interesting, I see that we can certainly achieve a lot by pulling parse5 into the DOM Shim directly, but a little hope of mine had been that the Shim could be dependency free, similar to how other polyfills have been implemented for previous Web Component (browser) APIs and thinking that maybe a community (protocol) shim could be realized. Not a big deal, but curious if Lit and others also do this? I know the dependencies in WCC are already a little bit all over the place, but just thought it would be worth a convo to see what your thoughts are here. Overall I think if it's just a bit of refactoring here to allow deeper SSR support of parent <> child components and making properties more viable during SSR while keeping all the outward behavior the same, then I think that would be a great contribution to WCC! 💯 It seems perhaps that we could probably break this down across two PRs if so:
|
Quick background... I'm a long time js/React dev working on my own personal projects across the last few years and started really investigating WCs a little over a month ago. Looking into things, I came across LitElement and similar options but none of them really fit how I want to work. I want to be able to extend HTMLElement without platform specific wrapper/abstraction classes. I don't want to have to compile anything... all of my code should work just as well in a Codepen (within reason and typescript aside) as it does in my project. I want components that I build to be isomorphic... I want to write them once and have them work both client-side and server-side. And I want to stay as close to the native web standards as possible while having access to all of the features and reactivity of something like React/Vue. Attributes/attributeChangedCallback is cool but I think attributes aren't always going to be the right solution. This led me to build my own reactivity framework across the past month which I think solves all of these problems. The main idea is that it is very modular. You can plug in your own renderer, autoloader, router, etc. You can use lit-html, or you can use the renderer I put together, or for simple projects that aren't super reactive, you can just use innerHTML, or you can build your own based on your usecase. This allows people to just plug in a new renderer as technology changes and other options come out. I also built an autoloader for lazy loading custom components and a very powerful router as well... both with as little abstraction as possible and fully modular. The framework doesn't require any wrapper classes, it incorporates all of the reactivity of React/Vue without a compiler or build step, it has a hooks system like React (these hooks do something anytime related reactive data changes) with both built in hooks and the ability to make custom hooks. It also has another hooks-like system similar to WordPress hooks which I call "inserts." These allow users to insert whatever code they want at certain milestones. The whole core library is currently 1.4kb min-zipped and I think is as stripped down as something like this could be. I'm testing it with rebuilding a React project across the next month or two but I'll let you know as soon as I release it publicly - I'd love to get your thoughts! The most recent step with building this thing out was figuring out SSR. That led me to lit-ssr first (where I saw your post asking about lit-ssr with HTMLElement web components), through Enhance, WebC, and finally to wcc. And wcc was the ticket! A simple platform that conforms to web standards with pretty much a single file's worth of code to do amazing stuff. No abstraction or build steps - it just works. The only problem for me is the properties thing.
This is awesome! Incredibly useful for sure.
Yes, I did see this. I ended up commenting out that bit and everything seems to be working fine but definitely needs more testing.
It's certainly possible to keep the shim dependency free but it would require basically rewriting exactly what parse5 does and maintaining it in parallel which doesn't make a lot of sense to me. If someone has already put together a great solution and is maintaining it, I think it makes sense to use it, especially because it is already a dependency of wcc itself. I don't think it prevents it from becoming adopted as a community shim but I'm not too sure how that works and if there is a set of standards somewhere that says community shims must be dependency free. Parse5 is, itself, dependency free and powers JSDOM so I don't think it's going anywhere. Apparently, node-html-parser is 3x faster and provides all of the same functionality (and more) so I'm not sure if that would be worth looking at as a replacement both in wcc and the shims. I believe parse5 is still the only fully spec-compliant HTML parser available for NodeJS. I was using it at first in my ssr-renderer and then switched to parse5 to better align with wcc. Node-html-parser updates seem more sporadic though and it does have two runtime dependencies. From what I can see, Lit's ssr dom-shim has node-fetch as a dependency. Additionally, from my testing, they don't support passing element properties to children with ssr either which is one of the reasons I landed on wcc. If wcc starts supporting WC properties, I believe it may be one of the only options that doesn't require a build step or buying into a specific framework. Pretty cool!
Awesome! This next week is kind of crazy for me, but I'll start working on the first PR as soon as I can. The second one is basically changing 3 lines of code but I agree that it's better as its own thing. Thanks for being open to the changes - hopefully, it leads to a stronger wcc! |
This is really great to hear, and happy the breadcrumbs led you to WCC. I definitely am trying to support that more DIY / less magic all the things, build from the standards up philosophy. I know anyone can make anything which is what I love about the JavaScript ecosystem, but what about for those who just want to do it themselves, you know? There are dozens of us! 😄
Good call, and good points; if it does / can can evolve into a community protocol, we can worry about all those tough choices then. For now, it's just extending what's already there, and in a really cool way. Let's go for it!
That could be an interesting comparison to check out. I've used node-html-parser as well but mostly as an alternative to something like JS DOM for basic querying of HTML strings and the like for some build time stuff, but for some reason only ever thought of parse5 for WCC. I can't say I've ever felt any slowness per se, but then I'm not a super benchmarker either, so I guess I could go either way, but I would favor accuracy pretty strongly though.
No worries and no rush! Really appreciate you digging the project and offering to contribute to make it better. Not sure if you are on Discord or anything, but also happy chat there as well. There are Discords for Lit and the Web Components Community Group I can share with you as well if any of those groups sound of interest to you.
Yes for sure! Would definitely be eager to see what you're working on. 👀 |
Of course, you’re doing great work here! I sent a friend request on Discord to “thescientist13.” Just in case your username differs there, mine is the same as it is here. I was able to spend a little more time this week and now have everything working smoothly with both Light DOM and Shadow DOM using lit-ssr's render (called within a custom renderer that copies properties to lit-ssr's output). I also have both working with a custom SSR renderer that doesn’t use Lit. All combinations seem to be working well! Before, I had things mostly there, but there were some rough areas, especially with Shadow DOM components. It feels much cleaner now, and hydration is working well too. Moving in this direction with the polyfill, I think it would be quite easy to polyfill append, prepend, insertAdjacentElement, and insertAdjacentHTML as well—potentially with as little as a line of code for some of them. Let me know if you see value in that, as I don’t see any major challenges to implementing it. One thing I can see people wanting to use in their web components is querySelector/querySelectorAll. It might be interesting to explore whether a polyfill for those could be feasible down the road. Anyway, feel free to hit me up on Discord when you have time, and we can talk more there! |
Ah, my username was slightly off (it's thescientist_13), haha. I reached out to you on Discord so we can keep chatting. Just to say for now I have an issue around handling these extra DOM methods, so happy to keep that conversation going on in #37 👍 |
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:
Example components:
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:
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:
The text was updated successfully, but these errors were encountered: