joicomponents
Version:
Design patterns to build native web components
530 lines (457 loc) • 23.9 kB
Markdown
# SlotchangeNipSlip: SlotchangeEavesdropping / BigBrotherEavesDropping
In [Problem: SlotchangeSurprise](7_Problem_SlotchangeSurprise), we saw how multiple `slotchange`
events can surprise us. In this chapter, we how *completely* irrelevant `slotchange` events
might trigger a `slotchange` listener, and thus how web components might accidentally eavesdrop
on `slotchange` events they should not be party of. We start with an example:
## Example:
Two core web component principles:
* Only `composed` events propagate up from the shadowDOM inside a custom element and through to the
host `node` and lightDOM of the custom element.
* Outside sources (other custom elements and scripts) should not listen into
events *inside* the shadowDOM of a custom element.
`slotchange` events are not `composed`.
A `slotchange` is and should be a *private* matter between the `<SLOT>` whose assignedNodes changes,
and the custom element which that `<SLOT>` directly belongs to.
But, with `slotchange` events there is a problem: `<SLOT>` elements can be chained.
*Direct* changes of the `assignedNodes` in one `<SLOT>` element can therefore also
*indirectly* change the `assignedNodes` of another `<SLOT>` element.
This chapter describes how to process both direct and indirect `slotchange` events.
## Direct and indirect `slotchange` events
For a custom element, a **directSlotNode** is a `<SLOT>` node that exist inside that
custom element's shadowDOM, and an **indirectSlotNode** is a `<SLOT>` node that
is chained to that slot.
A custom element who controls a `<SLOT>` element would like to be informed of two types of
`slotchange` events:
1. **Direct `slotchange` events**: a change in a `<SLOT>` element's *directly* assigned nodes.
If the `.childNodes` of the `host` element changes,
this could produce a direct `slotchange` event in a `<SLOT>`.
Direct `slotchange` events produce a different from the `<SLOT>.assignedNodes()`
before and after the event.
2. **Indirect `slotchange` events**: a change in a chained `<SLOT>` element.
If a `<SLOT>` node is placed as one of the `host` node's children, a change in that `<SLOT>` node's
`.assignedNodes()` can also indirectly change the `.assignedNodes({flatten: true})`
of the custom element's directSlotNode.
Such changes would *not* affect the result of the directSlotNode's `.assignedNodes()`, but *do*
change the result produces by the directSlotNode's `.assignedNodes({flatten: true})`.
As slots can be chained over many levels, indirect `slotchange` events can also
propagate via several slot nodes.
## Processing `slotchange` 1: `this.shadowRoot.addEventListener("slotchange" ...)`
`slotchange` events bubble, and when dispatched from a `<SLOT>` node,
it will bubble up to the `shadowRoot` ancestor of that `<SLOT>` node.
Also, when `<SLOT>` nodes are chained, `slotchange` events will also bubble up to the chained
`<SLOT>` node. This means that a `slotchange` event, if not stopped,
will propagate up so as to notify *all* `<SLOT>` nodes in its chain that would be affected.
Therefore, to retrieve both direct and indirect `slotchange` events in a custom element is simple.
Add an event listener on the `shadowRoot` in your custom element, and
this listener will then capture all direct and indirect slotchange events that can occur
inside your custom element.
To set up a callback for any custom element with a `.shadowRoot`, the following method can be applied:
```javascript
export function naiveSlotchangeCallback(el){
el.shadowRoot.addEventListener("slotchange", e => { //[*]
el.slotchangeCallback(e);
});
}
```
>Att 1!! Remember to add your `slotchange` listener to **this.shadowRoot**, and *not* ~~**this**~~!
`slotchange` events only `bubble`, but they are not `composed`.
When a `slotchange` event reach a `.shadowRoot` document, it will therefore *stop* and *not* travel
from this `this.shadowRoot` node and to `this` (the `host` node).
>Att 2!! Do not call `.stopPropagation()` in your `slotchange` listener.
Stop a `slotchange` from bubbling, and other custom elements will not be notified of indirect `slotchange`s.
## Processing `slotchange` 2: `findDirectSlotNode`
When indirect `slotchange` events bubble from one chained `<SLOT>` node to the next,
the event itself does not change. This is as expected, but
it can cause confusion in the indirect `slotchange` event listener.
In an indirect `slotchange` event, the `slotchange.target` and the `slotchange.composedPath()[0]`
is *not* a directSlotNode of the custom element.
Instead, the target `<SLOT>` node would be an indirectSlotNode from one or more lightDOMs above.
The directSlotNode that reside in your custom element's shadowDOM will be hidden in the event path
as something like `.composedPath()[1]` or `.composedPath()[2]` etc.
It makes *no difference* if you attach the event listener to the top `shadowRoot`
or to each individual `<SLOT>` node. Both event listeners will yield the same `slotchange` event
with the same `target` and `composedPath()`.
Often, when `<SLOT>`s are chained, no additional nodes are added along the way.
This means that often, calling `.assignedNodes()` on the `target` and the directSlotNode in
the path would yield the same result.
However, if additional nodes are added to the slottables of a custom elements along the way,
calling `.assignedNodes()` on the `target` and the directSlotNode in the path would *not*
yield the same result.
Whether or not the indirectSlotNode and the directSlotNode yield the same result
all depends on how the custom element is used. Which you as an author of the custom element
in principle do not control. This in turn means that tou can build a custom element
that works fine in 100 applications, only to suddenly break when applied to the next 100, when
people tries to use your custom element inside other custom elements.
Therefore, when you process a `slotchange` event, you should always make sure you have the "correct",
directSlotNode for your custom element.
To find the directSlotNode for in a `slotchange` event, you simply need to
check the nodes in the events `composedPath()` to find the first node which have the custom elements
`shadowRoot` as its `rootNode`:
```javascript
function findDirectSlotNode(e, shadowRoot){
const path = e.composedPath();
for(let i = 0; i < path.length -1; i++){
let node = path[i];
if (node.getRootNode() === shadowRoot)
return {directSlotNode: node, indirectness: i};
}
return null;
}
```
Another relevant finding from searching the events `composedPath()` like this is that the number of
links in the slot chain gets reflected by the position from where you find the directSlotNode.
If the event examined is a direct `slotchange` event, the position will be `0`.
Ie the `slotchange` event is triggered two lightDOMs above, the position would be `2`.
`findDirectSlotNode(e, shadowRoot)` therefore returns a result with containing both
the `directSlotNode` and the level of `indirectness` (where `0` === a direct `slotchange` event).
## Problem: SlotchangeEavesdropping
But, there is a snag with indirect `slotchange` events.
Your custom element `slotchange` listeners *actually can* intercept `slotchange` events
that are completely irrelevant for them.
To illustrate this problem, we will set up an example.
In this example we set up a group of custom elements that chain their `<SLOT>` nodes.
The resulting custom element `<family-photo>` will
frame an image in a wooden (brown) frame with a bronze (yellow) label.
The custom elements all use a `naiveSlotchangeCallback` to log their `slotchange` events.
```html
<family-photo>
<img src="https://images.pexels.com/photos/1146603/pexels-photo-1146603.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=125" alt="grandpa">
<span slot="label">My internet family</span>
</family-photo>
<script type="module">
function findDirectSlotNode(e, shadowRoot){
const path = e.composedPath();
for(let i = 0; i < path.length -1; i++){
let node = path[i];
if (node.getRootNode() === shadowRoot)
return {directSlotNode: node, indirectness: i};
}
return null;
}
function naiveSlotchangeCallback(el){
el.shadowRoot.addEventListener("slotchange", e => { //[*]
const slot = findDirectSlotNode(e, el.shadowRoot);
slot && el.slotchangeCallback(slot.directSlotNode, slot.indirectness, e);
});
}
class FamilyPhoto extends HTMLElement {
constructor() {
super();
this.attachShadow({
mode: "open"
});
this.shadowRoot.innerHTML = `
<wooden-frame style="display: inline-block;">
<slot></slot>
<bronze-label slot="label">
<slot name="label"></slot>
</bronze-label>
</wooden-frame>
`;
naiveSlotchangeCallback(this)
}
slotchangeCallback(slot, indirectness, event){
console.log("FamilyPhoto", indirectness, event.composedPath());
}
}
class WoodenFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({
mode: "open"
});
this.shadowRoot.innerHTML = `
<div style="position: relative; border: 40px solid brown;">
<slot></slot>
<div style="position: absolute; bottom: -35px; left: 30%;">
<slot name="label"></slot>
</div>
</div>
`;
naiveSlotchangeCallback(this)
}
slotchangeCallback(slot, indirectness, event){
console.log("WoodenFrame", indirectness, event.composedPath());
}
}
class BronzeLabel extends HTMLElement {
constructor() {
super();
this.attachShadow({
mode: "open"
});
this.shadowRoot.innerHTML = `
<style>
:host {display: inline-block; background: yellow;}
</style>
<slot></slot>
`;
naiveSlotchangeCallback(this)
}
slotchangeCallback(slot, indirectness, event){
console.log("BronzeLabel", indirectness, event.composedPath());
}
}
customElements.define("bronze-label", BronzeLabel);
customElements.define("wooden-frame", WoodenFrame);
customElements.define("family-photo", FamilyPhoto);
const familyPhoto = document.querySelector("family-photo");
setTimeout(()=>{
console.log("-----------------------------------")
const addedImg = document.createElement("img")
addedImg.src = "https://images.pexels.com/photos/12971/pexels-photo-12971.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=125";
familyPhoto.appendChild(addedImg);
}, 1000);
setTimeout(()=>{
console.log("-----------------------------------")
const addedLabel = document.createElement("div");
addedLabel.setAttribute("slot", "label");
addedLabel.innerText = " by orstavik";
familyPhoto.appendChild(addedLabel);
}, 2000);
</script>
```
This example illustrates the number of different direct and indirect `slotchange` events
that gets triggered as custom elements and `<SLOT>`s are used inside each other and chained.
We will return to the initial events in this example in the next chapter, but
here we will focus on the events that are triggered when new elements are added to the `familyPhoto`
in the two `setTimeout`s.
From the first `setTimeout`, an new `<img>` is added under the `<family-photo>`.
As it has no `slot` attribute, it is forwarded into `<wooden-frame>`.
This triggers the `slotchange` listeners in both:
1. `<wooden-frame>` that an indirect (1) `slotchange` event has occured, and
2. `<family-photo>` that an direct (0) `slotchange` event has occured.
This matches what the console prints:
```
WoodenFrame 1 (6) [slot, slot, div, document-fragment, wooden-frame, document-fragment] <slot>…</slot>
FamilyPhoto 0 (6) [slot, slot, div, document-fragment, wooden-frame, document-fragment] <slot>…</slot>
```
In the next `setTimeout`, a new `<div slot="label">` is added under the `<family-photo>`.
This `<div>` is forwarded into `<bronze-label slot="label">` element.
However, `<bronze-label slot="label">` is itself slotted into the `<wooden-frame>`.
Because of this nesting, `<wooden-frame>` also gets alerted about the change.
And the console prints:
```
BronzeLabel 1 (10) [slot, slot, document-fragment, bronze-label, slot, div, div, document-fragment, wooden-frame, document-fragment] <slot>…</slot>
WoodenFrame 4 (10) [slot, slot, document-fragment, bronze-label, slot, div, div, document-fragment, wooden-frame, document-fragment] <slot name="label">…</slot>
FamilyPhoto 0 (10) [slot, slot, document-fragment, bronze-label, slot, div, div, document-fragment, wooden-frame, document-fragment] <slot name="label">…</slot>
```
The problem we need to address is the `WoodenFrame 4 ...` log.
Due to the makeup of the event path for `slotchange` here, the `<wooden-frame>` shadowRoot
also intercepts the slotchange event of one of its grandchildren, a non-child descendants.
This is not desired. And here is why.
When HTML are composed, it would technically be possible to alert all elements about changes not only
to their immediate children, but also their grandchildren, great-grandchildren, great-great-grandchildren
etc. etc. But. This would not scale well. First of all, to implement such a structure, the number of
elements that would need to be observed, *and* the number of `slotchange` events that would be
listened to would rise exponentially. Second, the complexity for the poor developer trying to keep
control of the elements that would be affected by his or her changes, would an exponentially larger number
of elements and branches to contend to. Therefore, changes to grandchildren and lower descendants are
not considered a `slotchange`; *only* changes to immediate children of the `host` node are.
We should therefore *not* listen for the indirect `slotchange` event in `<wooden-frame>` neither.
It is a granchild `slotchange` event. It is a private matter between the `<slot name="label"></slot>`
and its direct parent `<bronze-label slot="label">`.
**`slotchange` only concerns a custom element and its direct children**,
other elements intercepting such communication are *SlotchangeEavesdropping*.
## Processing `slotchange` 3: `findDirectSlotNode` without SlotchangeEavesdropping
We do not want to eavesdrop. Nobody does. It is impolite.
And we especially do not want others to eavesdrop on us.
So, we want to clear away indirect event listeners for grandchildren.
Thankfully, the method of doing so is super simple.
If there is another element that is *not* a `<SLOT>` in the `composedPath()`
before a directSlotNode is found, that slotchange is *not for us*.
This leads to a universal, simple algorithm for processing **all** slotchange events that
will return the directSlotNode, the level of indirectness and filter out SlotchangeEavesdropping.
```javascript
function findDirectSlotNode(e, shadowRoot){
const path = e.composedPath();
for(let i = 0; i < path.length -1; i++){
let node = path[i];
if (node.tagName !== "SLOT")
return null;
if (node.getRootNode() === shadowRoot)
return {directSlotNode: node, indirectness: i};
}
return null;
}
```
## A naive `slotchangeCallback`
There is dry gunpowder in the above paragraphs. Let's look at the fireworks. Since:
1. all `slotchange` events of interest, ie. both direct and indirect, can be captured with a single
`this.shadowRoot.addEventListener("slotchange", ...)`,
2. all `slotchange` event listeners must ensure that they work on the directSlotNode, and not the target, and
3. indirect SlotchangeEavesdropping events should be avoided in all instances for the same reason that
direct `slotchange` events avoids them, then
**BANG!** it *is* both *possible* and *desireable* to create
**a single, unified `slotchange` listener in all custom elements** (that uses process `slotchange` events).
```javascript
function findYourOwnSlot(e, shadowRoot){
const path = e.composedPath();
for(let i = 0; i < path.length -1; i++){
let node = path[i];
if (node.tagName !== "SLOT") //[no eavesdropping]
return null; //[no eavesdropping]
if (node.getRootNode() === shadowRoot)
return [node, i];
}
return null;
}
export function naiveSlotchangeCallback(el){
el.shadowRoot.addEventListener("slotchange", e => { //[*]
const slot = findYourOwnSlot(e, el.shadowRoot);
slot && el.slotchangeCallback(slot.directSlotNode, slot.indirectness, e);
});
}
```
* The only drawback/dependency of this function is that the custom element
that it is applied to, must have an open `.shadowRoot`.
## Example: GrandpaInAFrame without SlotchangeEavesdropping
We conclude this chapter with our previous example, corrected not to eavesdrop on indirect grandchild
`slotchange` events. In the next chapter we will look at the multitude of initial `slotchange` events
that you see in the example, why they arise and how to best resolve the issue:
[Problem: DeclarativeResolution](Problem_DeclarativeResolution.md).
```html
<family-photo>
<img src="https://images.pexels.com/photos/1146603/pexels-photo-1146603.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=125" alt="grandpa">
<span slot="label">My internet family</span>
</family-photo>
<script type="module">
function findDirectSlotNode(e, shadowRoot){
const path = e.composedPath();
for(let i = 0; i < path.length -1; i++){
let node = path[i];
if (node.tagName !== "SLOT") //[no eavesdropping]
return null; //[no eavesdropping]
if (node.getRootNode() === shadowRoot)
return {directSlotNode: node, indirectness: i};
}
return null;
}
function naiveSlotchangeCallback(el){
el.shadowRoot.addEventListener("slotchange", e => { //[*]
const slot = findDirectSlotNode(e, el.shadowRoot);
slot && el.slotchangeCallback(slot.directSlotNode, slot.indirectness, e);
});
}
class FamilyPhoto extends HTMLElement {
constructor() {
super();
this.attachShadow({
mode: "open"
});
this.shadowRoot.innerHTML = `
<wooden-frame style="display: inline-block;">
<slot></slot>
<bronze-label slot="label">
<slot name="label"></slot>
</bronze-label>
</wooden-frame>
`;
naiveSlotchangeCallback(this)
}
slotchangeCallback(slot, indirectness, event){
console.log("FamilyPhoto", indirectness, event.composedPath());
}
}
class WoodenFrame extends HTMLElement {
constructor() {
super();
this.attachShadow({
mode: "open"
});
this.shadowRoot.innerHTML = `
<div style="position: relative; border: 40px solid brown;">
<slot></slot>
<div style="position: absolute; bottom: -35px; left: 30%;">
<slot name="label"></slot>
</div>
</div>
`;
naiveSlotchangeCallback(this)
}
slotchangeCallback(slot, indirectness, event){
console.log("WoodenFrame", indirectness, event.composedPath());
}
}
class BronzeLabel extends HTMLElement {
constructor() {
super();
this.attachShadow({
mode: "open"
});
this.shadowRoot.innerHTML = `
<style>
:host {display: inline-block; background: yellow;}
</style>
<slot></slot>
`;
naiveSlotchangeCallback(this)
}
slotchangeCallback(slot, indirectness, event){
console.log("BronzeLabel", indirectness, event.composedPath());
}
}
customElements.define("bronze-label", BronzeLabel);
customElements.define("wooden-frame", WoodenFrame);
customElements.define("family-photo", FamilyPhoto);
const familyPhoto = document.querySelector("family-photo");
setTimeout(()=>{
console.log("-----------------------------------")
const addedImg = document.createElement("img")
addedImg.src = "https://images.pexels.com/photos/12971/pexels-photo-12971.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=125";
familyPhoto.appendChild(addedImg);
}, 1000);
setTimeout(()=>{
console.log("-----------------------------------")
const addedLabel = document.createElement("div");
addedLabel.setAttribute("slot", "label");
addedLabel.innerText = " by orstavik";
familyPhoto.appendChild(addedLabel);
}, 2000);
</script>
```
## References
## other drafffts
## Old drafts
The argument for triggering `slotchange` only for transposed nodes is that it would avoid calling
unnecessary `slotchange` reactions for a known state. The problem with this argument is that it
does not take into account that other unknown contextual factors can drive and trigger the need
to process the fallback nodes in ways that cannot be described statically in HTML and CSS.
This means that quite frequently, `<slot>` fallback nodes needs to be processed in the constructor,
thus causing the redundancy problem to be shifted to the listener of slotchange callbacks.
Even at creation time, the
context of the web component is not known. It might be filled with transposed nodes to begin with,
it might not. If processing the childNodes and transposed nodes is heavy, it would be better to
, and thus does not need to be processed (which is not correct
in that the context of the web component is not known, and this also factors into the creation processing)
and b) that the initial state of the web component is its fallback nodes, and not empty (which is
not helpful in real life web components).
But this argument and the current premises are not well suited for web component reaction.
1. Web components need to react to changes in the list of displayed content of a `<slot>` element
in the flattened DOM. This use case is *more frequent* than reacting to changes in the list of
transposed nodes *only*. The reason for this is that *if* a web component needs to do an operation
based on the state of transposed nodes, these operations might also do some work *on* the transposed
nodes. Imagine for example an attribute value on the host element, such as a symbol type on a list,
that needs to be applied and communicated to the childnodes in view. Such transference of parent
state to its flattened DOM children can be triggered by `attributeChangedCallback(..)`. But, what if
the state of the parent web component list was not established by an attribute with its own callback?
In such cases you would like to perform the operation of updating the list symbol of the child
elements, whether they are transposed or fallback nodes.
2. To *opt out of and abort* a `slotchange` reaction that include fallback nodes is simpler than
it is to *add* a trigger to the initial state based on fallback nodes, without doing redundant work.
Put simply:
* if the fallback nodes is the default, initial state of the web component, then the developer should
process this state in the constructor. This might be a little work.
* Then if the web component is created directly with transposed nodes, then it is likely that this
initial setup against fallback nodes was a complete waste.
* This leads the developer to try to delay the initial setup against fallback nodes until it
has been verified that no transposed nodes are added during creation. As we will describe later,
this is doable, but not trivial.
Conclusion: The slotchange event is most relevant when it signals: "a change in
the list of child nodes in the flattened DOM" and when the default state of this list is empty, and not the fallback
nodes. This only requires triggering slotchange reactions when a slot uses its list of fallback nodes as its initial
result, and this makes it far simpler for the developer of a web component to write ordered reactions based on the
current state of a slot.
Therefore, best practice is therefore to react to both fallback nodes and transposed nodes alike.
NoFallbackSlotchange is the fact that no `slotchange` event is dispatched when a `<slot>` element
uses fallback nodes as its *initial* list of child nodes in the flattened DOM.