Skip to content

Commit 1b56511

Browse files
authored
docs: add no-child-traversal-in-attributechangedcallback docs (#114)
1 parent d478f02 commit 1b56511

File tree

1 file changed

+95
-0
lines changed

1 file changed

+95
-0
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Disallows traversal of children in the `AttributeChangedCallback` method (no-child-traversal-in-attributechangedcallback)
2+
3+
The intent of the `attributeChangedCallback` is to initialise state based on attribute values, and to observe and alter state when those values change. Traversing the DOM during the `attributeChangedCallback` phase is error-prone, because:
4+
5+
- it can fire before `connectedCallback`, meaning any initialisation inside of `connectedCallback` has not yet occurred.
6+
- it can fire before `this.isConnected` is `true`, meaning the node has been created but is not yet appended to the DOM. For example `this.ownerDocument` or `this.parent` will be null, and `this.querySelector()` will return `null` for any value.
7+
8+
To give a concrete example of this, there is a common pattern when constructing DOM in JS to create the element, set its attributes and append to the DOM, in that order. In code this might look like:
9+
10+
```js
11+
const el = document.createElement('foo-bar')
12+
el.setAttribute('baz', 'bing')
13+
// attributeChangedCallback('baz', null, 'bing') is fired!
14+
document.body.append(el)
15+
// connectedCallback() is fired!
16+
```
17+
18+
This is also true for document parsing in general: attributes that are in the HTML during parse time will call `attributeChangedCallback` before `connectedCallback`. This could be represented in code like:
19+
20+
```js
21+
document.body.innerHTML = '<foo-bar baz="bing"></foo-bar>'
22+
// attributeChangedCallback('baz', null, 'bing') is fired!
23+
// connectedCallback() is fired!
24+
```
25+
26+
Guarding against `null` properties, or returning early for `isConnected === false` is not good enough because there is high risk that attribute changes won't be properly propagated and state can fall out of sync. Guarding against these means adding duplicate code in other lifecycle callbacks such as `connectedCallback` to ensure this state does not fall out of sync. It is instead preferable to move such DOM traversals away from `attributeChangedCallback`, using one of the following:
27+
28+
- dispatch events from `attributeChangedCallback`, binding event listeners on the element itself within `connectedCallback`
29+
- defer DOM traversals to just-in-time lookup using methods or getters.
30+
31+
All of these patterns still mean state initialisation should be done in the `connectedCallback`. Do not rely on `attributeChangedCallback` for state initialisation.
32+
33+
## Rule Details
34+
35+
This rule disallows using DOM traversal APIs within the `attributeChangedCallback`.
36+
37+
👎 Examples of **incorrect** code for this rule:
38+
39+
```js
40+
class FooBarElement extends HTMLElement {
41+
attributeChangedCallback(name, _, value) {
42+
if (name === 'aria-owns') {
43+
// This has not been guarded against `this.isConnected` and so
44+
// `ownerDocument` is null.
45+
this.mine = this.ownerDocument.getElementById(value)
46+
}
47+
}
48+
}
49+
```
50+
51+
```js
52+
class FooBarElement extends HTMLElement {
53+
attributeChangedCallback(name, _, value) {
54+
if (name === 'data-text') {
55+
// This has not been guarded against `this.isConnected` and so
56+
// `ownerDocument` is null.
57+
this.querySelector('span').textContent = value
58+
}
59+
}
60+
}
61+
```
62+
63+
👍 Examples of **correct** code for this rule:
64+
65+
```js
66+
class FooBarElement extends HTMLElement {
67+
get mine() {
68+
return this.ownerDocument.getElementById(this.getAttribute('aria-owns'))
69+
}
70+
}
71+
```
72+
73+
```js
74+
class FooBarElement extends HTMLElement {
75+
attributeChangedCallback(name, _, value) {
76+
if (this.isConnected && name === 'data-text') {
77+
// Guarding with `isConnected` can be used here, but we also
78+
// need to synchronise this state in the `connectedCallback` as well.
79+
this.update()
80+
}
81+
}
82+
update() {
83+
this.querySelector('span').textContent = this.getAttribute('data-text')
84+
}
85+
connectedCallback() {
86+
// This needs to happen because `attributeChangedCallback` doesn't
87+
// _always_ update.
88+
this.update()
89+
}
90+
}
91+
```
92+
93+
## When Not To Use It
94+
95+
If you are comfortable with the edge cases of DOM traversal directly in the `attributeChangedCallback` then you can disable this rule.

0 commit comments

Comments
 (0)