Skip to content

Commit 0c0bf82

Browse files
authored
feat(linter): handle arrays of Promises in noFloatingPromises (#6512)
1 parent c5ee385 commit 0c0bf82

File tree

13 files changed

+720
-171
lines changed

13 files changed

+720
-171
lines changed

.changeset/angry-asses-attack.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@biomejs/biome": minor
3+
---
4+
5+
The rule [`noFloatingPromises`](https://biomejs.dev/linter/rules/no-misused-promises/)
6+
can now detect floating arrays of `Promise`s.
7+
8+
## Invalid examples
9+
10+
```ts
11+
// This gets flagged because the Promises are not handled.
12+
[1, 2, 3].map(async (x) => x + 1);
13+
```
14+
15+
## Valid examples
16+
17+
```ts
18+
await Promise.all([1, 2, 3].map(async (x) => x + 1));
19+
```

crates/biome_js_analyze/src/lint/nursery/no_floating_promises.rs

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use biome_analyze::{
33
};
44
use biome_console::markup;
55
use biome_js_factory::make;
6-
use biome_js_syntax::{AnyJsExpression, JsExpressionStatement, JsSyntaxKind};
6+
use biome_js_syntax::{
7+
AnyJsCallArgument, AnyJsExpression, AnyJsName, JsExpressionStatement, JsSyntaxKind, T,
8+
};
79
use biome_rowan::{AstNode, AstSeparatedList, BatchMutationExt, TriviaPieceKind};
810

911
use crate::{JsRuleAction, ast_utils::is_in_async_function, services::typed::Typed};
@@ -164,9 +166,14 @@ declare_lint_rule! {
164166
}
165167
}
166168

169+
pub enum NoFloatingPromisesState {
170+
ArrayOfPromises,
171+
UnhandledPromise,
172+
}
173+
167174
impl Rule for NoFloatingPromises {
168175
type Query = Typed<JsExpressionStatement>;
169-
type State = ();
176+
type State = NoFloatingPromisesState;
170177
type Signals = Option<Self::State>;
171178
type Options = ();
172179

@@ -177,6 +184,10 @@ impl Rule for NoFloatingPromises {
177184

178185
// Uncomment the following line for debugging convenience:
179186
//let printed = format!("type of {expression:?} = {ty:?}");
187+
if ty.is_array_of(|ty| ty.is_promise_instance()) {
188+
return Some(NoFloatingPromisesState::ArrayOfPromises);
189+
}
190+
180191
let is_maybe_promise =
181192
ty.is_promise_instance() || ty.has_variant(|ty| ty.is_promise_instance());
182193
if !is_maybe_promise {
@@ -187,26 +198,48 @@ impl Rule for NoFloatingPromises {
187198
return None;
188199
}
189200

190-
Some(())
201+
Some(NoFloatingPromisesState::UnhandledPromise)
191202
}
192203

193-
fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
204+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
194205
let node = ctx.query();
195-
Some(
196-
RuleDiagnostic::new(
197-
rule_category!(),
198-
node.range(),
199-
markup! {
200-
"A \"floating\" Promise was found, meaning it is not properly handled and could lead to ignored errors or unexpected behavior."
201-
},
202-
)
203-
.note(markup! {
204-
"This happens when a Promise is not awaited, lacks a `.catch` or `.then` rejection handler, or is not explicitly ignored using the `void` operator."
205-
})
206-
)
206+
match state {
207+
NoFloatingPromisesState::ArrayOfPromises => Some(
208+
RuleDiagnostic::new(
209+
rule_category!(),
210+
node.range(),
211+
markup! {
212+
"An array of Promises was found, meaning they are not "
213+
"properly handled and could lead to ignored errors or "
214+
"unexpected behavior."
215+
},
216+
)
217+
.note(markup! {
218+
"This happens when an array of Promises is not wrapped "
219+
"with Promise.all() or a similar method, and is not "
220+
"explicitly ignored using the `void` operator."
221+
}),
222+
),
223+
NoFloatingPromisesState::UnhandledPromise => Some(
224+
RuleDiagnostic::new(
225+
rule_category!(),
226+
node.range(),
227+
markup! {
228+
"A \"floating\" Promise was found, meaning it is not "
229+
"properly handled and could lead to ignored errors or "
230+
"unexpected behavior."
231+
},
232+
)
233+
.note(markup! {
234+
"This happens when a Promise is not awaited, lacks a "
235+
"`.catch` or `.then` rejection handler, or is not "
236+
"explicitly ignored using the `void` operator."
237+
}),
238+
),
239+
}
207240
}
208241

209-
fn action(ctx: &RuleContext<Self>, _: &Self::State) -> Option<JsRuleAction> {
242+
fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<JsRuleAction> {
210243
let node = ctx.query();
211244

212245
if !is_in_async_function(node.syntax()) {
@@ -215,19 +248,66 @@ impl Rule for NoFloatingPromises {
215248

216249
let expression = node.expression().ok()?;
217250
let mut mutation = ctx.root().begin();
218-
let await_expression = AnyJsExpression::JsAwaitExpression(make::js_await_expression(
219-
make::token(JsSyntaxKind::AWAIT_KW)
220-
.with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
221-
expression.clone().trim_leading_trivia()?,
222-
));
251+
match state {
252+
NoFloatingPromisesState::ArrayOfPromises => {
253+
let callee_expression =
254+
AnyJsExpression::JsStaticMemberExpression(make::js_static_member_expression(
255+
AnyJsExpression::JsIdentifierExpression(make::js_identifier_expression(
256+
make::js_reference_identifier(make::ident("Promise")),
257+
)),
258+
make::token(T![.]),
259+
AnyJsName::JsName(make::js_name(make::ident("all"))),
260+
));
261+
262+
let call_expression = AnyJsExpression::JsCallExpression(
263+
make::js_call_expression(
264+
callee_expression,
265+
make::js_call_arguments(
266+
make::token(T!['(']),
267+
make::js_call_argument_list(
268+
[AnyJsCallArgument::AnyJsExpression(
269+
expression.clone().trim_trivia()?,
270+
)],
271+
[],
272+
),
273+
make::token(T![')']),
274+
),
275+
)
276+
.build(),
277+
);
278+
279+
let await_expression =
280+
AnyJsExpression::JsAwaitExpression(make::js_await_expression(
281+
make::token(JsSyntaxKind::AWAIT_KW)
282+
.with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
283+
call_expression,
284+
));
223285

224-
mutation.replace_node(expression, await_expression);
225-
Some(JsRuleAction::new(
226-
ctx.metadata().action_category(ctx.category(), ctx.group()),
227-
ctx.metadata().applicability(),
228-
markup! { "Add await operator." }.to_owned(),
229-
mutation,
230-
))
286+
mutation.replace_node(expression, await_expression);
287+
Some(JsRuleAction::new(
288+
ctx.metadata().action_category(ctx.category(), ctx.group()),
289+
ctx.metadata().applicability(),
290+
markup! { "Wrap in Promise.all() and add await operator." }.to_owned(),
291+
mutation,
292+
))
293+
}
294+
NoFloatingPromisesState::UnhandledPromise => {
295+
let await_expression =
296+
AnyJsExpression::JsAwaitExpression(make::js_await_expression(
297+
make::token(JsSyntaxKind::AWAIT_KW)
298+
.with_trailing_trivia([(TriviaPieceKind::Whitespace, " ")]),
299+
expression.clone().trim_leading_trivia()?,
300+
));
301+
302+
mutation.replace_node(expression, await_expression);
303+
Some(JsRuleAction::new(
304+
ctx.metadata().action_category(ctx.category(), ctx.group()),
305+
ctx.metadata().applicability(),
306+
markup! { "Add await operator." }.to_owned(),
307+
mutation,
308+
))
309+
}
310+
}
231311
}
232312
}
233313

crates/biome_js_analyze/src/lint/nursery/no_misused_promises.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ declare_lint_rule! {
9494

9595
pub enum NoMisusedPromisesState {
9696
Conditional,
97-
ExpectedConditionalReturn,
98-
ExpectedVoidReturn,
97+
ConditionalReturn,
9998
Spread,
99+
VoidReturn,
100100
}
101101

102102
impl Rule for NoMisusedPromises {
@@ -133,7 +133,7 @@ impl Rule for NoMisusedPromises {
133133
"You may have intended to `await` the Promise instead."
134134
}),
135135
),
136-
NoMisusedPromisesState::ExpectedConditionalReturn => Some(
136+
NoMisusedPromisesState::ConditionalReturn => Some(
137137
RuleDiagnostic::new(
138138
rule_category!(),
139139
node.range(),
@@ -150,7 +150,7 @@ impl Rule for NoMisusedPromises {
150150
"does not work inside a synchronous callback."
151151
}),
152152
),
153-
NoMisusedPromisesState::ExpectedVoidReturn => Some(
153+
NoMisusedPromisesState::VoidReturn => Some(
154154
RuleDiagnostic::new(
155155
rule_category!(),
156156
node.range(),
@@ -182,7 +182,7 @@ impl Rule for NoMisusedPromises {
182182

183183
fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<JsRuleAction> {
184184
use NoMisusedPromisesState::*;
185-
if matches!(state, ExpectedConditionalReturn | ExpectedVoidReturn) {
185+
if matches!(state, ConditionalReturn | VoidReturn) {
186186
return None; // These cannot be automatically fixed.
187187
}
188188

@@ -296,9 +296,9 @@ fn find_misused_promise_returning_callback(
296296
};
297297

298298
if argument_ty.is_function_with_return_type(|ty| ty.is_conditional()) {
299-
Some(NoMisusedPromisesState::ExpectedConditionalReturn)
299+
Some(NoMisusedPromisesState::ConditionalReturn)
300300
} else if argument_ty.is_function_with_return_type(|ty| ty.is_void()) {
301-
Some(NoMisusedPromisesState::ExpectedVoidReturn)
301+
Some(NoMisusedPromisesState::VoidReturn)
302302
} else {
303303
None
304304
}

crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalid.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,9 @@ function returnMaybePromise(): Promise<void> | undefined {
345345
}
346346

347347
returnMaybePromise();
348+
349+
[1, 2, 3].map(async (x) => x + 1);
350+
351+
async function floatingArray() {
352+
[1, 2, 3].map((x) => Promise.resolve(x + 1));
353+
}

crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalid.ts.snap

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,12 @@ function returnMaybePromise(): Promise<void> | undefined {
352352
353353
returnMaybePromise();
354354
355+
[1, 2, 3].map(async (x) => x + 1);
356+
357+
async function floatingArray() {
358+
[1, 2, 3].map((x) => Promise.resolve(x + 1));
359+
}
360+
355361
```
356362
357363
# Diagnostics
@@ -1631,8 +1637,46 @@ invalid.ts:347:1 lint/nursery/noFloatingPromises ━━━━━━━━━━
16311637
> 347 │ returnMaybePromise();
16321638
│ ^^^^^^^^^^^^^^^^^^^^^
16331639
348 │
1640+
349 │ [1, 2, 3].map(async (x) => x + 1);
16341641
16351642
i This happens when a Promise is not awaited, lacks a `.catch` or `.then` rejection handler, or is not explicitly ignored using the `void` operator.
16361643
16371644
16381645
```
1646+
1647+
```
1648+
invalid.ts:349:1 lint/nursery/noFloatingPromises ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1649+
1650+
i An array of Promises was found, meaning they are not properly handled and could lead to ignored errors or unexpected behavior.
1651+
1652+
347 │ returnMaybePromise();
1653+
348 │
1654+
> 349 │ [1, 2, 3].map(async (x) => x + 1);
1655+
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1656+
350 │
1657+
351 │ async function floatingArray() {
1658+
1659+
i This happens when an array of Promises is not wrapped with Promise.all() or a similar method, and is not explicitly ignored using the `void` operator.
1660+
1661+
1662+
```
1663+
1664+
```
1665+
invalid.ts:352:2 lint/nursery/noFloatingPromises FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1666+
1667+
i An array of Promises was found, meaning they are not properly handled and could lead to ignored errors or unexpected behavior.
1668+
1669+
351async function floatingArray() {
1670+
> 352 │ [1, 2, 3].map((x) => Promise.resolve(x + 1));
1671+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1672+
353 │ }
1673+
354
1674+
1675+
i This happens when an array of Promises is not wrapped with Promise.all() or a similar method, and is not explicitly ignored using the `void` operator.
1676+
1677+
i Unsafe fix: Wrap in Promise.all() and add await operator.
1678+
1679+
352 │ → await·Promise.all([123].map((x=>·Promise.resolve(x·+·1)));
1680+
++++++++++++++++++ +
1681+
1682+
```

crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/valid.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,9 @@ async function testDestructuringAndCallingReturnsPromiseFromRest({
248248
}: Props) {
249249
rest.returnsPromise().catch(() => {}).finally(() => {});
250250
}
251+
252+
void [1, 2, 3].map(async (x) => x + 1);
253+
254+
async function floatingArray() {
255+
await Promise.all([1, 2, 3].map((x) => Promise.resolve(x + 1)));
256+
}

crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/valid.ts.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,4 +255,10 @@ async function testDestructuringAndCallingReturnsPromiseFromRest({
255255
rest.returnsPromise().catch(() => {}).finally(() => {});
256256
}
257257
258+
void [1, 2, 3].map(async (x) => x + 1);
259+
260+
async function floatingArray() {
261+
await Promise.all([1, 2, 3].map((x) => Promise.resolve(x + 1)));
262+
}
263+
258264
```

0 commit comments

Comments
 (0)