Skip to content

Commit 82acc5b

Browse files
authored
Merge pull request #855 from bmish/no-assignment-of-computed-property-dependencies
Add new rule `no-assignment-of-untracked-properties-used-in-tracking-contexts`
2 parents cd6c930 + af3e782 commit 82acc5b

7 files changed

+792
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Rules are grouped by category to help you understand their purpose. Each rule ha
7676
|:---|:--------|:------------|
7777
| | [computed-property-getters](./docs/rules/computed-property-getters.md) | enforce the consistent use of getters in computed properties |
7878
| :white_check_mark: | [no-arrow-function-computed-properties](./docs/rules/no-arrow-function-computed-properties.md) | disallow arrow functions in computed properties |
79+
| :wrench: | [no-assignment-of-untracked-properties-used-in-tracking-contexts](./docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md) | disallow assignment of untracked properties that are used as computed property dependencies |
7980
| :car: | [no-computed-properties-in-native-classes](./docs/rules/no-computed-properties-in-native-classes.md) | disallow using computed properties in native classes |
8081
| :white_check_mark: | [no-deeply-nested-dependent-keys-with-each](./docs/rules/no-deeply-nested-dependent-keys-with-each.md) | disallow usage of deeply-nested computed property dependent keys with `@each` |
8182
| :white_check_mark::wrench: | [no-duplicate-dependent-keys](./docs/rules/no-duplicate-dependent-keys.md) | disallow repeating computed property dependent keys |
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# no-assignment-of-untracked-properties-used-in-tracking-contexts
2+
3+
:wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
4+
5+
Ember 3.13 added an assertion that fires when using assignment `this.x = 123` on an untracked property that is used in a tracking context such as a computed property.
6+
7+
> You attempted to update "propertyX" to "valueY",
8+
but it is being tracked by a tracking context, such as a template, computed property, or observer.
9+
>
10+
> In order to make sure the context updates properly, you must invalidate the property when updating it.
11+
>
12+
> You can mark the property as `@tracked`, or use `@ember/object#set` to do this.
13+
14+
## Rule Details
15+
16+
This rule catches assignments of untracked properties that are used as computed property dependency keys.
17+
18+
## Examples
19+
20+
Examples of **incorrect** code for this rule:
21+
22+
```js
23+
import { computed } from '@ember/object';
24+
import Component from '@ember/component';
25+
26+
class MyComponent extends Component {
27+
@computed('x') get myProp() {
28+
return this.x;
29+
}
30+
myFunction() {
31+
this.x = 123; // Not okay to use assignment here.
32+
}
33+
}
34+
```
35+
36+
Examples of **correct** code for this rule:
37+
38+
```js
39+
import { computed, set } from '@ember/object';
40+
import Component from '@ember/component';
41+
42+
class MyComponent extends Component {
43+
@computed('x') get myProp() {
44+
return this.x;
45+
}
46+
myFunction() {
47+
set(this, 'x', 123); // Okay because it uses set.
48+
}
49+
}
50+
```
51+
52+
```js
53+
import { computed, set } from '@ember/object';
54+
import Component from '@ember/component';
55+
import { tracked } from '@glimmer/tracking';
56+
57+
class MyComponent extends Component {
58+
@tracked x;
59+
@computed('x') get myProp() {
60+
return this.x;
61+
}
62+
myFunction() {
63+
this.x = 123; // Okay because `x` is a tracked property.
64+
}
65+
}
66+
```
67+
68+
## Migration
69+
70+
The autofixer for this rule will update assignments to use `set`. Alternatively, you can begin using tracked properties.
71+
72+
## References
73+
74+
* [Spec](https://api.emberjs.com/ember/release/functions/@ember%2Fobject/set) for `set()`
75+
* [Spec](https://api.emberjs.com/ember/3.16/functions/@glimmer%2Ftracking/tracked) for `@tracked`
76+
* [Guide](https://guides.emberjs.com/release/upgrading/current-edition/tracked-properties/) for tracked properties

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
'new-module-imports': require('./rules/new-module-imports'),
1515
'no-actions-hash': require('./rules/no-actions-hash'),
1616
'no-arrow-function-computed-properties': require('./rules/no-arrow-function-computed-properties'),
17+
'no-assignment-of-untracked-properties-used-in-tracking-contexts': require('./rules/no-assignment-of-untracked-properties-used-in-tracking-contexts'),
1718
'no-attrs-in-components': require('./rules/no-attrs-in-components'),
1819
'no-attrs-snapshot': require('./rules/no-attrs-snapshot'),
1920
'no-capital-letters-in-routes': require('./rules/no-capital-letters-in-routes'),
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
'use strict';
2+
3+
const emberUtils = require('../utils/ember');
4+
const types = require('../utils/types');
5+
const decoratorUtils = require('../utils/decorators');
6+
const javascriptUtils = require('../utils/javascript');
7+
const propertySetterUtils = require('../utils/property-setter');
8+
const assert = require('assert');
9+
const { getImportIdentifier } = require('../utils/import');
10+
const {
11+
expandKeys,
12+
keyExistsAsPrefixInList,
13+
} = require('../utils/computed-property-dependent-keys');
14+
15+
const ERROR_MESSAGE =
16+
"Use `set(this, 'propertyName', 'value')` instead of assignment for untracked properties that are used as computed property dependencies (or convert to using tracked properties).";
17+
18+
/**
19+
* Gets the list of string dependent keys from a computed property.
20+
*
21+
* @param {Node} node - the computed property node
22+
* @returns {String[]} - the list of string dependent keys from this computed property
23+
*/
24+
function getComputedPropertyDependentKeys(node) {
25+
if (!node.arguments) {
26+
return [];
27+
}
28+
29+
return expandKeys(
30+
node.arguments
31+
.filter((arg) => arg.type === 'Literal' && typeof arg.value === 'string')
32+
.map((node) => node.value)
33+
);
34+
}
35+
36+
/**
37+
* Gets a list of computed property dependency keys used inside a class.
38+
*
39+
* @param {Node} nodeClass - Node for the class
40+
* @returns {String[]} - list of dependent keys used inside the class
41+
*/
42+
function findComputedPropertyDependentKeys(nodeClass, computedImportName) {
43+
if (types.isClassDeclaration(nodeClass)) {
44+
// Native JS class.
45+
return javascriptUtils.flatMap(nodeClass.body.body, (node) => {
46+
const computedDecorator = decoratorUtils.findDecorator(node, computedImportName);
47+
if (computedDecorator) {
48+
return getComputedPropertyDependentKeys(computedDecorator.expression);
49+
} else {
50+
return [];
51+
}
52+
});
53+
} else if (types.isCallExpression(nodeClass)) {
54+
// Classic class.
55+
return javascriptUtils.flatMap(
56+
nodeClass.arguments.filter(types.isObjectExpression),
57+
(classObject) => {
58+
return javascriptUtils.flatMap(classObject.properties, (node) => {
59+
if (
60+
types.isProperty(node) &&
61+
emberUtils.isComputedProp(node.value) &&
62+
node.value.arguments
63+
) {
64+
return getComputedPropertyDependentKeys(node.value);
65+
} else {
66+
return [];
67+
}
68+
});
69+
}
70+
);
71+
} else {
72+
assert(false, 'Unexpected node type for a class.');
73+
}
74+
75+
return [];
76+
}
77+
78+
/**
79+
* Gets a list of tracked properties used inside a class.
80+
*
81+
* @param {Node} nodeClass - Node for the class
82+
* @returns {String[]} - list of tracked properties used inside the class
83+
*/
84+
function findTrackedProperties(nodeClassDeclaration, trackedImportName) {
85+
return nodeClassDeclaration.body.body
86+
.filter(
87+
(node) =>
88+
types.isClassProperty(node) &&
89+
decoratorUtils.hasDecorator(node, trackedImportName) &&
90+
types.isIdentifier(node.key)
91+
)
92+
.map((node) => node.key.name);
93+
}
94+
95+
class Stack {
96+
constructor() {
97+
this.stack = new Array();
98+
}
99+
pop() {
100+
return this.stack.pop();
101+
}
102+
push(item) {
103+
this.stack.push(item);
104+
}
105+
peek() {
106+
return this.stack.length > 0 ? this.stack[this.stack.length - 1] : undefined;
107+
}
108+
size() {
109+
return this.stack.length;
110+
}
111+
}
112+
113+
module.exports = {
114+
meta: {
115+
type: 'problem',
116+
docs: {
117+
description:
118+
'disallow assignment of untracked properties that are used as computed property dependencies',
119+
category: 'Computed Properties',
120+
recommended: false,
121+
url:
122+
'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-assignment-of-untracked-properties-used-in-tracking-contexts.md',
123+
},
124+
fixable: 'code',
125+
schema: [],
126+
},
127+
128+
ERROR_MESSAGE,
129+
130+
create(context) {
131+
if (emberUtils.isTestFile(context.getFilename())) {
132+
// This rule does not apply to test files.
133+
return {};
134+
}
135+
136+
// State being tracked for this file.
137+
let computedImportName = undefined;
138+
let trackedImportName = undefined;
139+
let setImportName = undefined;
140+
141+
// State being tracked for the current class we're inside.
142+
const classStack = new Stack();
143+
144+
return {
145+
ImportDeclaration(node) {
146+
if (node.source.value === '@ember/object') {
147+
computedImportName =
148+
computedImportName || getImportIdentifier(node, '@ember/object', 'computed');
149+
setImportName = setImportName || getImportIdentifier(node, '@ember/object', 'set');
150+
} else if (node.source.value === '@glimmer/tracking') {
151+
trackedImportName =
152+
trackedImportName || getImportIdentifier(node, '@glimmer/tracking', 'tracked');
153+
}
154+
},
155+
156+
// Native JS class:
157+
ClassDeclaration(node) {
158+
// Gather computed property dependent keys from this class.
159+
const computedPropertyDependentKeys = new Set(
160+
findComputedPropertyDependentKeys(node, computedImportName)
161+
);
162+
163+
// Gather tracked properties from this class.
164+
const trackedProperties = new Set(findTrackedProperties(node, trackedImportName));
165+
166+
// Keep track of whether we're inside a Glimmer component.
167+
const isGlimmerComponent = emberUtils.isGlimmerComponent(context, node);
168+
169+
classStack.push({
170+
node,
171+
computedPropertyDependentKeys,
172+
trackedProperties,
173+
isGlimmerComponent,
174+
});
175+
},
176+
177+
CallExpression(node) {
178+
// Classic class:
179+
if (emberUtils.isAnyEmberCoreModule(context, node)) {
180+
// Gather computed property dependent keys from this class.
181+
const computedPropertyDependentKeys = new Set(
182+
findComputedPropertyDependentKeys(node, computedImportName)
183+
);
184+
185+
// No tracked properties in classic classes.
186+
const trackedProperties = new Set();
187+
188+
// Keep track of whether we're inside a Glimmer component.
189+
const isGlimmerComponent = emberUtils.isGlimmerComponent(context, node);
190+
191+
classStack.push({
192+
node,
193+
computedPropertyDependentKeys,
194+
trackedProperties,
195+
isGlimmerComponent,
196+
});
197+
}
198+
},
199+
200+
'ClassDeclaration:exit'(node) {
201+
if (classStack.size() > 0 && classStack.peek().node === node) {
202+
// Leaving current (native) class.
203+
classStack.pop();
204+
}
205+
},
206+
207+
'CallExpression:exit'(node) {
208+
if (classStack.size() > 0 && classStack.peek().node === node) {
209+
// Leaving current (classic) class.
210+
classStack.pop();
211+
}
212+
},
213+
214+
AssignmentExpression(node) {
215+
if (classStack.size() === 0) {
216+
// Not inside a class.
217+
return;
218+
}
219+
220+
// Ensure this is an assignment with `this.x = ` or `this.x.y = `.
221+
if (!propertySetterUtils.isThisSet(node)) {
222+
return;
223+
}
224+
225+
const currentClass = classStack.peek();
226+
227+
const sourceCode = context.getSourceCode();
228+
const nodeTextLeft = sourceCode.getText(node.left);
229+
const nodeTextRight = sourceCode.getText(node.right);
230+
const propertyName = nodeTextLeft.replace('this.', '');
231+
232+
if (currentClass.isGlimmerComponent && propertyName.startsWith('args.')) {
233+
// The Glimmer component args hash is automatically tracked so ignored it.
234+
return;
235+
}
236+
237+
if (
238+
!currentClass.computedPropertyDependentKeys.has(propertyName) &&
239+
!keyExistsAsPrefixInList(
240+
[...currentClass.computedPropertyDependentKeys.keys()],
241+
propertyName
242+
)
243+
) {
244+
// Haven't seen this property as a computed property dependent key so ignore it.
245+
return;
246+
}
247+
248+
if (currentClass.trackedProperties.has(propertyName)) {
249+
// Assignment is fine with tracked properties so ignore it.
250+
return;
251+
}
252+
253+
context.report({
254+
node,
255+
message: ERROR_MESSAGE,
256+
fix(fixer) {
257+
if (setImportName) {
258+
// `set` is already imported.
259+
return fixer.replaceText(
260+
node,
261+
`${setImportName}(this, '${propertyName}', ${nodeTextRight})`
262+
);
263+
} else {
264+
// Need to add an import statement for `set`.
265+
const sourceCode = context.getSourceCode();
266+
return [
267+
fixer.insertTextBefore(sourceCode.ast, "import { set } from '@ember/object';\n"),
268+
fixer.replaceText(node, `set(this, '${propertyName}', ${nodeTextRight})`),
269+
];
270+
}
271+
},
272+
});
273+
},
274+
};
275+
},
276+
};

0 commit comments

Comments
 (0)