Skip to content

Commit c360c0e

Browse files
authored
Merge pull request #110 from github/addEventListener-lints
Add event listener lints
2 parents d186731 + 2c5cda0 commit c360c0e

10 files changed

+328
-1
lines changed

docs/rules/no-useless-passive.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# No Useless Passive
2+
3+
This rule disallows setting `passive: true` for events on which it will have no effect.
4+
5+
Events where `passive: true` has an effect are: `touchstart`, `touchmove`, `wheel`, and `mousewheel`.
6+
7+
```js
8+
// bad (passive has no effect here)
9+
window.addEventListener('scroll', () => { /* ... */ }, { passive: true })
10+
11+
// good
12+
window.addEventListener('scroll', () => { /* ... */ })
13+
```
14+
15+
## Why?
16+
17+
Adding `passive: true` informs the browser that this event will not be calling `preventDefault` and as such is safe to asynchronously dispatch, freeing up the main thread for lag-free operation. However many events are not cancel-able and as such setting `passive: true` will have no effect on the browser.
18+
19+
It is safe to leave the option set, but this may have a negative effect on code authors, as they might believe setting `passive: true` has a positive effect on their operations, leading to a false-confidence in code with `passive: true`. As such, removing the option where it has no effect demonstrates to the code author that this code will need to avoid expensive operations as this might have a detrimental affect on UI performance.
20+
21+
## See Also
22+
23+
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners

docs/rules/prefer-observers.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Prever Observers
2+
3+
Some events, such as `scroll` and `resize` have traditionally caused performance issues on web pages, as they are high frequency events, firing many times per second as the user interacts with the page viewport.
4+
5+
## Scroll vs IntersectionObserver
6+
7+
Typically `scroll` events are used to determine if an element is intersecting a viewport, which can result in expensive operations such as layout calculations, which has a detrimental affect on UI performance. Recent developments in web standards have introduced the `IntersectionObserver`, which is a more performant mechanism for determining if an element is intersecting the viewport.
8+
9+
```js
10+
// bad, expensive, error-prone code to determine if the element is in the viewport;
11+
window.addEventListener('scroll', () => {
12+
const isIntersecting = checkIfElementIntersects(element.getBoundingClientRect(), window.innerHeight, document.clientHeight)
13+
element.classList.toggle('intersecting', isIntersecting)
14+
})
15+
16+
// good - more performant and less error-prone
17+
const observer = new IntersectionObserver(entries => {
18+
for(const {target, isIntersecting} of entries) {
19+
target.classList.toggle(target, isIntersecting)
20+
}
21+
})
22+
observer.observe(element)
23+
```
24+
25+
## Resize vs ResizeObserver
26+
27+
Similarly, `resize` events are typically used to determine if the viewport is smaller or larger than a certain size, similar to CSS media break points. Similar to the `IntersectionObserver` the `ResizeObserver` also exists as a more performant mechanism for observing changes to the viewport size.
28+
29+
```js
30+
// bad, low-performing code to determine if the element is less than 500px large
31+
window.addEventListener('resize', () => {
32+
element.classList.toggle('size-small', element.getBoundingClientRect().width < 500)
33+
})
34+
35+
// good - more performant, only fires when the actual elements change size
36+
const observer = new ResizeObserver(entries => {
37+
for(const {target, contentRect} of entries) {
38+
target.classList.toggle('size-small', contentRect.width < 500)
39+
}
40+
})
41+
observer.observe(element)
42+
```
43+
44+
## See Also
45+
46+
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
47+
https://developer.mozilla.org/en-US/docs/Web/API/Resize_Observer_API

docs/rules/require-passive-events.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Require Passive Events
2+
3+
This rule enforces adding `passive: true` to high frequency event listeners (`touchstart`, `touchmove`, `wheel`, `mousewheel`).
4+
5+
```js
6+
// bad
7+
window.addEventListener('touchstart', () => { /* ... */ })
8+
9+
// good
10+
window.addEventListener('touchstart', () => { /* ... */ }, { passive: true })
11+
```
12+
13+
## Why?
14+
15+
Adding these events listeners can block the main thread as it waits to find out if the callbacks call `preventDefault`. This can cause large amounts UI lag, which will be noticeable for users.
16+
17+
Adding `passive: true` informs the browser that this event will not be calling `preventDefault` and as such is safe to asynchronously dispatch, freeing up the main thread for lag-free operation.
18+
19+
## See Also
20+
21+
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners

lib/configs/browser.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ module.exports = {
1010
'github/no-blur': 'error',
1111
'github/no-dataset': 'error',
1212
'github/no-innerText': 'error',
13-
'github/unescaped-html-literal': 'error'
13+
'github/unescaped-html-literal': 'error',
14+
'github/no-useless-passive': 'error',
15+
'github/require-passive-events': 'error',
16+
'github/prefer-observers': 'error'
1417
}
1518
}

lib/rules/no-useless-passive.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const passiveEventListenerNames = new Set(['touchstart', 'touchmove', 'wheel', 'mousewheel'])
2+
3+
const propIsPassiveTrue = prop => prop.key && prop.key.name === 'passive' && prop.value && prop.value.value === true
4+
5+
module.exports = {
6+
meta: {
7+
docs: {},
8+
fixable: 'code'
9+
},
10+
11+
create(context) {
12+
return {
13+
['CallExpression[callee.property.name="addEventListener"]']: function(node) {
14+
const [name, listener, options] = node.arguments
15+
if (name.type !== 'Literal') return
16+
if (passiveEventListenerNames.has(name.value)) return
17+
if (options && options.type === 'ObjectExpression') {
18+
const i = options.properties.findIndex(propIsPassiveTrue)
19+
if (i === -1) return
20+
const passiveProp = options.properties[i]
21+
const l = options.properties.length
22+
const source = context.getSourceCode()
23+
context.report({
24+
node: passiveProp,
25+
message: `"${name.value}" event listener is not cancellable and so \`passive: true\` does nothing.`,
26+
fix(fixer) {
27+
const removals = []
28+
if (l === 1) {
29+
removals.push(options)
30+
removals.push(...source.getTokensBetween(listener, options))
31+
} else {
32+
removals.push(passiveProp)
33+
if (i > 0) {
34+
removals.push(...source.getTokensBetween(options.properties[i - 1], passiveProp))
35+
} else {
36+
removals.push(...source.getTokensBetween(passiveProp, options.properties[i + 1]))
37+
}
38+
}
39+
return removals.map(t => fixer.remove(t))
40+
}
41+
})
42+
}
43+
}
44+
}
45+
}
46+
}

lib/rules/prefer-observers.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const observerMap = {
2+
scroll: 'IntersectionObserver',
3+
resize: 'ResizeObserver'
4+
}
5+
module.exports = {
6+
meta: {
7+
docs: {},
8+
fixable: 'code'
9+
},
10+
11+
create(context) {
12+
return {
13+
['CallExpression[callee.property.name="addEventListener"]']: function(node) {
14+
const [name] = node.arguments
15+
if (name.type !== 'Literal') return
16+
if (!(name.value in observerMap)) return
17+
context.report({
18+
node,
19+
message: `Avoid using "${name.value}" event listener. Consider using ${observerMap[name.value]} instead`
20+
})
21+
}
22+
}
23+
}
24+
}

lib/rules/require-passive-events.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const passiveEventListenerNames = new Set(['touchstart', 'touchmove', 'wheel', 'mousewheel'])
2+
3+
const propIsPassiveTrue = prop => prop.key && prop.key.name === 'passive' && prop.value && prop.value.value === true
4+
5+
module.exports = {
6+
meta: {
7+
docs: {}
8+
},
9+
10+
create(context) {
11+
return {
12+
['CallExpression[callee.property.name="addEventListener"]']: function(node) {
13+
const [name, listener, options] = node.arguments
14+
if (!listener) return
15+
if (name.type !== 'Literal') return
16+
if (!passiveEventListenerNames.has(name.value)) return
17+
if (options && options.type === 'ObjectExpression' && options.properties.some(propIsPassiveTrue)) return
18+
context.report(node, `High Frequency Events like "${name.value}" should be \`passive: true\``)
19+
}
20+
}
21+
}
22+
}

tests/no-useless-passive.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const rule = require('../lib/rules/no-useless-passive')
2+
const RuleTester = require('eslint').RuleTester
3+
4+
const ruleTester = new RuleTester()
5+
6+
ruleTester.run('no-useless-passive', rule, {
7+
valid: [
8+
{
9+
code: 'document.addEventListener("scroll", function(event) {})'
10+
},
11+
{
12+
code: 'document.addEventListener("resize", function(event) {})'
13+
},
14+
{
15+
code: 'document.addEventListener("resize", function(event) {}, { passive: false })'
16+
}
17+
],
18+
invalid: [
19+
{
20+
code: 'document.addEventListener("scroll", function(event) {}, { passive: true })',
21+
output: 'document.addEventListener("scroll", function(event) {} )',
22+
errors: [
23+
{
24+
message: '"scroll" event listener is not cancellable and so `passive: true` does nothing.',
25+
type: 'Property'
26+
}
27+
]
28+
},
29+
{
30+
code: 'document.addEventListener("scroll", function(event) {}, { passive: true, foo: 1 })',
31+
output: 'document.addEventListener("scroll", function(event) {}, { foo: 1 })',
32+
errors: [
33+
{
34+
message: '"scroll" event listener is not cancellable and so `passive: true` does nothing.',
35+
type: 'Property'
36+
}
37+
]
38+
}
39+
]
40+
})

tests/prefer-observers.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const rule = require('../lib/rules/prefer-observers')
2+
const RuleTester = require('eslint').RuleTester
3+
4+
const ruleTester = new RuleTester()
5+
6+
ruleTester.run('prefer-observers', rule, {
7+
valid: [
8+
{
9+
code: 'document.addEventListener("touchstart", function(event) {})'
10+
}
11+
],
12+
invalid: [
13+
{
14+
code: 'document.addEventListener("scroll", function(event) {})',
15+
errors: [
16+
{
17+
message: 'Avoid using "scroll" event listener. Consider using IntersectionObserver instead',
18+
type: 'CallExpression'
19+
}
20+
]
21+
},
22+
{
23+
code: 'document.addEventListener("resize", function(event) {})',
24+
errors: [
25+
{
26+
message: 'Avoid using "resize" event listener. Consider using ResizeObserver instead',
27+
type: 'CallExpression'
28+
}
29+
]
30+
}
31+
]
32+
})

tests/require-passive-events.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const rule = require('../lib/rules/require-passive-events')
2+
const RuleTester = require('eslint').RuleTester
3+
4+
const ruleTester = new RuleTester()
5+
6+
ruleTester.run('require-passive-events', rule, {
7+
valid: [
8+
{
9+
code: 'document.addEventListener("load", function(event) {})'
10+
},
11+
{
12+
code: 'document.addEventListener("click", function(event) {})'
13+
},
14+
{
15+
code: 'document.addEventListener("touchstart", function(event) {}, { passive: true })'
16+
},
17+
{
18+
code: 'el.addEventListener("touchstart", function(event) {}, { passive: true })'
19+
}
20+
],
21+
invalid: [
22+
{
23+
code: 'document.addEventListener("touchstart", function(event) {})',
24+
errors: [
25+
{
26+
message: 'High Frequency Events like "touchstart" should be `passive: true`',
27+
type: 'CallExpression'
28+
}
29+
]
30+
},
31+
{
32+
code: 'el.addEventListener("wheel", function(event) {})',
33+
errors: [
34+
{
35+
message: 'High Frequency Events like "wheel" should be `passive: true`',
36+
type: 'CallExpression'
37+
}
38+
]
39+
},
40+
{
41+
code: 'document.addEventListener("wheel", function(event) {})',
42+
errors: [
43+
{
44+
message: 'High Frequency Events like "wheel" should be `passive: true`',
45+
type: 'CallExpression'
46+
}
47+
]
48+
},
49+
{
50+
// Intentionally mispelled!
51+
code: 'document.addEventListener("wheel", function(event) {}, { pssive: true })',
52+
errors: [
53+
{
54+
message: 'High Frequency Events like "wheel" should be `passive: true`',
55+
type: 'CallExpression'
56+
}
57+
]
58+
},
59+
{
60+
code: 'document.addEventListener("wheel", function(event) {}, { passive: false })',
61+
errors: [
62+
{
63+
message: 'High Frequency Events like "wheel" should be `passive: true`',
64+
type: 'CallExpression'
65+
}
66+
]
67+
}
68+
]
69+
})

0 commit comments

Comments
 (0)