Skip to content

Commit 2619c3b

Browse files
fix(eslint-plugin): [return-await] clean up in-try-catch detection and make autofixes safe (#9031)
* [no-return-await] Clean up the logic for in-try-catch detection and make autofixes safe * spell * snapshot * nicer diff * adjust unreachable case
1 parent c3a206f commit 2619c3b

File tree

5 files changed

+625
-185
lines changed

5 files changed

+625
-185
lines changed

Diff for: .cspell.json

+1
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
"tsvfs",
150150
"typedef",
151151
"typedefs",
152+
"unawaited",
152153
"unfixable",
153154
"unoptimized",
154155
"unprefixed",

Diff for: packages/eslint-plugin/docs/rules/return-await.mdx

+39-16
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ const defaultOptions: Options = 'in-try-catch';
2626

2727
### `in-try-catch`
2828

29-
Requires that a returned promise must be `await`ed in `try-catch-finally` blocks, and disallows it elsewhere.
30-
Specifically:
29+
In cases where returning an unawaited promise would cause unexpected error-handling control flow, the rule enforces that `await` must be used.
30+
Otherwise, the rule enforces that `await` must _not_ be used.
3131

32-
- if you `return` a promise within a `try`, then it must be `await`ed.
33-
- if you `return` a promise within a `catch`, and there **_is no_** `finally`, then it **_must not_** be `await`ed.
34-
- if you `return` a promise within a `catch`, and there **_is a_** `finally`, then it **_must_** be `await`ed.
35-
- if you `return` a promise within a `finally`, then it **_must not_** be `await`ed.
32+
Listing the error-handling cases exhaustively:
33+
34+
- if you `return` a promise within a `try`, then it must be `await`ed, since it will always be followed by a `catch` or `finally`.
35+
- if you `return` a promise within a `catch`, and there is _no_ `finally`, then it must _not_ be `await`ed.
36+
- if you `return` a promise within a `catch`, and there _is_ a `finally`, then it _must_ be `await`ed.
37+
- if you `return` a promise within a `finally`, then it must not be `await`ed.
3638

3739
Examples of code with `in-try-catch`:
3840

@@ -42,23 +44,34 @@ Examples of code with `in-try-catch`:
4244
```ts option='"in-try-catch"'
4345
async function invalidInTryCatch1() {
4446
try {
45-
return Promise.resolve('try');
46-
} catch (e) {}
47+
return Promise.reject('try');
48+
} catch (e) {
49+
// Doesn't execute due to missing await.
50+
}
4751
}
4852

4953
async function invalidInTryCatch2() {
5054
try {
5155
throw new Error('error');
5256
} catch (e) {
53-
return await Promise.resolve('catch');
57+
// Unnecessary await; rejections here don't impact control flow.
58+
return await Promise.reject('catch');
5459
}
5560
}
5661

62+
// Prints 'starting async work', 'cleanup', 'async work done'.
5763
async function invalidInTryCatch3() {
64+
async function doAsyncWork(): Promise<void> {
65+
console.log('starting async work');
66+
await new Promise(resolve => setTimeout(resolve, 1000));
67+
console.log('async work done');
68+
}
69+
5870
try {
5971
throw new Error('error');
6072
} catch (e) {
61-
return Promise.resolve('catch');
73+
// Missing await.
74+
return doAsyncWork();
6275
} finally {
6376
console.log('cleanup');
6477
}
@@ -70,7 +83,8 @@ async function invalidInTryCatch4() {
7083
} catch (e) {
7184
throw new Error('error2');
7285
} finally {
73-
return await Promise.resolve('finally');
86+
// Unnecessary await; rejections here don't impact control flow.
87+
return await Promise.reject('finally');
7488
}
7589
}
7690

@@ -89,23 +103,32 @@ async function invalidInTryCatch6() {
89103
```ts option='"in-try-catch"'
90104
async function validInTryCatch1() {
91105
try {
92-
return await Promise.resolve('try');
93-
} catch (e) {}
106+
return await Promise.reject('try');
107+
} catch (e) {
108+
// Executes as expected.
109+
}
94110
}
95111

96112
async function validInTryCatch2() {
97113
try {
98114
throw new Error('error');
99115
} catch (e) {
100-
return Promise.resolve('catch');
116+
return Promise.reject('catch');
101117
}
102118
}
103119

120+
// Prints 'starting async work', 'async work done', 'cleanup'.
104121
async function validInTryCatch3() {
122+
async function doAsyncWork(): Promise<void> {
123+
console.log('starting async work');
124+
await new Promise(resolve => setTimeout(resolve, 1000));
125+
console.log('async work done');
126+
}
127+
105128
try {
106129
throw new Error('error');
107130
} catch (e) {
108-
return await Promise.resolve('catch');
131+
return await doAsyncWork();
109132
} finally {
110133
console.log('cleanup');
111134
}
@@ -117,7 +140,7 @@ async function validInTryCatch4() {
117140
} catch (e) {
118141
throw new Error('error2');
119142
} finally {
120-
return Promise.resolve('finally');
143+
return Promise.reject('finally');
121144
}
122145
}
123146

Diff for: packages/eslint-plugin/src/rules/return-await.ts

+115-69
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
isAwaitKeyword,
1111
isTypeAnyType,
1212
isTypeUnknownType,
13+
nullThrows,
1314
} from '../util';
1415
import { getOperatorPrecedence } from '../util/getOperatorPrecedence';
1516

@@ -41,6 +42,10 @@ export default createRule({
4142
'Returning an awaited promise is not allowed in this context.',
4243
requiredPromiseAwait:
4344
'Returning an awaited promise is required in this context.',
45+
requiredPromiseAwaitSuggestion:
46+
'Add `await` before the expression. Use caution as this may impact control flow.',
47+
disallowedPromiseAwaitSuggestion:
48+
'Remove `await` before the expression. Use caution as this may impact control flow.',
4449
},
4550
schema: [
4651
{
@@ -68,64 +73,90 @@ export default createRule({
6873
scopeInfoStack.pop();
6974
}
7075

71-
function inTry(node: ts.Node): boolean {
72-
let ancestor = node.parent as ts.Node | undefined;
73-
74-
while (ancestor && !ts.isFunctionLike(ancestor)) {
75-
if (ts.isTryStatement(ancestor)) {
76-
return true;
77-
}
78-
79-
ancestor = ancestor.parent;
76+
/**
77+
* Tests whether a node is inside of an explicit error handling context
78+
* (try/catch/finally) in a way that throwing an exception will have an
79+
* impact on the program's control flow.
80+
*/
81+
function affectsExplicitErrorHandling(node: ts.Node): boolean {
82+
// If an error-handling block is followed by another error-handling block,
83+
// control flow is affected by whether promises in it are awaited or not.
84+
// Otherwise, we need to check recursively for nested try statements until
85+
// we get to the top level of a function or the program. If by then,
86+
// there's no offending error-handling blocks, it doesn't affect control
87+
// flow.
88+
const tryAncestorResult = findContainingTryStatement(node);
89+
if (tryAncestorResult == null) {
90+
return false;
8091
}
8192

82-
return false;
83-
}
84-
85-
function inCatch(node: ts.Node): boolean {
86-
let ancestor = node.parent as ts.Node | undefined;
93+
const { tryStatement, block } = tryAncestorResult;
8794

88-
while (ancestor && !ts.isFunctionLike(ancestor)) {
89-
if (ts.isCatchClause(ancestor)) {
95+
switch (block) {
96+
case 'try':
97+
// Try blocks are always followed by either a catch or finally,
98+
// so exceptions thrown here always affect control flow.
9099
return true;
91-
}
92-
93-
ancestor = ancestor.parent;
94-
}
95-
96-
return false;
97-
}
98-
99-
function isReturnPromiseInFinally(node: ts.Node): boolean {
100-
let ancestor = node.parent as ts.Node | undefined;
100+
case 'catch':
101+
// Exceptions thrown in catch blocks followed by a finally block affect
102+
// control flow.
103+
if (tryStatement.finallyBlock != null) {
104+
return true;
105+
}
101106

102-
while (ancestor && !ts.isFunctionLike(ancestor)) {
103-
if (
104-
ts.isTryStatement(ancestor.parent) &&
105-
ts.isBlock(ancestor) &&
106-
ancestor.parent.end === ancestor.end
107-
) {
108-
return true;
107+
// Otherwise recurse.
108+
return affectsExplicitErrorHandling(tryStatement);
109+
case 'finally':
110+
return affectsExplicitErrorHandling(tryStatement);
111+
default: {
112+
const __never: never = block;
113+
throw new Error(`Unexpected block type: ${String(__never)}`);
109114
}
110-
ancestor = ancestor.parent;
111115
}
116+
}
112117

113-
return false;
118+
interface FindContainingTryStatementResult {
119+
tryStatement: ts.TryStatement;
120+
block: 'try' | 'catch' | 'finally';
114121
}
115122

116-
function hasFinallyBlock(node: ts.Node): boolean {
123+
/**
124+
* A try _statement_ is the whole thing that encompasses try block,
125+
* catch clause, and finally block. This function finds the nearest
126+
* enclosing try statement (if present) for a given node, and reports which
127+
* part of the try statement the node is in.
128+
*/
129+
function findContainingTryStatement(
130+
node: ts.Node,
131+
): FindContainingTryStatementResult | undefined {
132+
let child = node;
117133
let ancestor = node.parent as ts.Node | undefined;
118134

119135
while (ancestor && !ts.isFunctionLike(ancestor)) {
120136
if (ts.isTryStatement(ancestor)) {
121-
return !!ancestor.finallyBlock;
137+
let block: 'try' | 'catch' | 'finally' | undefined;
138+
if (child === ancestor.tryBlock) {
139+
block = 'try';
140+
} else if (child === ancestor.catchClause) {
141+
block = 'catch';
142+
} else if (child === ancestor.finallyBlock) {
143+
block = 'finally';
144+
}
145+
146+
return {
147+
tryStatement: ancestor,
148+
block: nullThrows(
149+
block,
150+
'Child of a try statement must be a try block, catch clause, or finally block',
151+
),
152+
};
122153
}
154+
child = ancestor;
123155
ancestor = ancestor.parent;
124156
}
125-
return false;
126-
}
127157

128-
// function findTokensToRemove()
158+
return undefined;
159+
}
129160

130161
function removeAwait(
131162
fixer: TSESLint.RuleFixer,
@@ -202,33 +233,35 @@ export default createRule({
202233
if (isAwait && !isThenable) {
203234
// any/unknown could be thenable; do not auto-fix
204235
const useAutoFix = !(isTypeAnyType(type) || isTypeUnknownType(type));
205-
const fix = (fixer: TSESLint.RuleFixer): TSESLint.RuleFix | null =>
206-
removeAwait(fixer, node);
207236

208237
context.report({
209238
messageId: 'nonPromiseAwait',
210239
node,
211-
...(useAutoFix
212-
? { fix }
213-
: {
214-
suggest: [
215-
{
216-
messageId: 'nonPromiseAwait',
217-
fix,
218-
},
219-
],
220-
}),
240+
...fixOrSuggest(useAutoFix, {
241+
messageId: 'nonPromiseAwait',
242+
fix: fixer => removeAwait(fixer, node),
243+
}),
221244
});
222245
return;
223246
}
224247

248+
const affectsErrorHandling = affectsExplicitErrorHandling(expression);
249+
const useAutoFix = !affectsErrorHandling;
250+
225251
if (option === 'always') {
226252
if (!isAwait && isThenable) {
227253
context.report({
228254
messageId: 'requiredPromiseAwait',
229255
node,
230-
fix: fixer =>
231-
insertAwait(fixer, node, isHigherPrecedenceThanAwait(expression)),
256+
...fixOrSuggest(useAutoFix, {
257+
messageId: 'requiredPromiseAwaitSuggestion',
258+
fix: fixer =>
259+
insertAwait(
260+
fixer,
261+
node,
262+
isHigherPrecedenceThanAwait(expression),
263+
),
264+
}),
232265
});
233266
}
234267

@@ -240,35 +273,39 @@ export default createRule({
240273
context.report({
241274
messageId: 'disallowedPromiseAwait',
242275
node,
243-
fix: fixer => removeAwait(fixer, node),
276+
...fixOrSuggest(useAutoFix, {
277+
messageId: 'disallowedPromiseAwaitSuggestion',
278+
fix: fixer => removeAwait(fixer, node),
279+
}),
244280
});
245281
}
246282

247283
return;
248284
}
249285

250286
if (option === 'in-try-catch') {
251-
const isInTryCatch = inTry(expression) || inCatch(expression);
252-
if (isAwait && !isInTryCatch) {
287+
if (isAwait && !affectsErrorHandling) {
253288
context.report({
254289
messageId: 'disallowedPromiseAwait',
255290
node,
256-
fix: fixer => removeAwait(fixer, node),
291+
...fixOrSuggest(useAutoFix, {
292+
messageId: 'disallowedPromiseAwaitSuggestion',
293+
fix: fixer => removeAwait(fixer, node),
294+
}),
257295
});
258-
} else if (!isAwait && isInTryCatch) {
259-
if (inCatch(expression) && !hasFinallyBlock(expression)) {
260-
return;
261-
}
262-
263-
if (isReturnPromiseInFinally(expression)) {
264-
return;
265-
}
266-
296+
} else if (!isAwait && affectsErrorHandling) {
267297
context.report({
268298
messageId: 'requiredPromiseAwait',
269299
node,
270-
fix: fixer =>
271-
insertAwait(fixer, node, isHigherPrecedenceThanAwait(expression)),
300+
...fixOrSuggest(useAutoFix, {
301+
messageId: 'requiredPromiseAwaitSuggestion',
302+
fix: fixer =>
303+
insertAwait(
304+
fixer,
305+
node,
306+
isHigherPrecedenceThanAwait(expression),
307+
),
308+
}),
272309
});
273310
}
274311

@@ -321,3 +358,12 @@ export default createRule({
321358
};
322359
},
323360
});
361+
362+
function fixOrSuggest<MessageId extends string>(
363+
useFix: boolean,
364+
suggestion: TSESLint.SuggestionReportDescriptor<MessageId>,
365+
):
366+
| { fix: TSESLint.ReportFixFunction }
367+
| { suggest: TSESLint.SuggestionReportDescriptor<MessageId>[] } {
368+
return useFix ? { fix: suggestion.fix } : { suggest: [suggestion] };
369+
}

0 commit comments

Comments
 (0)