Closed kojiishi closed 8 years ago
Alternatively, we could add something like finishedParsingChildrenCallback
I think nobody needs that (meaning it doesn't semantically even scale), but createdCallback
from V0 would be already great, assuming it triggers as soon as the live node has been parsed so that if the CE is defined upfront, it won't trigger until the end of the node is reached, and if the CS is defined after, it triggers as soon as the browser can access its content.
Basically your solution to put an element at the end of a Custom Element to know if its ready should be backed in the Custom Element API itself, not a per-developer responsibility, since that's the moment any custom element would like to setup.
Those created procedurally via JS can trigger the same thing via tick so that adding nodes on the fly synchronously would be still possible and the component can initialize itself properly right after.
If createdCallback
from V0 is a bad name due history, let it be contentParsedCallback
or even readyCallback
so that at least the standard would provide a universal way to setup CEs without needing mandatory ShadowDOM to be consistent (yet with same problem if there was content inside the node or not during its construction/upgrade).
That sounds precisely like the kind of a use case MutationObserver
would address.
Also, it's wrong to assume that child nodes would be inserted once and never change. Scripts can totally remove & add more child nodes later on so you'd have to have MutationObserver
to observe those changes anyway.
That sounds precisely like the kind of a use case MutationObserver would address.
As already explained, MutationObserver doesn't trigger anything if the element is already live on the DOM and the custom element is defined after. There are example to test this. https://github.com/w3c/webcomponents/issues/551#issuecomment-429262811
Also, it's wrong to assume that child nodes would be inserted once and never change.
Nobody assumes that, we need a way to setup once the custom element. The constructor is not a good place to setup a custom element if it doesn't use shadow dom and would like to initialize or parse/understand/query/use its content.
Scripts can totally remove & add more child nodes later on so you'd have to have MutationObserver to observe those changes anyway.
You keep ignoring the issue: how to setup a custom element.
We can talk forever DOM can change, we all know this, it's a useless discussion.
Nobody knows how to setup a custom element though, in a way that works with definitions already known, loaded on demand, or procedural.
It is a real problem with v1: but the compromise which changed the lovely v0 was essentially an fu to components, an effort to make CE non viable. The solution is actually Dom[0], way easier in es6, but are “naughty”. Require a monkey patch.
If child behavior depends on parent type, put a Shared function or a getter prop on HTML.prototype using Obj.defineProperty; make the getter a state machine/switch/proxy dependent on this.nodeNamr and/or this.parentElement.nodeName.
The getter on the HtmlElement.prototype will register parentsvand children immediately. There is no connected bullshit.
Here is a verbose declarative approach with animation, resize. And most of the features of flex without any css.
Nobody knows how to setup a custom element though, in a way that works with definitions already known, loaded on demand, or procedural.
I don't understand this. You just need to iterate over child nodes inside the constructor if there are any, schedule a MutationObserver on child node change on this
and then re-iterate whenever child nodes are inserted or removed. Simple as that. The element needs to remain functional throughout this process after the constructor had finished running.
@rniwa So if that is as simple as that, could you be so kind and post a bullet-proof boilerplate for a custom element setup? Something that just makes the component work the way a developer would naturally expect it to? Or just link me to the page that has this explained in-depth so a regular developer can understand and apply it?
More than 2 years ago, @WebReflection stated the following and I can see myself sign that:
If I might, and without sarcasm meant, the more I think about this upgrade part of the specs, the more I think it feels like "quantum physics" where it's not clear to consumers (users, developers) what a custom element is, or what it'll become. https://github.com/w3c/webcomponents/issues/551#issuecomment-242571054
I admit I haven't used MutationObserver
up until now, but it feels odd that I have to, given a mature specification like web components v1, which many people have worked on for like 4 or 5 years. Doesn't this even prove there's a missing lifecycle hook?
@jfrazzano Who would ever be interested in making CEs non-viable?
You just need to iterate over child nodes inside the constructor ... Simple as that
Simple, right? That assumes you know inside the constructor if the Custom Elements would expect nodes to setup itself, or not.
An empty custom element is a perfectly valid use case that fails your simple approach.
A Custom Element that might be forever empty or optionally have nodes to dictate its status is another perfect common use case, i.e. my-select
that would see its shape only after known its content has one opt more my-option
or not, before setting up its ShadowDOM or its final shape.
How do you know when it's the time for that component to initialize itself once if not by trusting some parent has a MutationObserver to take indirectly care of that?
This is impractical, and what is missing is a way to know, from the component, the component body has been fully parsed, which should happen once for a component lifecycle, and never more than once.
Just like a constructor, with all possible setup available, including a dirty innerHTML
, which instead throws arbitrary errors if the custom element is being upgraded but not fully known (childNodes.length = 0
).
As simple as that.
P.S. @rniwa even @cramforce (Google AMP Team) had this issue forever about this ( discussed also in here https://twitter.com/cramforce/status/975310752666984448 ) and he suggested me to check for nextSibling
and then again, a node could be the only child so that knowing there's no nextSibling
leads to false positives.
As Malte said, we need the implementation to give developers an API to know when it's safe/OK/fine to setup a custom element (once, not per each dom mutation inside it).
edit also the browser knows this, because indeed it arbitrary throws errors if it's too early, but it doesn't expose when is not.
@rniwa I can't stop thinking about this
Alternatively, we could add something like
finishedParsingChildrenCallback
Now, I don't care about finishedParsingChildrenCallback
but I'd love to have a parsedCallback
instead.
Yes, that might never happen if a Custom Element is the entire body of a huge page full of intermediate flushes, but that's a very confined problem, not the most common use case.
Basically, flushing this:
<body>
<div>
<my-early-definition <?php
flush();
usleep(300000);
?>
key="value">
<span>ear</span>
<?php
flush();
usleep(300000);
?>
<span>ly</span>
</my-early-definition>
<my-lazy-definition key="value">
<p>lazy</p>
</my-lazy-definition>
</div>
</body>
It doesn't matter if there is a Custom Element or a MutationObserver
, the connected will never happen until the opening tag is finished (consistently in both Chrome and Safari).
That means that when attributeChangedCallback
or a mutation record with its addedNodes
is triggered, the beginning of the element is known.
That flush in the middle though, will always trick the browser to early trigger either a connectedCallback
or an observed mutation within added nodes.
However, what's basically impossible to know in user land but absolutely known behind the scene, is when the closing tag is either enforced or found, so that nodes found after would either be children, sibling, or part of the parent.
That is what I'd love to have in custom elements so that it is possible to understand when the element is fully known or not.
new MyEl
would trigger parsedCallback
instantly after the constructor
parsedCallback
will trigger only once the whole node has been flagged as parsed or known or with a closed tag. Everything else remains the same but at least we have an entry point to setup / flag the component state (or show spinners via CSS and add a class to drop it later on ... and similar stuff)parsedCallback
would trigger possibly before any of the attributeChanged/connectedCallback
friends but yet, if that's complicated, it's important that triggers ASAP, no matter what.The parsedCallback
would provide a primitive mechanism that would make any real-world Custom Element user happy because it exposes an extremely important information that is vital to understand, handle, or setup, reliably in both client and server rendered code Custom Elements.
Thanks for considering that.
As a data point, AMP's custom element base class has a custom callback called "buildCallback"
named to signal the point when custom elements should be safe to "build" there child structure.
This is currently implemented as:
connectedCallback
firednextSibling
For the vast majority of elements this is strictly a better time to do initialization than connectedCallback
. Currently connectedCallback
may be called with and without children present. That kind of racy behavior leads to bugs all over the place. Requiring use of MutationObserver for cases where actual mutations are unexpected is a really bad programming model and prone to buggy code.
Of course, some custom elements must be initialized before children are parsed. This is primarily the case for container elements that may have large amounts of child nodes and waiting for all of them to parse would break streaming rendering. This needs to continue to be supported but that isn't a good argument that there shouldn't be a shortcut for the the more common use case.
I still think that a dedicated childrenChangedCallback
or similar is needed to fix this very rough edge of the APIs, as requested in #550 and #619. The exact right combination of slotchange and and DOMContentLoaded events, MutationObserver, and connectedCallback
, is just too obscure to be usable, and a finishedParsingChildrenCallback
doesn't solve the dynamically changing children case.
The platform should provide a reasonable signal for "If I need to process children, when should I do it".
@justinfagnani the childrenChangedCallback
is an easy peasy thing to configure in the constructor
, if needed, so it's way easier to have and definitively less important than parsedCallback
/ buildCallback
, IMO.
finishedParsingChildrenCallback
doesn't solve the dynamically changing children case.
anything specific to children doesn't solve much (some component might want to inject its own children without ShadowDOM), and children are super easy to observe already.
The platform should provide a reasonable signal for "If I need to process children, when should I do it".
For one-off setup that is exactly what parsedCallback
/ buildCallback
are being proposed for.
For anything else, if needed, we already have MutationObserver, I don't think we should slow down everything with an implicit mutation observer for children in every custom element.
@justinfagnani The exact right combination of slotchange and and DOMContentLoaded events, MutationObserver, and connectedCallback, is just too obscure to be usable [...]
Very well agreed. Currently it appears like you got to be a total DOM lifecycle, browser DOM implementation and consider-all-the-possible-cases-guru to create robust web components relying on children. @WebReflection called it quantum physics, and that's exactly how you make regular developers resort to frameworks instead of building on top of native technologies.
Did the spec authors not see, or underestimate, or simply ignore this when designing the spec v1?
Hi everyone, based on the recomendations from this and other posts, we have been able to get around this problem with the folowing steps:
Edit: It was ignorant of me to present this with the words " we have been able to get around this problem" because our solution relies on the requestAnimationFrame which is going to be triggered first when the browser is the able to do so (in chromium after the DOMContentLoaded) which than means that with our solution one should not rely on the DOMContentLoaded but to build the custom "webComponentsReady" event, which delays the JS more and is bad practice. I have been able, as the @cramforce in his post suggested, to get around the problem with the readyState + nextSibling + MutationObserver. And yeah as the @WebReflection already posted, none of the solutions are going to work with the concatenated HTML. I believe that for my case this would be good enough solution but also believe that this should be solved a bit differently (template + WC ?)
Long story short (new code): See: https://github.com/w3c/webcomponents/issues/551#issuecomment-431258689
I'd like to underline that in a scenario like the following one, all described techniques would fail.
<!doctype html><html><head><script src="my-el.js"></script></head><body><my-el></my-el></body></html>
The my-el has no sibling and its parent neither, neither the parent parent.
Only connectedCallback
would be relevant and yet it won't be granted that the end of the element has been reached so that even in this case parsedCallback
/ buildCallback
would be needed.
TIL browsers beautify the content so that my-el
there would have a nextSibling
even if not declared, however with this content it won't, and it's still valid:
<!doctype html><html><head></head><body><my-el>
How does all this relate to customized-built-ins that rely on children to set up, like <select is="my-select">
or <ul is="my-list">
?
So hands down, this is what we're going to give a shot, following what we were able to extract from this and other posts on the children/connectedCallback topic.
Comments welcome. Yes, we're aware that it will fail in the edge case which @WebReflection mentioned. If anyone sees any other possible edge case, please let us know.
class HTMLBaseElement extends HTMLElement {
constructor(...args) {
const self = super(...args)
self.parsed = false // guard to make it easy to do certain stuff only once
self.parentNodes = []
return self
}
setup() {
// collect the parentNodes
let el = this;
while (el.parentNode) {
el = el.parentNode
this.parentNodes.push(el)
}
// check if the parser has already passed the end tag of the component
// in which case this element, or one of its parents, should have a nextSibling
// if not (no whitespace at all between tags and no nextElementSiblings either)
// resort to DOMContentLoaded or load having triggered
if ([this, ...this.parentNodes].some(el=> el.nextSibling) || document.readyState !== 'loading') {
this.childrenAvailableCallback();
} else {
this.mutationObserver = new MutationObserver(() => {
if ([this, ...this.parentNodes].some(el=> el.nextSibling) || document.readyState !== 'loading') {
this.childrenAvailableCallback()
this.mutationObserver.disconnect()
}
});
this.mutationObserver.observe(this, {childList: true});
}
}
}
class MyComponent extends HTMLBaseElement {
constructor(...args) {
const self = super(...args)
return self
}
connectedCallback() {
// when connectedCallback has fired, call super.setup()
// which will determine when it is safe to call childrenAvailableCallback()
super.setup()
}
childrenAvailableCallback() {
// this is where you do your setup that relies on child access
console.log(this.innerHTML)
// when setup is done, make this information accessible to the element
this.parsed = true
// this is useful e.g. to only ever attach event listeners to child
// elements once using this as a guard
}
}
customElements.define('my-component', MyComponent)
I've put this into a public gist as well:
@cramforce @WebReflection Could you please take a look and comment?
According to first performance measurements in Chrome done by my colleague @irhadkul the performance drain is minimal (single digit microseconds) when comparing the suggested method with accessing things in connectedCallback
outright.
@franktopel things I'd do differently:
WeakSet
for the already parsed bitsetup
should be instead the connectedCallback
of the HTMLBaseElement
class, and the class should also have a childrenAvailableCallback no-op (or actually ignore everything on comnnectedCallback
if "childrenAvailableCallback" in this
is false)childrenAvailableCallback
will ever be calledchildrenAvailableCallback
callback it doesn't backfire forever.Accordingly, this is how I'd go, or what makes sense to propose as standard.
const HTMLParsedElement = (() => {
const DCL = 'DOMContentLoaded';
const init = new WeakSet;
const isParsed = el => {
do {
if (el.nextSibling)
return true;
} while (el = el.parentNode);
return false;
};
const cleanUp = (el, observer, onDCL) => {
observer.disconnect();
el.ownerDocument.removeEventListener(DCL, onDCL);
parsedCallback(el);
};
const parsedCallback = el => el.parsedCallback();
return class HTMLParsedElement extends HTMLElement {
connectedCallback() {
if ('parsedCallback' in this && !init.has(this)) {
init.add(this);
if (document.readyState === 'complete' || isParsed(this))
// ensure an order via a micro-task so that
// parsedCallback is always after connectedCallback
Promise.resolve(this).then(parsedCallback);
else {
// the need a DOMContentLoaded case
const onDCL = () => cleanUp(this, observer, onDCL);
this.ownerDocument.addEventListener(DCL, onDCL);
// the early case still good to setup one
const observer = new MutationObserver(changes => {
changes.some(record => {
if (record.addedNodes.length) {
cleanUp(this, observer, onDCL);
return true;
}
});
});
// we are interested in the element nextSibling so
// lets observe its parent instead of its own nodes
observer.observe(this.parentNode, {childList: true});
}
}
}
};
})();
Observing the parentNode
still might hide shenanigans but at least is its only direct container and not the whole document so it's IMO most likely a more reliable approach.
Eventually, the observer could be configured as {childList: true, subtree: true}
and the if
should check if (isParsed(this))
instead of checking record.addedNodes.length
.
Right ... little variation that uses a WeakMap
instead and it's also observing children for all occasions
const HTMLParsedElement = (() => {
const DCL = 'DOMContentLoaded';
const init = new WeakMap;
const isParsed = el => {
do {
if (el.nextSibling)
return true;
} while (el = el.parentNode);
return false;
};
const cleanUp = (el, observer, ownerDocument, onDCL) => {
observer.disconnect();
ownerDocument.removeEventListener(DCL, onDCL);
init.set(el, true);
parsedCallback(el);
};
const parsedCallback = el => el.parsedCallback();
return class HTMLParsedElement extends HTMLElement {
connectedCallback() {
if ('parsedCallback' in this && !init.has(this)) {
const self = this;
const {ownerDocument} = self;
init.set(self, false);
if (ownerDocument.readyState === 'complete' || isParsed(self))
Promise.resolve(self).then(parsedCallback);
else {
const onDCL = () => cleanUp(self, observer, ownerDocument, onDCL);
ownerDocument.addEventListener(DCL, onDCL);
const observer = new MutationObserver(changes => {
if (isParsed(self)) {
cleanUp(self, observer, ownerDocument, onDCL);
return true;
}
});
observer.observe(self.parentNode, {childList: true, subtree: true});
}
}
}
get parsed() {
return init.get(this) === true;
}
};
})();
I think I'll publish this one to npm.
@WebReflection
Comments on your comments:
in empty custom elements a childrenAvailableCallback
, as the name implies, doesn't make much sense anyway. As far as I can see you can simply extend HTMLElement
instead of HTMLBaseElement
in that case. At the end of the day, this approach is meant to solve the problem that arises from children being unavailable when connectedCallback
triggers.
Then how would you address asynchronous adding of child elements? Probably the right approach here would be to pass a cleanUp
callback to childrenAvailableCallback
.
What is still open with this solution is to adjust it for use in customized-built-ins that have expectations about their children.
Btw, I was also considering publishing this on npm, but probably it'll gain more traction if you do it.
@WebReflection I saw you're quick: https://github.com/WebReflection/html-parsed-element
I wouldn't reject attribution, neither would @irhadkul I assume :)
well, if any of you want I can include attributions but just to be clear, this is what HyperHTMLElement does since about ever, using a timeout instead of MutationObserver
for better compatibility (down to IE9) and without guarding all calls to connected/attributeChanged before the created()
is invoked.
The html-parsed-element
is an alternative that might become handy for those not interested in HyperHTMLElement.
I will still link to this ticket in there so again, I don't mind adding anyone in here as contributor 👋
also @franktopel ...
this approach is meant to solve the problem that arises from children being unavailable when connectedCallback triggers.
My approach solves every issue. When parsedCallback
is invoked you will have children in there, if any. If you want to listen to further mutations to children, just add your own Mutation Observer.
Then how would you address asynchronous adding of child elements?
You don't . You disconnect the observer too so that's not your intent and also you want to this.childrenAvailableCallback()
once, and once only indeed.
Again, the missing bit that is essential is to know when it's safe to handle children or even inject nodes/html. Once we have that, everything else is trivial.
Attribution is definitely welcome. While none of us has done anything even remotely comparable to your work, we surely contributed to the birth of html-parsed-element with the last 10 days' work.
Regarding your comment for better compatibility (down to IE9) I was surprised to find HyperHTMLElement
is compatible with every mobile browser and IE11 or greater on https://github.com/WebReflection/hyperHTML-Element
IIR you can test this page and it should work in IE9 too https://webreflection.github.io/hyperHTML-Element/test/?es5
compatibility is probably for something not fully transpilable but I don't remember what.
Feel free to file a PR for the attribution so I'm sure it's done properly/as you expect.
Btw, how does attributeChangedCallback()
behave with respect to children? We have a tabs component that is controlled via a data-active-tab
attribute in combination with an attributeChangedCallback
case.
@franktopel the attributeChangedCallback
is triggered as soon as the beginning of the node is known, AKA the opening tag.
Differently from connectedCallback
or whatever children watcher we want, the attributeChangedCallback
will never trigger when one attribute is known but another one isn't ,because attributeChangedCallback
is consistent in triggering when all attributes on the opening tag are known, instead of randomly in the wild without any signal all nodes are known, which is what my proposal addresses.
P.S. @franktopel attributions are live
@WebReflection So in that case we (our team) have the exact same problem with attributeChangedCallback
as well.
the attributeChangedCallback
is triggered as soon as the beginning of the node is known, AKA the opening tag. How is that ever useful at all? What would a developer ever want to do at that point in time, without being able to access the element's content?
@franktopel it's useful for empty nodes with shadow dom, and not much else indeed.
With parsedCallback
you can setup sure that attributes are there as well.
Meanwhile, if you react to attributeChangedCallback
, if this.parsed
is false you should queue the operations if important for the component state.
const attributeChanged = new WeakMap;
class MyEl extends HTMLParsedElement {
parsedCallback() {
// setup the node, you have access to all its content/attributes
// then ...
const changes = attributeChanged.get(this);
if (changes) {
attributeChanged.delete(this);
changes.forEach(args => this.attributeChangedCallback(...args));
}
}
attributeChangedCallback(...args) {
if (!this.parsed) {
const changes = attributeChanged.get(this) || [];
if (changes.push(args) === 1)
attributeChanged.set(this, changes);
return;
}
// the rest of the code
}
connectedCallback() {
// here you can safely add listeners
// or set own component properties
this.live = true;
}
disconnectedCallback() {
// here you can safely remove listeners
this.live = false;
}
}
Well, we're not using shadow DOM at all. We just need to replicate Swing controls for the web, for a huge migration project. And currently it needs to support IE 11 mainly. And it has to work for the next 10 to 15 years, without huge update efforts, like you'd have with using a framework like Angular or React. That's why the company decided to use native web components.
So you're saying the whole spec has been designed around a very limited use edge case?
Also, we have been using attributeChangedCallback
on a tabs
component to set the initially active/visible tab, so this must again have been an issue of it working in the upgrade case, but not in the parsing case. What a mess, again!
Wouldn't it be an even better approach to dispatch a ComponentContentLoaded
custom event? That would solve the attributeChangedCallback
problem as well, and it would make it so outside elements and components can attach a listener to that event (if they rely on it).
We could even have all components register at a central service in their constructor on creation, and have that same service emit a global ComponentsReady
event as soon as all registered component instances have emitted their ComponentContentLoaded
event.
You can dispatch any event you want from parsedCallback ...having hybrid callbacks and events feels inconsistent with the API , imo
https://github.com/WICG/webcomponents/issues/551#issuecomment-431258689
@franktopel and @WebReflection, thank you again for making a full implementation of this, I think it will help out a ton! I love how Web Components work, and using this in addition with Constructable Stylesheets will make everything so much more seamless, now being able to parse innerHTML
consistently.
Noob suggestion here, why not just link your scripts with deferred or type="module"
, that way you know the DOM will be parsed and the child nodes present when your connectedCallback
runs?
<script type="module" src="/static/..../component.min.js>
Noob suggestion here, why not just link your scripts with deferred or
type="module"
, that way you know the DOM will be parsed and the child nodes present when yourconnectedCallback
runs?
That will not work for nodes that are dynamically inserted, after initial setup.
Solution for this problem is pretty simple, all one has to do it wrap connectedCallback
in window.requestAnimationFrame
and you will always have all children available.
I am pretty sure this is faster then any other proposed solution, as setTimeout # 0
and similar.
More in my answer here https://stackoverflow.com/questions/70949141/web-components-accessing-innerhtml-in-connectedcallback/75402874#75402874
@dux not correct apparently, read thread from here: https://github.com/WICG/webcomponents/issues/809#issuecomment-1455481698
From a blink bug, an author reported that
connectedCallback
is called too early in Blink.or a sample code in jsfiddle.
When current Blink impl runs this code,
this.children.length
is 1 inconnectedCallback
forx-x
. This looks like matching to the spec to me, but I appreciate discussion here to confirm if my understanding is correct, and also to confirm if this is what we should expect.My reading goes this way:
x-x
as Any other start tag, it insert an HTML element.connectedCallback
.div
. In its create an element for a token, definition is null, so will execute script is false. Thisdiv
is then inserted.y-y
. In its create an element for a token, definition is non-null, so will execute script is true.connectedCallback
runs here.Am I reading the spec correctly? If so, is this what we should expect?
@domenic @dominiccooney @rniwa