Skip to content
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

DOM APIs which replace all children end up firing superfluous slotchange events #764

Open
rniwa opened this issue Aug 30, 2018 · 17 comments

Comments

@rniwa
Copy link
Collaborator

rniwa commented Aug 30, 2018

When replacing all children of a node, via textContent for example, removing each child of the node results in the invocation of the concept to assign slotables for a tree.

For example, consider the following shadow tree:
<p id="target"><slot id="slot1"></slot><slot id="slot2"></slot><slot id="slot3"></slot></p>

If target.textContent = '' is evaluated, it would result in the removal of slot1, followed by that of slot2 and slot3. As a result, we would fire slotchange event on slot2 and slot3 despite of the fact those slots would have never been "rendered" with assigned nodes in practice.

This results in slotchange event handlers of slot2 and slot3 potentially doing unnecessary & useless work.

Relatedly, Firefox's implementation seems to fire slotchange on slot2 & slot3 when target.remove() is evaluated, which seems wrong because the time at which the concept to assign slotables for a tree is evaluated, slot2 and slot3 are no longer in the tree.

See https://gist.github.com/rniwa/1a24c77317a50785ae8885e58f35966c for the demo.

@rniwa
Copy link
Collaborator Author

rniwa commented Aug 30, 2018

@hayatoito
Copy link
Contributor

It looks an expected behavior to me; Fire slotchange events on every slots if their assigned nodes are changed.

I wonder what is a practical issue for the current expected behavior? Could you share that with us?

Unless there is a strong practical issue, I would prefer a consistent behavior.

@emilio
Copy link

emilio commented Aug 31, 2018

cc @smaug----

@smaug----
Copy link

Ok, Gecko may have an issue, but other than that, looks reasonable and consistent.

@rniwa
Copy link
Collaborator Author

rniwa commented Sep 4, 2018

The issue here is that slot elements get superfluous slotchange events which are of no use. The fact replace all is implemented as a sequence of removing each node is more of a spec fiction. The author shouldn't have to deal with it. There is also a serious performance cost in firing slotchange event on all those slot elements. In fact, if we were to implement this in WebKit, we would slow down all operations of replace all so it's simply not acceptable. As such, the WebKit team would object to the currently specified behavior.

Note that neither Gecko nor WebKit implement the currently specified behavior, and Chrome doesn't fire slotchange in the tree order in other cases. It just so happens to match this particular spec behavior but the order of elements in other cases are not spec conforming.

hubot pushed a commit to WebKit/WebKit-http that referenced this issue Sep 5, 2018
…ing slot elements

https://bugs.webkit.org/show_bug.cgi?id=189144
<rdar://problem/43871061>

Reviewed by Antti Koivisto.

LayoutTests/imported/w3c:

* web-platform-tests/shadow-dom/slotchange-customelements-expected.txt:
* web-platform-tests/shadow-dom/slotchange-event-expected.txt:
* web-platform-tests/shadow-dom/slotchange-expected.txt:

Source/WebCore:

This patch implements `slotchange` event when a slot element is inserted, removed, or renamed in the DOM tree.
Let us consider each scenario separately.

Insertion (https://dom.spec.whatwg.org/#concept-node-insert): In this case, we must fire `slotchange` event on
slot elements whose assigned nodes have changed in the tree order. When there is at most one slot element for
each name, this can be done by simply checking whether each slot has assigned nodes or not. When there are more
than one slot element, however, the newly inserted slot element may now become the first slot of a given name,
and gain assined nodes while the previously first element loses its assigned nodes. To see if the newly inserted
slot element is the first of its kind, we must travere the DOM tree to check the order of that and the previously
first slot element. To do this, we resolve the slot elements before start inserting nodes in
executeNodeInsertionWithScriptAssertion via SlotAssignment::resolveSlotsBeforeNodeInsertionOrRemoval. Note that
when the DOM tree has at most one slot element of its kind, resolveSlotsBeforeNodeInsertionOrRemoval is a no-op
and addSlotElementByName continues to operate in O(1). Becasue addSlotElementByName is called on each inserted
slot element in the tree order, we do the tree traversal upon finding the first slot element which has more than
one of its kind in the current tree. In this case, we resolve all other slot elements and enqueues slotchange
event as needed to avoid doing the tree traversal more than once.

Removal (https://dom.spec.whatwg.org/#concept-node-remove): In removal, we're concerned with removing the first
slot element of its kind. We must fire slotchange event on the remaining slot elements which became the first of
its kind after the removal as well as the ones which got removed from the tree if they had assigned nodes.
Furthermore, the DOM specification mandates that we first fire slotchange events in the tree from which a node
was removed and then in the removed subtree. Because we must only fire slotchange event on the first slot element
of its kind which has been removed, we must resolve the first slot elements of each kind before a node removal
in removeAllChildrenWithScriptAssertion and removeNodeWithScriptAssertion as we've done for insertion. Again,
in the case there was at most one slot element of each kind, resolveSlotsBeforeNodeInsertionOrRemoval is a no-op
and removeSlotElementByName would continue to operate in O(1). When there are multiple slot elements for a given
kind, we immediately enqueue slotchange event on the slot elements which newly became the first of its kind but
delay the enqueuing of slotchange event on the removed slot elements until removeSlotElementByName is called on
that element so that enqueuing of slotchange events on the slot elements still remaining in the in the tree would
happen before those which got removed as the DOM specification mandates.

Rename (https://dom.spec.whatwg.org/#shadow-tree-slots): In the case the slot element's name content attribute
is changed, the renamed element might have become the first of its kind or ceased to be the first of its kind.
There could be two other slot elements appearing later in the tree order which might have gained or lost assigned
nodes as a result. In this case, we invoke the algorithms for removing & inserting the slot with a key difference:
we enqueue slotchange event on the renamed slot immediately if it has assigned nodes.

To enqueue slotchange event in the tree order, this patch adds oldElement, which is a WeakPtr to a slot element,
to SlotAssignment::Slot. This WeakPtr is set to the slot element which used to have assigned nodes prior to the
node insertion, removal, or rename but no longer has after the mutation. This patch also adds a slot mutation
version number, m_slotMutationVersion, which is incremented whenever a node is about to be inserted or removed,
and slot resolution version, m_slotResolutionVersion, which is set to the current slot mutation version number
when the full slot resolution is triggered during slot mutations. They are used to avoid redundant tree traversals
in resolveSlotsAfterSlotMutation. This patch also makes m_needsToResolveSlotElements compiled in release builds
to avoid resolving slot elements when there is at most one slot element of each kind.

For insertion, oldElement is set to the slot which used to be the first of its kind before getting set to a slot
element being inserted in resolveSlotsAfterSlotMutation. We enqueue slotchange event on the newly inserted slot
element at that point (1). Since the slot element which used to be the first of its kind appears after the newly
inserted slot element by definition, we're guaranteed to see this oldElement later in the tree traversal upon
which we enqueue slotchange event. Note that if this slot element was the first of its kind, then we're simply
hitting (1), which is O(1) and does not invoke the tree traversal.

For removal, oldElement is set to the slot which used to be the first of its kind. Because this slot element is
getting removed, slotchange event must be enqueud after slotchange events have been enqueued on all slot elements
still remaining in the tree. To do this, we enqueue slotchange event immediately on the first slot element of
its kind during the tree traversal as we encounter it (2), and set oldElement to the previosuly-first-but-removed
slot element. slotchange event is enqueued on this slot element when removeSlotElementByName is later invoked via
HTMLSlotElement::removedFromAncestor which traverses each removed element in the tree order. Again, if this was
the last slot of its kind, we'd simply expedite (2) by enqueuing slotchange event during removeSlotElementByName,
which is O(1).

When the DOM invokes the concept to replace all children (https://dom.spec.whatwg.org/#concept-node-replace-all),
however, this algorithm isn't sufficient because the removal of each child happens one after another. We would
either need to resolve slots between each removal, or treat the removal of all children as a single operation.
While the DOM specification currently specifies the former behavior, this patch implements the latter behavior
to avoid useless work. See the DOM spec issue: WICG/webcomponents#764

Test: fast/shadow-dom/slotchange-for-slot-mutation.html

* dom/ContainerNode.cpp:
(WebCore::ContainerNode::removeAllChildrenWithScriptAssertion): Call resolveSlotsBeforeNodeInsertionOrRemoval
before start removing children.
(WebCore::ContainerNode::removeNodeWithScriptAssertion): Ditto.
(WebCore::executeNodeInsertionWithScriptAssertion): Ditto before inserting children.
* dom/ShadowRoot.cpp:
(WebCore::ShadowRoot::~ShadowRoot): Set m_hasBegunDeletingDetachedChildren to true. This flag is used to supress
slotchange events during the shadow tree destruction.
(WebCore::ShadowRoot::renameSlotElement): Added.
(WebCore::ShadowRoot::removeSlotElementByName): Added oldParentOfRemovedTree as an argument.
* dom/ShadowRoot.h:
(WebCore::ShadowRoot::shouldFireSlotchangeEvent): Added.
* dom/SlotAssignment.cpp:
(WebCore::findSlotElement): Added.
(WebCore::nextSlotElementSkippingSubtree): Added.
(WebCore::SlotAssignment::hasAssignedNodes): Added. Returns true if the slot of a given name has assigned nodes.
(WebCore::SlotAssignment::renameSlotElement): Added.
(WebCore::SlotAssignment::addSlotElementByName): Call resolveSlotsAfterSlotMutation when slotchange event needs
to be dispatched for the current slot and there are more than one slot elements.
(WebCore::SlotAssignment::removeSlotElementByName): Ditto. When the slot's oldElement is set to the current slot
element, meaning that this slot element used to have assigned nodes, then enqueue slotchange event. It also has
a special case for oldParentOfRemovedTree is null when renaming a slot element. In this case, we want to enqueue
slot change event immediately on the renamed slot element and any affected elements as in a node insertion since
removeSlotElementByName would never be called on a slot element which newly become the first of its kind after
a slot rename.
(WebCore::SlotAssignment::resolveSlotsAfterSlotMutation): Added. This is the slot resolution algorithm invoked
when there are more than one slot elements for a given name. It has two modes dealing with insertion & removal.
The insertion mode is also used for renaming a slot element. The firs
(WebCore::SlotAssignment::resolveSlotsBeforeNodeInsertionOrRemoval): Added. Resolves all slot elements prior to
inserting or removing nodes. In many cases, this should be a no-op since m_needsToResolveSlotElements is set to
true only when there are more than one slot element of its kind.
(WebCore::SlotAssignment::willRemoveAllChildren): Ditto. Also sets m_willBeRemovingAllChildren to true.
(WebCore::SlotAssignment::didChangeSlot):
(WebCore::SlotAssignment::resolveAllSlotElements): Use seenFirstElement instead of element to indicate whether
we have seen a slot element of given name for consistency with resolveSlotsAfterSlotMutation.
* dom/SlotAssignment.h:
(WebCore::SlotAssignment::Slot): Added oldElement and seenFirstElement.
(WebCore::SlotAssignment): Always compile m_needsToResolveSlotElements. Added m_willBeRemovingAllChildren,
m_slotMutationVersion, and m_slotResolutionVersion.
(WebCore::ShadowRoot::resolveSlotsBeforeNodeInsertionOrRemoval): Added. Calls the one in SlotAssignment.
(WebCore::ShadowRoot::willRemoveAllChildren): Ditto.
* html/HTMLSlotElement.cpp:
(WebCore::HTMLSlotElement::removedFromAncestor):
(WebCore::HTMLSlotElement::attributeChanged): Calls ShadowRoot::renameSlotElement instead of
removeSlotElementByName and addSlotElementByName pair.

LayoutTests:

Added a W3C style testharness.js test for inserting, removing, and renaming slot elements.

It has 62 distinct test cases for closed/open shadow roots in connected and disconnected trees
for the total of 248 test cases.

This test presumes the resolution of WICG/webcomponents#764 in our favor.

Chrome fails 48 test cases because it doesn't follow the tree order when dispatching slotchange event
on the previously first slot element, and Firefox fails 84 test cases because it fails to fire slotchange
in the tree order when a node is inserted.

* fast/shadow-dom/slotchange-for-slot-mutation-expected.txt: Added.
* fast/shadow-dom/slotchange-for-slot-mutation.html: Added.


git-svn-id: http://svn.webkit.org/repository/webkit/trunk@235650 268f45cc-cd09-0410-ab3c-d52691b4dbfc
@hayatoito
Copy link
Contributor

hayatoito commented Sep 5, 2018

Thanks. So the expected behavior from the author's perspective would be:

shadowRoot: <p><slot id="slot1"></slot><b><slot id="slot2"></slot></b><slot id="slot3"></slot></p>
Removing p results in slotchange events on: ["slot1"]
shadowRoot: <p><slot id="slot1"></slot><slot id="slot2"></slot><slot id="slot3"></slot></p>
Setting p.textContent to "" results in slotchange events on: ["slot1"]

, right? (Both cases fire a slotchange event only on slot1). I agree that this behavior looks consistent from the autors' perspective.

From the spec's perspective, can the spec (or maybe the implementation too?) become complicated if we try to align the current spec to the author's expected behavior?

@rniwa
Copy link
Collaborator Author

rniwa commented Sep 5, 2018

@hayatoito : I think implementation / spec just needs to special-case replace-all case, e.g. add a flag to the concept to remove a node similar to an optional suppress observers flag and skip steps 12, 12.1 and 12.2 which assign spottables for a tree. replace all would then invoke it later.

It would add a slight complexity but I think it's an overall win for developer ergonomics & perf since resolving slots after removing each subtree is very expensive.

@annevk
Copy link
Collaborator

annevk commented Sep 7, 2018

What exactly would replace all invoke? "Run assign slotables for a tree with parent's root."? Or node's root? I guess they're always equivalent if we do this at the end. Presumably we would have to adjust the insert operation as well, which also has a suppress observers flag? Or is the "problem" non-existent there somehow? (Unfortunately I don't have all the logic in my head anymore, I hope you all don't mind the questions.)

@caridy
Copy link

caridy commented Sep 7, 2018

cc @ekashida

@rniwa
Copy link
Collaborator Author

rniwa commented Sep 8, 2018

We'd have to run assign slotables for a tree with parent’s root, and then run it again for the children we just removed.

@rniwa rniwa closed this as completed Sep 8, 2018
@rniwa rniwa reopened this Sep 8, 2018
@rniwa
Copy link
Collaborator Author

rniwa commented Sep 8, 2018

There is a minor complication for implementors that mutation events and unload evnets in iframes could insert those removed child back into somewhere else but it's not really an issue for the spec since it doesn't have mutation events (WebKit and Blink also disables mutation events in shadow trees).

@annevk
Copy link
Collaborator

annevk commented Sep 10, 2018

(To be clear, mutation events might well become an issue if they are not removed: whatwg/dom#305. There was some talk of firing them at a similar time as custom element reactions however, which would reduce a number of issues.)

@annevk
Copy link
Collaborator

annevk commented Sep 10, 2018

@rniwa I looked at this some more and I wonder if we should basically extend the "suppress observers flag" to also cover running "assign slotables for a tree". E.g., if you do replaceChild() (defined via https://dom.spec.whatwg.org/#concept-node-replace) with two <slot>s, it seems we should also run the algorithm once for parent's root and once for the removed node, right?

I guess it might only be observable for the case you brought up, but it seems conceptually cleaner if we manage them all in the same way.

@rniwa
Copy link
Collaborator Author

rniwa commented Sep 11, 2018

Sure, re-using the suppress observers flag makes senes to me.

annevk added a commit to whatwg/dom that referenced this issue Sep 11, 2018
This is an attempt at fixing WICG/webcomponents#764.

I first wrote an algorithm that combined the operations done by remove and insert. I then used that for replace and replace all. I then abstracted it to avoid duplication.

I'm a little worried that this is not correct as this delays assigning slotables to a slot quite a bit, which I think might be observable in some cases.
@annevk
Copy link
Collaborator

annevk commented Sep 11, 2018

@rniwa I created whatwg/dom#695, but I'm a little worried as slotchange is intertwined with assigning slotables to slots.

annevk added a commit to whatwg/dom that referenced this issue Sep 11, 2018
This is an attempt at fixing WICG/webcomponents#764.

I first wrote an algorithm that combined the operations done by remove and insert. I then used that for replace and replace all. I then abstracted it to avoid duplication.

I'm a little worried that this is not correct as this delays assigning slotables to a slot quite a bit, which I think might be observable in some cases.
@orstavik
Copy link

You also get slotchange events that can be perceived as redundant when you construct layered custom elements with slots. This seems to be correct behavior according to spec, and it happens similarly in both Chrome, Firefox and Safari. But from the developer standpoint they are somewhat unexpected and can cause confusion.

The minimal situation:

  1. You have custom element B that uses custom A in its shadowRoot.
  2. Both A and B have slots, and adds a slotchange listener to their shadowRoot in their constructor.
  3. The slots are nested. B puts its slot in the slottable position of A.
  4. If you create B, with something in its slottable position, then that will fire:
    a. 2 slotchange event in A and
    b. 1 slotchange events in B.

https://codepen.io/orstavik/pen/jeEqpe

This problem also grows as you nest slot and web components deeper. If you have 3 layers, C -> B -> A, then you will get a total of 6 slotchange events:
3 slotchange event in A
2 slotchange events in B
1 slotchange event in C

https://codepen.io/orstavik/pen/bmNpLv

The content of the last codepen (that can be used to create both situations) I also add as an attachment.
redundantSlotchangeEvents.txt

@orstavik
Copy link

The principle of this problem is a bit tricky. Thinking out loud here of how I make sense of it.

When you construct the element, with children elements, and slotted children, it is a sequence of multiple actions. The inner constructor sets up elements with empty slottables in the first DOM constellation.
That triggers a slotchange event. Then the middle constructor sets up a second DOM constellation includes the inner element. That triggers a second slotchange event that bubbles to both the middle and inner slot element. Then the outer constructor sets up a third DOM constellation. That triggers a third slotchange event that bubbles to all three slot elements. 3 events fired. But 6 events caught. 3 events caught by the inner slot. 2 events by the middle. 1 event by the outer. And(!), in this case, and I think most such cases, the inner and middle slot is really only interested in the last slotchange event that they receive indirectly and that originates from the outermost slot.

Issue 1.
As a developer, I do not see the construction of a single custom element as the creation of a sequence of multiple DOM constellations. Although this is what the browser does, it took me a long time to envisage this DOM constellation sequence triggering 3 different slotchange events that bubble via the slots. And I am not sure I am right.

As a developer, I anticipate that the slotchange callback function of the two inner slots is only triggered once. I see the construction of this DOM branch as "a single slotchange moment".
In this particular scenario, I would also anticipate that the assignedNodes of the inner elements never would be empty. And I would not have thought about efficiency concerns for the inner or middle slot listener callback due to them being called twice.

Issue 2.
The last slotchange event that bubbles up to the innermost slot element, and that is the relevant slotchange event, does not originate from the innermost slot. That means that the event detail is confusing, as for example its .composedPath()[0] is the outermost slot.

If I attach the event listener for slotchange on the shadowRoot, and have multiple slots in my shadowRoot, I would have to search the event detail/ composedPath of the event detail to find the slot that is in my shadowRoot. If I simply assumed that .composedPath()[0] was the relevant slot element, and for example called .assignedNodes({flatten: true}) on it, then I could sometimes get the right data (as in this instance) and sometimes the wrong data (as more assigned nodes for that slot could be added in the middle layer).

annevk added a commit to whatwg/dom that referenced this issue Jan 19, 2021
This is an attempt at fixing WICG/webcomponents#764.

I first wrote an algorithm that combined the operations done by remove and insert. I then used that for replace and replace all. I then abstracted it to avoid duplication.

I'm a little worried that this is not correct as this delays assigning slotables to a slot quite a bit, which I think might be observable in some cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants