Skip to content

Commit d3d7b4d

Browse files
committed
Propose new rule: no-unsafe-this-access
1 parent c9ed497 commit d3d7b4d

File tree

1 file changed

+160
-0
lines changed

1 file changed

+160
-0
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
'use strict';
2+
3+
const ERROR_MESSAGE =
4+
// eslint-disable-next-line eslint-plugin/prefer-placeholders
5+
'Unsafe `this` access after `await`. ' +
6+
'Guard against accessing data on destroyed objects with `@ember/destroyable` `isDestroyed` and `isDestroying`';
7+
8+
const types = require('../utils/types');
9+
const { getImportIdentifier } = require('../utils/import');
10+
11+
// Test here:
12+
// https://astexplorer.net/#/gist/e364803b7c576e08f232839bf3c17287/15913876e050a1ca02af71932e14b14242e36ead
13+
14+
/**
15+
* These objects have their own destroyable APIs on `this`
16+
*/
17+
const FRAMEWORK_EXTENDABLES = [
18+
{
19+
importPath: '@glimmer/component',
20+
},
21+
{
22+
importPath: '@ember/component',
23+
},
24+
{
25+
importPath: '@ember/component/helper',
26+
},
27+
{
28+
importPath: '@ember/routing/route',
29+
},
30+
{
31+
importPath: '@ember/controller',
32+
},
33+
];
34+
35+
// if already has protection, also early return
36+
// two forms:
37+
// - isDestroying(this) || isDestroyed(this) // on any destroyable object
38+
// - this.isDestroying || this.isDestroyed // available on most framework objects
39+
function isProtection(node) {
40+
const fns = new Set(['isDestroying', 'isDestroyed']);
41+
42+
switch (node.type) {
43+
case 'CallExpression': {
44+
return (
45+
fns.has(node.callee.name) &&
46+
node.arguments.length === 1 &&
47+
node.arguments[0].type === 'ThisExpression'
48+
);
49+
}
50+
case 'MemberExpression':
51+
return node.object.type === 'ThisExpression' && fns.has(node.property.name);
52+
default:
53+
console.log('unhandled protection check', node);
54+
}
55+
56+
return false;
57+
}
58+
59+
//------------------------------------------------------------------------------
60+
// Rule Definition
61+
//------------------------------------------------------------------------------
62+
/** @type {import('eslint').Rule.RuleModule} */
63+
module.exports = {
64+
meta: {
65+
type: 'suggestion',
66+
docs: {
67+
description: 'disallow `this` access after await unless destruction protection is present',
68+
category: 'Miscellaneous',
69+
recommended: true,
70+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-unsafe-this-access-in-async-function.md',
71+
},
72+
fixable: 'code',
73+
schema: [],
74+
},
75+
76+
create(context) {
77+
const inFunction = [];
78+
const inClass = [];
79+
let encounteredAwait;
80+
let lastProtection;
81+
82+
// https://eslint.org/docs/developer-guide/working-with-rules#contextgetsourcecode
83+
const source = context.getSourceCode();
84+
85+
return {
86+
ClassDeclaration(node) {
87+
inClass.push(node);
88+
},
89+
'ClassDeclaration:exit'(node) {
90+
inClass.pop();
91+
},
92+
FunctionExpression(node) {
93+
inFunction.push(node);
94+
encounteredAwait = null;
95+
},
96+
'FunctionExpression:exit'(node) {
97+
inFunction.pop();
98+
},
99+
IfStatement(node) {
100+
const { test } = node;
101+
102+
switch (test.type) {
103+
case 'LogicalExpression': {
104+
const { left, right } = test;
105+
106+
if (isProtection(left) || isProtection(right)) {
107+
lastProtection = node;
108+
encounteredAwait = null;
109+
}
110+
break;
111+
}
112+
default:
113+
console.log('unhandled if statestatement', node);
114+
}
115+
},
116+
AwaitExpression(node) {
117+
if (inClass.length === 0) {
118+
return;
119+
}
120+
if (inFunction.length === 0) {
121+
return;
122+
}
123+
124+
encounteredAwait = node.parent;
125+
},
126+
MemberExpression(node) {
127+
if (node.object.type !== 'ThisExpression') {
128+
return;
129+
}
130+
if (!encounteredAwait) {
131+
return;
132+
}
133+
134+
context.report({
135+
node: node.object,
136+
message: ERROR_MESSAGE,
137+
138+
// https://eslint.org/docs/developer-guide/working-with-rules#applying-fixes
139+
*fix(fixer) {
140+
if (!encounteredAwait) {
141+
return;
142+
}
143+
144+
const toFix = encounteredAwait;
145+
encounteredAwait = null;
146+
147+
const protection = '\nif (isDestroying(this) || isDestroyed(this)) return;';
148+
const original = source.getText(toFix);
149+
150+
yield fixer.replaceText(toFix, original + protection);
151+
152+
// extend range of the fix to the range
153+
yield fixer.insertTextBefore(toFix, '');
154+
yield fixer.insertTextAfter(toFix, '');
155+
},
156+
});
157+
},
158+
};
159+
},
160+
};

0 commit comments

Comments
 (0)