WICG / webcomponents

Web Components specifications
Other
4.39k stars 375 forks source link

Need "slotchange" event #288

Closed dglazkov closed 8 years ago

dglazkov commented 9 years ago

For developers, it will be good to have an event that is fired whenever slotting algorithm runs within a shadow tree. Maybe runs and produces changes compared to the previous result? Don't know. Also don't know if this is v1 or v2.

rniwa commented 8 years ago

@esprehn : I'm fine with using mutation observer if you guys agree that the records are inserted synchronously when nodes are inserted/removed from DOM. @hayatoito didn't want to implement something like that, and wanted a single entry per slot assignment/distribution change per micro-task to avoid style-recalc(?) in Blink, and that's way too weird for mutation observers because the whole point of mutation records is that it gives you records of what had happened.

So please go figure that out amongst your colleagues before arguing with other vendors.

hayatoito commented 8 years ago

Yeah, I am aware that this is super-expensive potentially, as I have expressed a strong concern many times in this issue.

But we do not have any measurable data yet.

Let me think about using MutationObserver too. Let me make yet another POC. I'd like to measure the difference. I'll update this issue later.

esprehn commented 8 years ago

@rniwa Are you fine inserting records synchronously even if nothing changed? That is you notify all observers that the slots could have changed, but may not have?

rniwa commented 8 years ago

@esprehn : That doesn't seem like a great developer ergonomics. If that's acceptable, and the developers are expected to check whether something has changed for real and/or what has changed, then why don't we just fire an asynchronous event instead?

It just doesn't make sense for browsers to be creating useless mutation record objects for each potential change when the author is going to ignore all those records and check whether something has changed themselves. It also makes mutation observer API less coherent because no other mutation type does this.

hayatoito commented 8 years ago

I've not measured it yet, but if we were to support such a synchronous record which should be inserted only when a slot's distributed nodes really change, that sounds much expensive than an event at microtask checkpoint because UA has to check the potential change of distributed events at each DOM mutation. How that can be done in O(1) in terms of time and space?

rniwa commented 8 years ago

@hayatoito in WebKit, such a check can be done in O(k) where k is the number of slots each node has to go through during distribution assuming the slots within each shadow tree have not been mutated (no slot was removed or inserted). I'm sure @dglazkov can explain it to you.

Having said that, I like async event best. Neither mutation observer nor an event at end-of-microtask makes much sense given the use cases presented thus far are all about updating the appearance, which doesn't need to occur until right before the browser paints the page. There is no need to keep adding useless records or keep firing events at the end of each micro-task.

annevk commented 8 years ago

I think regardless of what approach we take, the only reasonable way to specify this is by running "distribution" during insert/remove operations. That seems expensive since lots of changes can affect distribution. Hopefully implementations can find ways to optimize.

So, during insert/remove we will have figured out whether new nodes are distributed to a slot.

If we want to use mutation observers, I suggest that at the end of insert/remove, if a slot element's distributed content has changed, we queue a mutation record whose type is "slot", target is the slot element, and everything else is empty/null. That seems reasonably simple.

If we don't want to use mutation observers, I suggest that again at the end of insert/remove, and if a slot element's distributed content has changed, we queue a microtask to dispatch an event at the slot element. Now, if it's really too expensive that this event would traverse the tree, we could give <slot> some kind of <slot>.events event target object and dispatch the change event there, but that gets a little ugly (and requires an extra object per slot).

Having written this up I think I favor mutation records, but @ajklein not leaning in that direction makes me a little wary.

ajklein commented 8 years ago

My reaction earlier in this thread to MutationRecords shouldn't be given too much weight: it was a gut check more than anything else.

rniwa commented 8 years ago

I've implemented this as slotchange event in WebKit. You can try it out on our nightly builds: https://webkit.org/nightly/.

annevk commented 8 years ago

@rniwa could you describe the semantics here so we don't have to reverse engineer your implementation?

rniwa commented 8 years ago

@annevk : it's an asynchronous event that fires at the end of task on each slot when a slot's distributed nodes change.

annevk commented 8 years ago

@rniwa can actions in microtasks affect it? What happens for distribution changes during microtasks? You really need to elaborate on how scheduling works in detail.

rniwa commented 8 years ago

@annevk : You can see the full implementation in http://trac.webkit.org/changeset/198115. What I'm doing is scheduling a new task which fires an event. e.g. when inserting a node, I'd check if that node will affect the distributed node of any slot, and if it does, I schedule a new task which fires this event unless we've previously scheduled such a task on the same slot element in the same task (includes same micro task). Since I'm queuing a new task, there is no micro-tasks involved.

annevk commented 8 years ago

I see. But that means user interface events and such can get in between and we're solving none of the problems we solved with mutation observers. Given such an implementation I think reusing the mutation observer machinery would be better.

We could even make it so that there's at most one mutation record of this type in the queue, if that alleviates some concerns. It would make this record a little bit special, but I think that might be okay.

rniwa commented 8 years ago

I think the use case matters here. It looks like the all use cases listed here are about updating the UI / rendering to accommodate changes in the distributed nodes, and not so much about updating DOM / API surface for component users. So it's actually desirable for those updates to not take place until the end of the current task. I'm also fine with dispatching this event before the next rAF as well.

I don't think inserting at most one mutation record of this type is a good idea because it breaks the fundamental invariant of mutation observers that all mutations are recorded, and that each mutation record allows users to revert and reapply the same mutation elsewhere.

annevk commented 8 years ago

@rniwa I think it doesn't break that invariant. The moment you observe the mutation record (be it through takeRecords() or end-of-task) you can replay it. And once it's gone a new one will be queued if appropriate which you can then replay again.

And yes, if it's about updating UI queuing a task as you did is the wrong way to go, since UI can happen between tasks (and other tasks can happen between tasks, triggering yet more undesired effects).

rniwa commented 8 years ago

My problem is more that there will be no context to re-construct what had happened. We'd need insertedNodes and removedNodes, etc... to replicate the same distributed nodes change, and that would be way too expensive for slots.

Like I just said, the only sane alternative is to fire at the same time as the next rAF.

annevk commented 8 years ago

If you do your own bookkeeping, you can replay. This is similar to attribute records without old values. But instead here we don't even provide a way to get to the old value (you have to store it yourself) and new value you have to get yourself too through a method.

Something akin to rAF could work, but e.g., @smaug and @dbaron have raised concerns in https://www.w3.org/Bugs/Public/show_bug.cgi?id=28876 that we already have too much activity unwinding at that point. We'd have to clearly define when it happens relative to the other activity that happens there, which is doable within the framework HTML provides, but per feedback it's unclear whether that framework will stand the test of time.

Furthermore, if you only do this for rAF you lose the generality of Shadow DOM working in any node tree. Suddenly some small part of it will only work correctly when chained up to a document that is the active document of a browsing context. That seems rather sad.

rniwa commented 8 years ago

I think that means that we can't have this in v1. I don't think we can come to a consensus on this matter within a reasonable amount of time.

JanMiksovsky commented 8 years ago

Aaaaaah! Please don't give up on this! We need this feature!

If it's proving too difficult to converge on a design in this forum, would it help to convene an online discussion to hash out a design everyone likes for this? I'm happy to volunteer to pull that together. Strawman: one rep from each browser vendor, to meet for up to 2 hours during the Pacific Daylight Time workday in the first (or possibly second) week of April. @rniwa: Would that be fast enough?

Pleeeeeeease?

rniwa commented 8 years ago

I'm proposing to have a telecon in the first or the second week of April since we need to announce in-person meetings at least eight weeks in advance (especially because I'm going off on a vacation starting April 13th).

treshugart commented 8 years ago

FWIW We've implemented the slotchange in a prollyfill and the overhead in triggering a debounced event is minimal, though it is noticeable in benchmarks. From the small amount of practical experience we have using it, it seems to do what we need quite nicely.

We also spiked attaching added / removed nodes as detail to the event since we could record which nodes have been slotted / unslotted since the last event was triggered. We ended up removing it since we weren't sure what was happening in this thread and we didn't have a valid use case for it. That said, it didn't impact performance when reporting unflattened nodes.

annevk commented 8 years ago

@treshugart what is debounced? Also, it looks like your event is dispatched synchronously.

annevk commented 8 years ago

@rniwa it would help if you could elaborate a bit. I have explained how using mutation records could work and could be kept consistent with the mutation records we have today. Thanks to the takeRecords() feature it seems also the only approach that could explain synchronous layout features (apart from something that works exactly like it, but isn't mutation observers, of course).

I've also explained how using animation frame timing does not fit with shadow DOM since it does not work for arbitrary node trees. I've explained how using normal tasks is problematic.

I could come up with a description for a microtask-timed event, but it seems that would end up being very similar to mutation observers and not have the takeRecords() benefit, unless we add that on top.

I'm happy to try attend a teleconference if one is scheduled, but I don't see why we can't continue to have this conversation here.

rniwa commented 8 years ago

I have explained how using mutation records could work and could be kept consistent with the mutation records we have today.

That depends on what you mean by "kept consistent". As far as I'm concerned, the pinnacle of mutation observers is its ability to reconstruct the state of a DOM tree. Using childList option, one can observe every child node inserted and removed from a parent. With slotchange, only thing we can observe is that the list of distributed nodes have changed. It doesn't tell us which nodes are added or removed into/from where.

Because of this, I'm opposed to using mutation observers unless the list of nodes that got inserted and removed are also provided. But @hayatoito and I both agree that doing so would be prohibitively expensive in both Blink and WebKit so I don't think this is an option.

Thanks to the takeRecords() feature it seems also the only approach that could explain synchronous layout features (apart from something that works exactly like it, but isn't mutation observers, of course).

No, you can't quite explain that since builtin elements update its layout/rendering synchronously when other DOM / CSS OM APIs forces a layout. There is no mechanism by which custom elements can be notified to call takeRecords() in time.

I've also explained how using animation frame timing does not fit with shadow DOM since it does not work for arbitrary node trees. I've explained how using normal tasks is problematic.

That's a fair point. In that case, we probably just need to make it a synchronous event as @treshugart just implemented but I don't think Blink folks are amendable to implement this as a synchronous event since it would be expensive.

Finally, there is an option to implement this as an asynchronous event as I've done in WebKit. However, you're opposed to that:

And yes, if it's about updating UI queuing a task as you did is the wrong way to go, since UI can happen between tasks (and other tasks can happen between tasks, triggering yet more undesired effects).

So I don't see a way forward on this issue unless one of us changes his/her opinion and/or new evidence emerges that suggests one approach is better than others.

annevk commented 8 years ago

@rniwa it depends entirely on how you observe whether or not you can recreate the tree. We could just say that for v1 (and maybe for eternity) there's no exposed way to observe everything with regards to slots, but with your own additional bookkeeping you will get the same result.

Having said that, I'm not necessarily opposed to dispatching an event using microtask timing. It sounds like you are not necessarily opposed to that either given that you think synchronous is okay here.

So maybe we should do that. The simplest way to do that would be to simply dispatch these events at the end of https://dom.spec.whatwg.org/#notify-mutation-observers.

@smaug----, thoughts?

treshugart commented 8 years ago

@annevk

What is debounced?

https://github.com/component/debounce

It uses setTimeout() - thus queueing a task - but it ensures that it only gets fired once after repeated synchronous calls to that method are invoked, meaning multiple slot changes are batched into a single event.

Also, it looks like your event is dispatched synchronously.

Do you mean synchronous as in dispatchEvent() is synchronous, or do you mean synchronous as in you read it like an event would fire for every single mutation instead of being batched? If the former, I don't have any idea how I'd do that in polyfill-land. If the latter, then debounce does make it "async".

I've thought about doing the same thing with requestAnimationFrame() to get closer to what's being described in this thread but haven't got around to it yet. Unfortunately, I don't think there is a better way to get closer to what's described here, in polyfill-land.

Even though it's currently using tasks, we have the ability to do our own bookkeeping, so we know when and where changes have happened even if UI changes have happened between queueing a task to trigger the event and when the event eventually gets fired. When the task is executed and just before the event is fired, we can take the records and attach them to the event detail if need be. We don't currently do this, but it's fairly trivial to implement for a given slot's assigned nodes (not deep).

The alternative - as has already been explained - would be to leave it up to the user to diff the previous state with the new state. This is definitely doable. We haven't come up with a use case warranting knowing the exact changes yet for a slot change, but we also haven't really used the event in anger yet.

If it'd be helpful, I'm happy to try and see what I can do in terms of spiking a mutation observer implementation in the polyfill.

smaug---- commented 8 years ago

I'm having hard time to understand what kind of of "async" event webkit has now. " it's an asynchronous event that fires at the end of task on each slot when a slot's distributed nodes change". That is contradictory sentence, at least if I read it "at the end of the current task", since it means from UA point of view it isn't async.

So, hard to comment when I don't understand what is being proposed. Anyhow, I'm against "event that fires at the end of the current task". Better to use end of microtask, or just normal task queuing (but that latter leads to issues with rAF).

esprehn commented 8 years ago

WebKit appears to be using the DocumentEventQueue, which is effectively setTimeout(0) timing.

annevk commented 8 years ago

I would be happy here with either using a mutation record as I described earlier, or reusing the mutation observer machinery to dispatch a "slotchange" event at slot elements as described in https://github.com/w3c/webcomponents/issues/288#issuecomment-203351162.

rniwa commented 8 years ago

Okay, it seems like dispatching slotchange event at the end of micro-task seems like something we can all compromise to?

annevk commented 8 years ago

Yeah, "end of microtask" is not the exact terminology, but if everyone understands that to mean "mutation observer timing" I think we are in agreement.

rniwa commented 8 years ago

Yeah, that's what I mean.

annevk commented 8 years ago

Great, one thing less to discuss tonight. \o/

hayatoito commented 8 years ago

Do we have to resolve this issue https://github.com/w3c/webcomponents/issues/73 to clarity the condition which triggers the "slotchange" event?

For me, it is still unclear in which condition this event should be dispatched.

I think UA can optimize the calculation somehow, but we need the formal definition of the condition so that we can have an interoperable "slotchange" event.

annevk commented 8 years ago

Yes, the way I'll define this is by modifying the insert/remove algorithms in https://dom.spec.whatwg.org/#mutation-algorithms to assign slotables to slots and queue dispatch of this event if there was a change in distribution (and no event was queued thus far). This means that distribution is defined to be synchronous (and can be observed synchronously through various attributes and methods), but implementations can still make all manners of optimizations as we won't require the event to include a lot of detail and such and while the attributes and methods are defined to have synchronous answers they can be computed lazily too.

annevk commented 8 years ago

Telecon agreement: we'll define the slotchange event using mutation observer timing to be dispatched at each slot element. We might offer a feature similar to takeRecords() on slot elements in the future.

I'll try to make this happen in the DOM Standard.

hayatoito commented 8 years ago

Thanks. After the DOM Standard is updated, I'll spend some time on how to (efficiently) detect the slotchange of each slots, synchronously in each DOM mutation in Blink. That's the most difficult part, I think.

I'll provide implementation feedback.

annevk commented 8 years ago

I'm still struggling a bit with how to best define this. My idea was to effectively run slotting whenever you insert, but if we want to define "slotchange", we'll need to keep records to know something actually changed as a result of an insert or remove.

For insert, it seems these two are significant:

For remove:

So when those happen we need to "reslot" a bit and I guess we'd simply check whether an element was already assigned somewhere and if it remains assigned to the same slot it doesn't count as a change.

Does this brain storm sound reasonable?

rniwa commented 8 years ago

So you want to fire slotchange event when a slot element is newly inserted into a shadow root or removed from a shadow root? I'm not sure if that makes sense. The event I implemented in WebKit wouldn't get fired in those cases.

So when those happen we need to "result" a bit and I guess we'd simply check whether an element was already assigned somewhere and if it remains assigned to the same slot it doesn't count as a change.

That sounds right. You DO need to recursively check assignedSlot's assignedSlotif it's not null as we're affecting the distributed nodes of all those slot elements. For example, if we inserted a node N under a shadow root R1 with a slot element S1, and we realize that N is now newly assigned to S1. Then, that could affect the distributed nodes of another slot element S2 to which S1 had been assigned. If there is such a slot S2, we need to check whether S2 is assigned to another slot for the same reason, and so on and so forth.

annevk commented 8 years ago

So you want to fire slotchange event when a slot element is newly inserted into a shadow root or removed from a shadow root? I'm not sure if that makes sense. The event I implemented in WebKit wouldn't get fired in those cases.

That makes sense. You want to perform slotting though so you know if the next insert/remove changes things.

Then, that could affect the distributed nodes of another slot element S2 to which S1 had been assigned. If there is such a slot S2, we'd check whether S2 is assigned to another slot for the same reason, and so on and so forth.

And all of them get an event, right? Thank you for mentioning that.

rniwa commented 8 years ago

So you want to fire slotchange event when a slot element is newly inserted into a shadow root or removed from a shadow root? I'm not sure if that makes sense. The event I implemented in WebKit wouldn't get fired in those cases.

That makes sense. You want to perform slotting though so you know if the next insert/remove changes things.

Right.

Then, that could affect the distributed nodes of another slot element S2 to which S1 had been assigned. If there is such a slot S2, we'd check whether S2 is assigned to another slot for the same reason, and so on and so forth.

And all of them get an event, right? Thank you for mentioning that.

Yup.

hayatoito commented 8 years ago

For insert, it seems these two are significant:

"Insert" means this timing, https://dom.spec.whatwg.org/#concept-node-insert, right? I am assuming that we do nothing at the timing of insertion steps (https://dom.spec.whatwg.org/#concept-node-insert-ext).

  • The node is being inserted into a parent node that also has a shadow root
  • An inclusive-descendant of node is a slot element and node's (new) root is a shadow root

It looks we should remove the condition of "node's (new) root is a shadow root" from there.

e.g. The following might not be a practical example, but it could happen:

document tree:

<x-foo>
  <slot id=s1>
    <div id=inserted></div> <--- inserted
  </slot>
</x-foo>

x-foo' shadow tree:

<slot id=s2></slot>

s2's "slotchange" should be fired if #inserted is inserted as a child of #s1.

inserted's root is not a shadow root in this case.

In addition to insert and remove, we have to check the change of element's "slot" attribute and slot's "name" attribute.

Let me take another look later.

annevk commented 8 years ago

@hayatoito "insertion steps" run at roughly the same time as "insert", they're part of the same atomic operation anyway.

annevk commented 8 years ago

@hayatoito your example is a case I missed by the way, that would be a "node is inserted into a parent that is a slot element". If nothing is assigned to the node, that would be the fallback that will get assigned.

annevk commented 8 years ago

Here is some pseudo-code I plan to adopt for the DOM Standard next week. Hopefully folks can take a look at it and hopefully it's understandable. I'm assuming that slotables have pointers to slots and slots hold a list of pointers to slotables and both need updating during mutations. Flattening is not calculated as that does not seem necessary. CSS and the assignedNodes() method can handle flattening by themselves.

insert

  The node is being inserted into a parent node that also has a shadow root
    -> let slot be findSlot(node)
    -> if slot is non-null:
      -> run assignSlotables(slot) // this is naive, but good enough for spec
      -> slotchange(slot)

  The node is being inserted into a parent node that is a slot
    -> assignSlotables(parent)
    -> if node's assigned slot is non-null, slotchange(node's assigned slot)

  An inclusive-descendant of node is a slot element
    -> for each _slot_ in node's tree // a new slot changes everything
      -> let current be _slot_'s assigned nodes
      -> assignSlotables(_slot)
      -> let new be _slot_'s assigned nodes
      -> if current != new and _slot_ was not just inserted, slotchange(_slot_)

remove

  Node has an assigned slot
    -> slotchange(node's assigned slot) // recursive
    -> run assignSlotables(node's assigned slot)
    -> Assert: node's assigned slot is null

  Node has a slot element as inclusive-descendant
    -> for each _slot_ in node's tree:
      -> let current be _slot_'s asigned nodes
      -> assignSlotables(_slot_)
      -> let new be _slot_'s assigned nodes
      -> if current != new, slotchange(_slot_)
    -> for each _slot_ in oldParent's tree // spec calls this parent
      -> let current be _slot_'s asigned nodes
      -> assignSlotables(_slot_)
      -> let new be _slot_'s assigned nodes
      -> if current != new, slotchange(_slot_)

slotable's slot attribute change
  -> if value == oldValue, return
  -> if node's assigned slot is non-null:
    -> assignSlotables(node's assigned slot)
    -> slotchange(node's assigned slot)
  -> Assert: node's assigned slot == null
  -> let slot be findSlot(node)
  -> if slot is non-null
    -> assignSlotables(slot)
    -> slotchange(slot)

slot's name attribute change
  -> for each slot element _slot_ in slot element's tree
    -> let current be _slot_'s asigned nodes
    -> assignSlotables(_slot)
    -> let new be _slot_'s assigned nodes
    -> if current != new, slotchange(_slot_)

Obviously there's a bit of copypasta here that can be (and will be in the spec) reduced with extra algorithms.

hayatoito commented 8 years ago

Thanks. I have reviewed "insert" part. Let me review other parts later.

It looks that there is an assumption that we preserve the result of assgnSlottables(slot) somewhere. In other words, before running each algorithms, we can assume that each slot's "assingned nodes" are always up-to-date, right?

insert

  The node is being inserted into a parent node that also has a shadow root
    -> let slot be findSlot(node)
    -> if slot is non-null:
      -> run assignSlotables(slot) // this is naive, but good enough for spec
      -> slotchange(slot)

How slotchange(slot) can be defined? I think that should be recursive, like:

function slotchanged(slot):
  enqueue_slotchange_event_if_it_is_not_enquened_for(slot);
  let slot2 = fildSlot(slot):
  if slot2 != null:
     slotchanged(slot2)

Does this match your expectation?

Ops. I am afraid that this slotchanged(slot) is still not enough because a slot might be a fallback content of another slot. That makes the situation complex. We need additional check:

function slotchanged(slot):
  enqueue_slotchange_event_if_it_is_not_enquened_for(slot);
  let slot2 = findSlot(slot):
  if (slot2 != null):
     slotchanged(slot2)
  else if (slot's parent is slot) and (slot's parent's assigned nodes are empty):
     slotchanged(slot's parent)

  The node is being inserted into a parent node that is a slot
    -> assignSlotables(parent)
    -> if node's assigned slot is non-null, slotchange(node's assigned slot)

The node is never assigned to the parent slot, according to the definition. It can be a member of distributed nodes, as a fallback content, but it is not assigned to the parent, by definition. Thus, these should be:

  The node is being inserted into a parent node that is a slot
    -> if parent's assigned nodes are empty, slotchange(parent)

  An inclusive-descendant of node is a slot element
    -> for each _slot_ in node's tree // a new slot changes everything

This should be in tree order (which is implicit?) to make sure that assignSlottables(slot-A) is called before assignSlottables(slot-B-which-is-a-descendant-of-A) is called. That is required to handle the fallback contents correctly.

annevk commented 8 years ago

Yes. A slot has assigned nodes (a list of slotables). A slotable has an assigned slot ( a slot).

The assign slotables algorithm updates both those internal variables. (Therefore the node can end up being assigned to the parent when it is inserted into a slot. I don't see a reason to special case fallback content. It should just end up being assigned if there only is fallback content.)

The "slotchange" algorithm (going to call it signal a slot change most likely) is indeed recursive, but does not need "findSlot", it can simply use the assigned slot pointer for recursion.

hayatoito commented 8 years ago

I don't see a reason to special case fallback content. It should just end up being assigned if there only is fallback content.

Yeah, I am feeling that "Not considering fallback contents as assigned nodes" is making the algorithms around here complicated. However, that's the conclusion in https://github.com/w3c/webcomponents/issues/317.

I am open to either option:

  1. Preserve the status quo. "assigned nodes" do not include the fallback contents. Instead, we might want to have a helper function for the spec so that we can write the algorithms easier.
  2. Let the definition of "assigned nodes" include the fallback contents too.

@rniwa , WDTY? 2 is a relatively huge change for the spec. I have to update a lot of places... :(

annevk commented 8 years ago

Oh, I missed that assignedNodes() only includes fallback in the flattened case. Okay, then yes, I should make the changes as you suggested and review again. I don't think we need to change the current model.