Skip to content

Commit 703637c

Browse files
authored
Merge pull request #23 from chadhietala/moar-side-effect-apis
Detect more side-effect APIs when they are in an CP
2 parents 1d7829c + e84b0cd commit 703637c

File tree

4 files changed

+401
-0
lines changed

4 files changed

+401
-0
lines changed

guides/rules/no-side-effect-cp.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# No Computed Property Side-effects
2+
3+
In Ember we want data to flow down from the route and send actions up modifiy that data. This means that data flows from the top of the application tree structure and passes down to the child nodes. These children are not the owners of the data passed to them. Instead, they derive their own data based on the passed data. Those children can then choose to pass forward that derived data to its child and so on. If a child needs to mutate data passed to it, it must ask for it by firing any action. Once the owner makes the mutation, that change is propagated back down.
4+
5+
When we send actions in computed properties we are violating this pattern which makes systems much more difficult to reason about. Specifically with actions in computed propertites what we are semantically saying is:
6+
7+
1. When I first access this property send an action
8+
2. This action will not fire again unless the dependent key of this computed property changes.
9+
10+
The fact that property access could cause some side effect to occur is less than ideal as it forces you to write code that reacts to data changing over time instead of controlling the side effects based on user input into the system. Often when we see this pattern we see that one component is responsible displaying the data and another unrelated component is handles the action. This then forces a developer to look in mutliple places to understand how the component works. We can see this in the following example
11+
12+
```
13+
<input value={{firstName}} />
14+
{{if isValid 'VALID' 'INVALID'}}
15+
```
16+
17+
```
18+
Ember.Component.extend({
19+
init() {
20+
this._super(...arguments);
21+
this.firstName = null;
22+
},
23+
nameChanged: Ember.computed('firstName', function() {
24+
this.sendAction(this.get('validateAction'), this.get('firstName')); // Somebody else does the validation
25+
})
26+
})
27+
```
28+
29+
As you can see above, when we do side-effect programing we are forced to remove concerns of one component into another. More specically, `isValid` and the `validateAction` are passed to the component to call whenever the component changes it's `firstName` property. We can't look at just this component to see how validation is performed on our input. In contrast we can localize this logic and directly turn browser events e.g. oninput into semantic event `validateNameLength`. Here we are directly controlling the side-effects by taking an event in and directly handleing it, as opposed to rebroadcasting based on `firstName` changing.
30+
31+
```
32+
<input value={{firstName}} oninput={{action 'validateNameLength'}} />
33+
{{if isValid 'VALID' 'INVALID'}}
34+
```
35+
36+
```
37+
Ember.Component.extend({
38+
init() {
39+
this._super(...arguments);
40+
this.isValid = false;
41+
this.firstName = null;
42+
},
43+
actions: {
44+
validateNameLength() {
45+
if (this.firstName.length > 10) {
46+
this.set('isValid', true);
47+
} else {
48+
this.set('isValid', false);
49+
}
50+
}
51+
}
52+
})
53+
```
54+

lib/rules/no-side-effect-cp.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const { getCaller, cleanCaller } = require('../utils/caller');
2+
const isCPGetter = require('../utils/computed-property');
3+
const collectImportBindings = require('../utils/imports');
4+
5+
const SIDE_EFFECTS = ['this.send', 'this.sendAction', 'this.sendEvent', 'Em.sendEvent', 'Ember.sendEvent', 'this.trigger', 'this.set', 'Ember.set', 'Em.set', 'this.setProperties', 'Ember.setProperties', 'Em.setProperties'];
6+
7+
const MESSAGE = 'Do not send events or actions in Computed Properties. This will cause data flow issues in the application, where the accessing of a property causes some side-effect. You should only send actions on behalf of user initiated events. Please see the following guide for more infromation: https://github.com/chadhietala/ember-best-practices/blob/master/guides/rules/no-action-cp.md';
8+
9+
module.exports = {
10+
meta: {
11+
message: MESSAGE
12+
},
13+
create(context) {
14+
let inCPGettter = false;
15+
let bindings;
16+
17+
return {
18+
ImportDeclaration(node) {
19+
bindings = collectImportBindings(node, {
20+
'@ember/object': ['set', 'setProperties'],
21+
'@ember/object/events': ['sendEvent']
22+
});
23+
},
24+
25+
FunctionExpression(node) {
26+
if (isCPGetter(node)) {
27+
inCPGettter = true;
28+
}
29+
},
30+
'FunctionExpression:exit'(node) {
31+
if (isCPGetter(node)) {
32+
inCPGettter = false;
33+
}
34+
},
35+
36+
CallExpression(node) {
37+
if (inCPGettter) {
38+
let caller = cleanCaller(getCaller(node));
39+
let hasSideEffect = SIDE_EFFECTS.includes(caller) || bindings.includes(caller);
40+
if (hasSideEffect) {
41+
context.report(node, MESSAGE);
42+
}
43+
}
44+
}
45+
};
46+
}
47+
};

lib/utils/imports.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module.exports = function collectImportBindings(node, imports) {
2+
let sources = Object.keys(imports);
3+
let sourceName = sources[sources.indexOf(node.source.value)];
4+
let importedBindings = imports[sourceName];
5+
6+
if (sourceName) {
7+
return node.specifiers.filter((specifier) => {
8+
return importedBindings.includes(specifier.imported.name);
9+
}).map((specifier) => specifier.local.name);
10+
}
11+
12+
return [];
13+
};

0 commit comments

Comments
 (0)