Skip to content
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ The `--fix` option on the command line automatically fixes problems reported by
| :wrench: | [use-ember-get-and-set](./docs/rules/use-ember-get-and-set.md) | Enforces usage of Ember.get and Ember.set |


### Ember Object
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this section was supposed to move. yarn update should generate this file for you.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think after running yarn update only moved this block, i don't remember manually doing it. Anyway restored the position for now


| | Rule ID | Description |
|:---|:--------|:------------|
| :white_check_mark: | [avoid-leaking-callbacks-in-ember-objects](./docs/rules/avoid-leaking-callbacks-in-ember-objects.md) | Avoids callback memory leaks |
| :white_check_mark: | [avoid-leaking-state-in-ember-objects](./docs/rules/avoid-leaking-state-in-ember-objects.md) | Avoids state leakage |
| | [computed-property-getters](./docs/rules/computed-property-getters.md) | Enforce the consistent use of getters in computed properties |


### Possible Errors

| | Rule ID | Description |
Expand All @@ -123,14 +132,6 @@ The `--fix` option on the command line automatically fixes problems reported by
| :white_check_mark: | [routes-segments-snake-case](./docs/rules/routes-segments-snake-case.md) | Enforces usage of snake_cased dynamic segments in routes |


### Ember Object

| | Rule ID | Description |
|:---|:--------|:------------|
| :white_check_mark: | [avoid-leaking-state-in-ember-objects](./docs/rules/avoid-leaking-state-in-ember-objects.md) | Avoids state leakage |
| | [computed-property-getters](./docs/rules/computed-property-getters.md) | Enforce the consistent use of getters in computed properties |


### Testing

| | Rule ID | Description |
Expand Down
41 changes: 41 additions & 0 deletions docs/rules/avoid-leaking-callbacks-in-ember-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@

# Avoid callback leaks in Ember objects
## Rule `avoid-leaking-callbacks-in-ember-objects`

Callback leaks are memory leaks that occur due to state being caught in a callback function that is never released from memory.

Since callback functions for things like event listeners and interval timers are retained by reference elsewhere, you must be careful to unregister them when no longer needed or ensure that the context they're registered with is destroyed.


Examples of **incorrect** code for this rule:

```js
export default Ember.Component.extend({
didInsertElement() {
if (this.get('onScroll')) {
window.addEventListener('scroll', (...args) => this.get('onScroll')(...args));
}
}
});
```

Examples of **correct** code for this rule:

```js
export default Ember.Component.extend({
didInsertElement() {
if (this.get('onScroll')) {
this._onScrollHandler = (...args) => this.get('onScroll')(...args);
window.addEventListener('scroll', this._onScrollHandler);
}
},

willDestroy() {
window.removeEventListener('scroll', this._onScrollHandler);
}
});
```

## Further Reading

https://github.com/ember-best-practices/memory-leak-examples/blob/master/exercises/exercise-2.md
1 change: 1 addition & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ module.exports = {
'routes-segments-snake-case': require('./rules/routes-segments-snake-case'),
'use-brace-expansion': require('./rules/use-brace-expansion'),
'use-ember-get-and-set': require('./rules/use-ember-get-and-set'),
'avoid-leaking-callbacks-in-ember-objects': require('./rules/avoid-leaking-callbacks-in-ember-objects'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be alphabetical in the list.

Copy link
Copy Markdown
Contributor Author

@rajasegar rajasegar Jun 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to the top in alphabetical order

},
configs: {
base: require('./config/base'),
Expand Down
3 changes: 2 additions & 1 deletion lib/recommended-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
module.exports = {
"ember/alias-model-in-controller": "off",
"ember/avoid-leaking-callbacks-in-ember-objects": "error",
"ember/avoid-leaking-state-in-components": "off",
"ember/avoid-leaking-state-in-ember-objects": "error",
"ember/avoid-using-needs-in-controllers": "error",
Expand Down Expand Up @@ -50,4 +51,4 @@ module.exports = {
"ember/routes-segments-snake-case": "error",
"ember/use-brace-expansion": "error",
"ember/use-ember-get-and-set": "off"
}
}
67 changes: 67 additions & 0 deletions lib/rules/avoid-leaking-callbacks-in-ember-objects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use strict';

// const ember = require('../utils/ember');
const utils = require('../utils/utils');

const ERROR_MESSAGE = 'All `addEventListener` calls should have a corresponding `removeEventListener` to avoid leaking callbacks.';
//------------------------------------------------------------------------------
// Ember object rule - Avoid callback memory leaks
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this rule be limited to Ember objects only?

If it should, it would need a if (!(ember.isEmberObject(node) || ember.isReopenObject(node))) check somewhere, similar to the existing avoid-leaking-state-in-ember-objects rule.

If it's not necessary to limit it to Ember objects, and we can just run it on a per-file basis, then we would want to rename the rule to avoid mentioning in-ember-objects, and remove all the extra Ember object code from the test cases.

Copy link
Copy Markdown
Contributor Author

@rajasegar rajasegar Jun 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am afraid we can't directly use the if (!(ember.isEmberObject(node) || ember.isReopenObject(node))) checks here since we are actually targeting the addEventListener and removeEventListener call expressions we can't check them using the above utils.

And if we are not limiting this to Ember objects, is it OK to rename the rule to `avoid-leaking-callbacks'

Copy link
Copy Markdown
Member

@bmish bmish Jun 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this rule is specific to Ember?

If this rule is not specific to Ember, then it could just be a generic JavaScript eslint rule, and likely would not belong in eslint-plugin-ember. (I found a similar rule here)

If this rule is specific to Ember, then we need to update the code to actually limit it to something like Ember objects/components. In this case, you could write a helper function like isInsideEmberObject(node) which loops/recurses upwards in the tree checking each ancestor.

Interested to get the opinion of @Turbo87 @rwjblue on this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bmish I tend to agree. I don't see a reason to scope this to just Ember objects.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rajasegar I wonder if you could try contributing this rule to eslint itself as a general no-leaking-callbacks rule? They are pretty strict about accepting new rules but perhaps you could at least ask what they think about it since it seems like a general JavaScript rule.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure @bmish I will try

// Callback leaks are memory leaks that occur due to state being caught
// in a callback function that is never released from memory.
//------------------------------------------------------------------------------

module.exports = {
meta: {
docs: {
description: 'Avoids callback memory leaks',
category: 'Ember Object',
recommended: true,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rules should never be added as recommended since that would be a breaking change. We can consider making it recommended in the future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the recommended flag

url: 'https://github.com/ember-best-practices/memory-leak-examples/blob/master/exercises/exercise-2.md'
},
fixable: null, // or "code" or "whitespace"
ERROR_MESSAGE,
},


create(context) {
const report = function (node) {
context.report(node, ERROR_MESSAGE);
};

const addedListeners = [];
const removedListeners = [];
return {
'Program:exit': function () {
addedListeners.forEach((a) => {
const idx = removedListeners.findIndex(r => r.el === a.el && r.event === a.event);

if (idx < 0) {
// No removeEventListener
report(a.node);
}
});
},

CallExpression(node) {
if (utils.getPropertyValue(node, 'callee.property.name') === 'addEventListener') {
const listener = {
el: node.callee.object.name,
event: node.arguments[0].value,
node
};

addedListeners.push(listener);
}

if (utils.getPropertyValue(node, 'callee.property.name') === 'removeEventListener') {
const listener = {
el: node.callee.object.name,
event: node.arguments[0].value
};

removedListeners.push(listener);
}
}
};
},
};
1 change: 1 addition & 0 deletions tests/__snapshots__/recommended.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ Array [
"require-super-in-init",
"routes-segments-snake-case",
"use-brace-expansion",
"avoid-leaking-callbacks-in-ember-objects",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

]
`;
157 changes: 157 additions & 0 deletions tests/lib/rules/avoid-leaking-callbacks-in-ember-objects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------

const rule = require('../../../lib/rules/avoid-leaking-callbacks-in-ember-objects');
const RuleTester = require('eslint').RuleTester;

const { ERROR_MESSAGE } = rule;
// ------------------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------------------


const eslintTester = new RuleTester({
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module'
}
});
eslintTester.run('avoid-leaking-callbacks-in-ember-objects', rule, {
valid: [
{
code: `
export default Ember.Component.extend({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case seems to duplicate the other valid test case. Let's keep the Component.extend but not the older syntax Ember.Component.extend one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate case removed.

didInsertElement() {
if (this.get('onScroll')) {
this._onScrollHandler = (...args) => this.get('onScroll')(...args);
window.addEventListener('scroll', this._onScrollHandler);
}
},

willDestroyElement() {
window.removeEventListener("scroll", this._onScrollHandler);
this._super(...arguments);
}
});`,
},
{
code: `
import Component from '@ember/component';

export default Component.extend({
didInsertElement() {
if (this.get('onScroll')) {
this._onScrollHandler = (...args) => this.get('onScroll')(...args);
window.addEventListener('scroll', this._onScrollHandler);
}
},

willDestroyElement() {
window.removeEventListener("scroll", this._onScrollHandler);
this._super(...arguments);
}
});
`,
},
{
code: `
import Component from '@ember/component';

export default Component.extend({});`,
},
{
code: `
export default Ember.Service.extend({
init() {
if (this.get('onScroll')) {
this._onScrollHandler = (...args) => this.get('onScroll')(...args);
window.addEventListener('scroll', this._onScrollHandler);
}
},

willDestroy() {
window.removeEventListener("scroll", this._onScrollHandler);
this._super(...arguments);
}
});`,
},
],
invalid: [
{
code: `
import Component from '@ember/component';

export default Component.extend({
didInsertElement() {
if (this.get('onScroll')) {
window.addEventListener('scroll', (...args) => this.get('onScroll')(...args));
}
}
});`,
output: null,
errors: [{
message: ERROR_MESSAGE,
}],
},
{
code: `
import Component from '@ember/component';
export default Component.extend({
init() {
if (this.get('onScroll')) {
window.addEventListener('scroll', (...args) => this.get('onScroll')(...args));
}
},

didInsertElement() {
if (this.get("onResize")) {
window.addEventListener("resize", (...args) => this.get("onResize")(...args));
}
}

});
`,
output: null,
errors: [{
message: ERROR_MESSAGE,
},
{
message: ERROR_MESSAGE,
}],
},
{
code: `
import Component from '@ember/component';

export default Component.extend({
init() {
if (this.get('onScroll')) {
this._onScrollHandler = (...args) => this.get('onScroll')(...args);
window.addEventListener('scroll', this._onScrollHandler);
}
},


didInsertElement() {
if (this.get('onResize')) {
window.addEventListener('resize', (...args) => this.get('onResize')(...args));
}
},

willDestroyElement() {
window.removeEventListener("scroll", this._onScrollHandler);
this._super(...arguments);
}

});
`,
output: null,
errors: [{
message: ERROR_MESSAGE,
}],
},


],
});