whatwg / dom

DOM Standard
https://dom.spec.whatwg.org/
Other
1.55k stars 286 forks source link

Shadow: Specify when `slotchange` fires #447

Closed surma closed 7 years ago

surma commented 7 years ago

Safari, Chrome and ShadyDOM currently exhibit different behavior when slotchange does and does not fire. As far as I understand the spec, it is not precisely spec’d, meaning all these current behaviors are technically spec compliant, but can be very frustrating for developers.

Demo 1: Slots are created in the constructor Demo 2: Slots are creatred in connectedCallback

Which browser fires slotchange under what circumstances?

Slots in constructor, parser creates element Slots in constructor, element gets upgraded Slots in connectedCallback, parser creates element Slots in connectedCallback, element gets upgraded
Chrome Fire No fire Fire No Fire
Safari Fire No fire No Fire No Fire
ShadyDOM Fire Fire Fire Fire

(ShadyDOM was tested using Firefox).

While my main concern is consistency across browsers, I like ShadyDOM’s behavior best in terms of DX as it is consistent with how attributeChangeCallback in that it fires upon initialization. This would allow developers to have their update logic in the slotchange handler and they wouldn’t need to manually detect silently slotted elements in connectedCallback().

/cc @hayatoito

annevk commented 7 years ago

Why is it not precise?

surma commented 7 years ago

@annevk Maybe I am not understanding the spec, that is entirely possible. If it is specced, do you mind telling me which browser is behaving correctly so I can file bugs against the others?

surma commented 7 years ago

However, maybe we can have a discussion if it is possible/feasible to change the spec to the behavior I describe below or help me understand why that is not a good idea™️

annevk commented 7 years ago

As far as I can tell the specification consistently fires slotchange however the tree was created. Meaning only "ShadyDOM" is correct (though I haven't looked at the timing). If your understanding is different you'll have to tell me how you arrived at that conclusion.

hayatoito commented 7 years ago

I think the following chromium bug is related: https://bugs.chromium.org/p/chromium/issues/detail?id=696659

I believe that the current Blink's behavior satisfies spec-conformance. As I mentioned in the bug, I guess what is being proposed here is:

From https://github.com/whatwg/dom/pull/229#issuecomment-211734744

When inserting a slot, we set a suppress signaling flag.

If we change it to:

When inserting a slot, we do not set a suppress signaling flag.

Then, I think the spec's behavior matches that of Shady DOM.

annevk commented 7 years ago

See https://github.com/w3c/webcomponents/issues/288#issuecomment-206791125 by @rniwa for why we suppress in that case. I'm okay with changing this if you're all agreed (including @rniwa) we should change this.

Anyone interested in writing tests?

surma commented 7 years ago

Just want to reemphasize that I am arguing from a developer experience perspective here, and that the current state is unexpected (and inconvenient).

I’d actually be happy to take this opportunity learn to write tests and help out that way. I’m sure one of my colleagues could help me get started here. (@domenic?)

annevk commented 7 years ago

I appreciate you filing the issue. I just wasn't sure at first what the problem was, but @hayatoito stepping through the algorithms in the Chrome bug made it more clear.

annevk commented 7 years ago

http://web-platform-tests.org/ has documentation on writing tests. It's probably worth looking at existing tests once you've got the framework running locally as the easiest way to figure out how to write them.

domenic commented 7 years ago

The other thing to do is check through the existing tests to make sure none of them test the currently-specced behavior; if they do they need to change.

Happy to help more over IRC or Hangouts or whatever once I'm in the office (~an hour or so). Although those docs should help you get started.

surma commented 7 years ago

I opened a PR for a web-platform-tests that tests for the ShadyDOM behavior. Pending consensus in this issue :)

hayatoito commented 7 years ago

See w3c/webcomponents#288 (comment) by @rniwa for why we suppress in that case.

Thanks. I need some time on investigating how Blink can fire a event in such a case. I thought that is doable, but I am not 100% sure. I need to do an experiment.

I think the interoperability is the most important here. I am open to any opinions.

hayatoito commented 7 years ago

I have roughly implemented the behavior which I think is being proposed; when inserting a slot, and one or more slotables are assigned to the slot at this timing, we fire slotchange on the slot. As far as I experimented, Blink doesn't have any significant difficulties to implement this behavior. I can say Blink is okay to change the spec and implement it so that Blink can fire slotchange for Demo2 as well as for Demo1 in https://github.com/whatwg/dom/issues/447#issue-224427134.

If we can agree this change, I am happy to send a PR to DOM Standard, write a WPT, and update Blink's behavior. I think backward compatibility risk is very low.

rniwa commented 7 years ago

WebKit's behavior is not to fire slotchange on a newly created slot when the slot got assigned of nodes as it got inserted into the tree as I mentioned in https://github.com/w3c/webcomponents/issues/288#issuecomment-206791125. I don't know Chrome fires only in demo1 but not in demo2.

Here's my thought.

  1. Other DOM events such as change don't fire when the initial value is set.
  2. A well written component probably have an efficient code path for when it's constructing shadow tree instead of relying on slotchange event

Firing slotchange event when slot is inserted would make (2) harder because now we have to remember to ignore the very first slotchange being fired. So I'm still slightly inclined towards not firing for the initial insertion.

surma commented 7 years ago

Note: While preparing a demo for this reply, I found out that things become even more inconsistent depending on whether you create your slots in the constructor or connectedCallback. I augmented the original table!

Slots in constructor, parser creates element Slots in constructor, element gets upgraded Slots in connectedCallback, parser creates element Slots in connectedCallback, element gets upgraded
Chrome Fire No fire Fire No Fire
Safari Fire No fire No Fire No Fire
ShadyDOM Fire Fire Fire Fire

@hayatoito Thanks for investigating. Very glad to hear that the behavior is technically possible and feasible.

@rniwa As I said, my main goal is consistency, with a preference for changing the behavior to always firing slotchange. Let me address these two things separately:

Consistency

The behavior of slotchange depends on:

Unless you are intimately familiar with the intricacies of Custom Elements and ShadowDOM, this is going to trip up and annoy developers. When developing generic components, you might write code that works fine during development, but because users include your CE script at a different point in the page, things suddenly stop working.

A similar argument can be made for slotchange behaving differently depending on whether you create the slot in the constructor or in connectedCallback.

Always firing

Take a look at this demo, which is a stripped down version of a tab panel component I am working on. I’m using ShadowDOM to “reshuffle” the children as a technique for graceful degradation. If JS is disabled, the tabs function as headings for the different sections. So in the markup tabs and panels are interleaved. I use ShadowDOM do group tabs and panels.

I need to be notified about new elements because I need to generate IDs and semantically link tabs and panels using aria-controls and aria-labelledby. Right now, I’d have to manually call into the linking code in the connectedCallback() in addition to linking it to the slotchange event.

I am aware that CE lifecycle callbacks are something different from ShadowDOM events, but the fact that attributeChangedCallback does get called for initialization purposes and slotchange does not, is confusing and somewhat unexpected. A MutationObserver would help, but ShadowDOM already has an internal MO so I think it’d be nice to be able to make use of that (apart from clearing up the aforementioned inconsistency with attributeChangeCallback).

rniwa commented 7 years ago

Those are some good points but I think we need to hear opinions of other stake holders. If they're all okay with this change, we can go ahead and make the spec change.

sorvell commented 7 years ago

Definitely agree with making this change. It's much easier to create an element when the developer only has to consider one processing model for basic lifecycle.

A well written component probably have an efficient code path for when it's constructing shadow tree instead of relying on slotchange event

One could similarly argue that connectedCallback should not run if the element is connected when the constructor runs because the constructor could just check isConnected. As with slotchange, this has the unfortunate effect of requiring a developer to consider multiple processing models to handle basic lifecycle. This is just fundamentally cumbersome.

rniwa commented 7 years ago

Okay, given the use cases and multiple developers saying the current behavior is confusing, I'd now argue that we should make this spec change.

surma commented 7 years ago

Sounds like noone is opposed to the change 🎉

@hayatoito Do you mind creating the PR for the spec? I’ll happily take care of the PR for the tests (w3c/web-platform-tests#5705).

hayatoito commented 7 years ago

Sure. Let me make a PR to DOM Standard. I'll update Blink's behavior after it gets merged.

Thank you all for making this change happen. Especially, huge kudos for @surma. :+1:

annevk commented 7 years ago

@hayatoito @surma @rniwa so should we keep suppressing for removal slots or should we just remove the concept of suppressing altogether?

hayatoito commented 7 years ago

I don't have any opinion about that of removing. It looks no one is interested in that.

@surma @sorvell Do you have any preference for that? If either is okay, we could remove the concept of suppressing signal altogether.

hayatoito commented 7 years ago

From the implementor's perspective, if we remove suppressing for slots removal, the engine will need to do an additional check (and additional potential slotchange event firing). Unless there is a good use case for slotchange at "slot removal", it might be better to keep suppressing it.

bicknellr commented 7 years ago

should we keep suppressing for removal slots or should we just remove the concept of suppressing altogether?

IMHO, it's better to have the same behavior in all cases: "If the nodes distributed to the slot changed, then 'slotchange' fires." I don't think the inconsistency introduced by adding the special case "However, if the nodes distributed to a slot changed because that slot was removed from a tree, then 'slotchange' does not fire." is justified by avoiding the performance cost of an extra event.

annevk commented 7 years ago

@bicknellr why not?

annevk commented 7 years ago

Okay, if someone still feels strongly we should remove suppression for slots removal I suggest they file a new issue and we can tackle that separately.

rniwa commented 7 years ago

I think we should get rid of the suppression completely given that would simplify the spec & implementation. I can't think of a reason why we'd want to keep it just for the removal case.

surma commented 7 years ago

I think I agree with @bicknellr. IINM, detaching the slot from the shadow root will remove all slotted elements (slot.assignedNodes() returns an empty NodeList). Therefore, firing a slotchange event seems very logical and consistent to me.

If that make suppression obsolete, I am all for removing it.

(Sorry for the late reply, I/O is happening)

hayatoito commented 7 years ago

@rniwa, @surma As @annevk suggested, could you file a bug separately? We might want to tackle that separately because that is not included in the original concern.

annevk commented 7 years ago

@rniwa you're not concerned with performance then? @hayatoito if everyone but us wants to remove this, we might as well keep it in this issue...

hayatoito commented 7 years ago

Okay. Then, let's continue the discussion. Basically, I don't have a strong opinion, however, I should be very careful for any changes which might have a performance impact and which might not have a good use case.

I think that we all are aware that getting rid of suppressing completely would simplify the spec and looks more consistent. I am aware of that too. So no need to repeat it.

My questions:

surma commented 7 years ago

When firing such a slotevent at slot's removal, the slot's assigned nodes will be always empty. Is slotchange for that really a useful event for web developers? Does web developers have a clean up task at this timing, although that assigned nodes is always empty?

I think those scenarios exist, where cleanup is desirable and/or necessary. (E.g. Setting/Unsetting aria-activedescandant.)

In real worlds, does removing slots (or its one of ancestors) from the tree happen frequently? If that happens frequently and there is no use case for a slotchange event at this timing, I object to this change because the performance impact can't be ignored.

I have no hard numbers, but from experience I’d say that performance does not need to be a concern here. Removing slots from a Shadow Root is pretty rare – I have never seen or done it. Maybe @sorvell or @kevinpschaaf can provide additional insight.

hayatoito commented 7 years ago

@surma Thanks.

I think those scenarios exist, where cleanup is desirable and/or necessary. (E.g. Setting/Unsetting aria-activedescandant.)

Yeah, I can imagine. :) In addition to that, I am a little worrying about that a slotchange event for removal case would be a noise for a slotchange event listener because a slotchange listener wouldn't be interested in slotchange for "removal case" in most cases because its assigned nodes is always empty, and the slot was already disconnected from the (custom element's) shadow tree when they receive a slotchange.

It would be great if we can see a concrete example of the current usage of a slotchange event listener. Do you know that?

I guess it would be something like:

slot.addEventListener('slotchange', (e) => {
  // ...
  for (const slotable of slot.assignedNodes()) {
    // do some task for slotable
  }
}

, right? If so, I don't worry much.

I have no hard numbers, but from experience I’d say that performance does not need to be a concern here. Removing slots from a Shadow Root is pretty rare – I have never seen or done it. Maybe @sorvell or @kevinpschaaf can provide additional insight.

I think it would be difficult to have hard numbers. :) So we don't need to try to get hard numbers strictly. Rough feeling would be enough, which I had to double-check.

From the implementor's perspective, since the slot was already disconnected from the tree, I need some time to know its feasibility to support it nicely. It is different from other cases.

In any cases, I think we can land https://github.com/whatwg/dom/pull/459 separately, which shouldn't be blocked on the discussion. That is the reason I suggested to file a separate issue so that the discussion shouldn't block the https://github.com/whatwg/dom/pull/459.

annevk commented 7 years ago

Well, if we're going to change it I'd rather we'd change it all at once. Less churn for developers and less fallout. Changing how slotchange dispatches twice in quick succession seems worse.

hayatoito commented 7 years ago

I got it. Thanks for the advice. Let's use this issue until the discussion settles down.

surma commented 7 years ago

In addition to that, [...] a slotchange event for removal case would be a noise for a slotchange event listener because [...] its assigned nodes is always empty.

That’s a really good point. For example: If I wanted to use the slotchange event to remove event listeners I added to slotted children, I wouldn’t have access to them anymore.

Maybe it’s worth thinking about exposing removedNodes in the slotchage event somehow. The event comes from an internal MutationObserver, so that data should be available, no? Probably a discussion for a new issue.

Regarding your code sample, @hayatoito, I think that you are right that most implementations iterate over slot.assignedNodes() so we wouldn’t need to worry.

It would be great if we can see a concrete example of the current usage of a slotchange event listener. Do you know that?

I don‘t have any particular examples, no. Again, maybe @sorvell or @kevinpschaaf can help out?

bicknellr commented 7 years ago

Sorry about the late response; I think other folks here pretty much covered what I would have said though. I can only provide anecdotal evidence that the elements I've worked on don't often (ever?) rearrange their shadow tree in a way that causes their slot(s) to be removed. Most of the time, they clone and append the content of some template and that shadow tree's structure remains largely the same throughout the element's lifetime.

On a related note, after reading through the earlier comments on this thread and the related sections of the spec in a bit more detail, I learned that this event is only dispatched when the number of nodes assigned to a slot changes between zero and some non-zero amount. Was this a recent addition? I was under the impression that this event fired whenever the assigned nodes of a slot changed in any way, so I'm not sure I understand what the intended use of slotchange is anymore. If it isn't meant to be a way to synchronously respond to all distribution changes, what is it?

I could understand if slotchange raises the same concerns as mutation events (given that it's effectively a subset) and why that might have caused the spec change but the value seems to have been lost. If this concept is still too slow to be implemented in earnest - even with the limited scope of slotchange - maybe it should be abandoned altogether. I'd rather have no API than a deceptively incomplete one. A custom element author could always use a MutationObserver or override appendChild, removeChild, insertBefore, etc. so long as they're willing to find a reasonable workaround for children inserted during parsing or deep cloning.

hayatoito commented 7 years ago

No. I don't know such a recent addition. A slotchange event should be dispatched in response to all distribution changes, basically (except for some cases, like a slot removal case, which we are discussing). That should be an intended behavior. That hasn't changed, AFAIK.

For example, given:

<my-el>
  <:shadow-root>
     <slot></slot>
  <:shadow-root>
  <div id=a></div>
  <div id=b></div>
</my-el>

document.querySelector('#a').remove() triggers a slotchange on the slot, in response to a change; [a, b] -> [b].

surma commented 7 years ago

I learned that this event is only dispatched when the number of nodes assigned to a slot changes between zero and some non-zero amount

At least in practice that is not true. Here’s a small test case.

I could understand if slotchange raises the same concerns as mutation events

Could you link me some more details of what you are referring to? I am not in the loop.

bicknellr commented 7 years ago

@hayatoito, thanks for pointing this out. This is my mistake, I didn't read the spec carefully enough: I looked at insert (step 6.4) and remove (step 11), which call 'signal a slot change' depending on the number of assigned nodes of a slot for changes to children of that slot, and confused these with the responses to changes to the assigned nodes of the slot. So, I ended up overlooking 'assign slotables', which is where 'signal a slot change' for changes to assigned nodes really happens and, as you mentioned, for any change to assigned nodes. Anyway, I'm now pretty convinced that the behavior of slotchange isn't "deceptively incomplete". :)

@surma, here's the MDN page on mutation events; slotchange reminds me of them a bit and they were deprecated a while ago because of performance problems, but I can't really speak to the history there. In a sense slotchange acts as both DOMNodeInserted and DOMNodeRemoved because any insertion or removal could cause a slotchange if the parent inserted or removed from has a shadow root with a slot. Though, my guess is that slotchange is less expensive than these two events: mutation events had to be dispatched on any change to any node and bubble through the single tree containing all nodes in the document but slotchange is only dispatched when the parent of the child being moved has a shadow root containing a slot and bubbling is limited only to that parent's shadow root.

hayatoito commented 7 years ago

Maybe it’s worth thinking about exposing removedNodes in the slotchage event somehow. The event comes from an internal MutationObserver, so that data should be available, no? Probably a discussion for a new issue.

Yeah, it is a discussion for a new issue, but I have to say Blink is difficult to support it. For a historical and the performance reason, Blink calculates slot's assigned nodes lazily. That means Blink tries to detect a slotchange is being happened at DOM mutation timing, but Blink doesn't calculate each slot's assigned nodes at this timing. The calculation of assigned nodes is delayed until some trigger would happen later, like slot.assignedNodes() is called in an event listener. I guess this might sound strange, but this is an important performance optimization I would like to keep, so that we don't have to remove slotchange from DOM Standard due to the performance reasons. :)

surma commented 7 years ago

Thanks for the insight – very interesting actually. Agreed, let’s leave my remark for another time.

The bottom line is that for consistency’s sake and matching developer intuition I’d prefe we fire slotchange on slot removal.

hayatoito commented 7 years ago

Okay, I roughly supported slotchange on slot removal in Blink today. It looks doable. If I find something impossible, I'll update this issue again.

Let's wait for others' opinion for a week. If no one objects to it, let's update DOM Standard so that we fire a slotchange on slot removal too.

annevk commented 7 years ago

@hayatoito that plan sounds fine with me. Please get @domenic to review and land the change provided everyone is on board as I'll be away for four-five weeks starting in a few days.

rniwa commented 7 years ago

I guess another option is to get rid of the suppression but fire slotchange event only when the slot is inside a shadow tree. Then we wouldn't fire a slotchange on a removed slot element since that's not in a shadow tree. That might make this event a lot easier to understand & reason.

hayatoito commented 7 years ago

I guess another option is to get rid of the suppression but fire slotchange event only when the slot is inside a shadow tree. Then we wouldn't fire a slotchange on a removed slot element since that's not in a shadow tree. That might make this event a lot easier to understand & reason.

That sounds reasonable to me. +1 for that.

I would like to mention that there is a minor backward incompatibility:

However, I think we can ignore this case in practical. No one is interested in this case, I guess.

If someone is worrying about this backward incompatibility, please let us know that.

hayatoito commented 7 years ago

One more thing:

I remember that @annevk once said something like:

Basically, the DOM shouldn't change its behavior by its root node type.

We might violate this principle, but IMO, this case could be okay. If we find that this inconsistency could be a problem in the future, we might want to relax the condition of "only if a slot is in a shadow tree". Until that, I prefer conservative approach.

annevk commented 7 years ago

It seems somewhat reasonable. I think it would be even better if slot outside shadow trees didn't do anything else either then. No functional API whatsoever, but maybe that's more trouble than it's worth.

hayatoito commented 7 years ago

I see. I am okay to disable <slot>'s functional APIs completely if it is not in a shadow tree. The sounds more consistent.

As of now, the functional APIs which are visible to outside are:

  1. <slot> can fire a slotchange even if it is not in a shadow tree, as I explained
  2. slot.getAssignedNodes({flatten: true}): This might return non-empty if the slot has a child even if the slot is not in a shadow tree.

I think that is all. We should update DOM Standard to disable 1 and 2.

I am happy to update PR to DOM Standard if this is okay. I think this change doesn't have any practical impact on web developers.

surma commented 7 years ago

I like this. Disabling <slot> functionality outside of a ShadowRoot seems very reasonable to me and will simplify explaining behavior should someone run into one of the edge cases.

@hayatoito I think that change should be covered in a separate WPT for ShadowDOM. Do you want to add those?