-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[eslint] add rule to forbid async forEach bodies #111637
Changes from all commits
9fad060
20495b0
b49a185
caa608a
081e27c
11f3108
7d07a1a
8f3b502
73e42fb
4c94b18
f206369
f9d171b
6d08466
1f6be74
75024c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
const tsEstree = require('@typescript-eslint/typescript-estree'); | ||
const esTypes = tsEstree.AST_NODE_TYPES; | ||
|
||
/** @typedef {import("eslint").Rule.RuleModule} Rule */ | ||
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Node} Node */ | ||
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.CallExpression} CallExpression */ | ||
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.FunctionExpression} FunctionExpression */ | ||
/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.ArrowFunctionExpression} ArrowFunctionExpression */ | ||
/** @typedef {import("eslint").Rule.RuleFixer} Fixer */ | ||
|
||
const ERROR_MSG = | ||
'Passing an async function to .forEach() prevents promise rejections from being handled. Use asyncForEach() or similar helper from "@kbn/std" instead.'; | ||
|
||
/** | ||
* @param {Node} node | ||
* @returns {node is ArrowFunctionExpression | FunctionExpression} | ||
*/ | ||
const isFunc = (node) => | ||
node.type === esTypes.ArrowFunctionExpression || node.type === esTypes.FunctionExpression; | ||
|
||
/** | ||
* @param {any} context | ||
* @param {CallExpression} node | ||
*/ | ||
const isAsyncForEachCall = (node) => { | ||
return ( | ||
node.callee.type === esTypes.MemberExpression && | ||
node.callee.property.type === esTypes.Identifier && | ||
node.callee.property.name === 'forEach' && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. unfortunately, it doesn't cover cases when a promise is returned without an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, in that case we're creating a promise which can be handled, so I feel like it's less of an issue. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Could you elaborate on it? Sorry, I'm not follow who handles a promise in this case 😞 async function doAsync () {}
[].forEach(doAsync) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I figured you were describing: array.forEach(() => new Promise((resolve) => ...)) There's a chance we could improve this to use types for locally defined functions, but the more research I do on other things the more I feel like we need to start providing ESLint with full types... Which has a lot of complications. |
||
node.arguments.length >= 1 && | ||
isFunc(node.arguments[0]) && | ||
node.arguments[0].async | ||
); | ||
}; | ||
|
||
/** @type {Rule} */ | ||
module.exports = { | ||
meta: { | ||
fixable: 'code', | ||
schema: [], | ||
}, | ||
create: (context) => ({ | ||
CallExpression(_) { | ||
const node = /** @type {CallExpression} */ (_); | ||
|
||
if (isAsyncForEachCall(node)) { | ||
context.report({ | ||
message: ERROR_MSG, | ||
loc: node.arguments[0].loc, | ||
}); | ||
} | ||
}, | ||
}), | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
const { RuleTester } = require('eslint'); | ||
const rule = require('./no_async_foreach'); | ||
const dedent = require('dedent'); | ||
|
||
const ruleTester = new RuleTester({ | ||
parser: require.resolve('@typescript-eslint/parser'), | ||
parserOptions: { | ||
sourceType: 'module', | ||
ecmaVersion: 2018, | ||
ecmaFeatures: { | ||
jsx: true, | ||
}, | ||
}, | ||
}); | ||
|
||
ruleTester.run('@kbn/eslint/no_async_foreach', rule, { | ||
valid: [ | ||
{ | ||
code: dedent` | ||
array.forEach((a) => { | ||
b(a) | ||
}) | ||
`, | ||
}, | ||
{ | ||
code: dedent` | ||
array.forEach(function (a) { | ||
b(a) | ||
}) | ||
`, | ||
}, | ||
], | ||
|
||
invalid: [ | ||
{ | ||
code: dedent` | ||
array.forEach(async (a) => { | ||
await b(a) | ||
}) | ||
`, | ||
errors: [ | ||
{ | ||
line: 1, | ||
message: | ||
'Passing an async function to .forEach() prevents promise rejections from being handled. Use asyncForEach() or similar helper from "@kbn/std" instead.', | ||
}, | ||
], | ||
}, | ||
{ | ||
code: dedent` | ||
array.forEach(async function (a) { | ||
await b(a) | ||
}) | ||
`, | ||
errors: [ | ||
{ | ||
line: 1, | ||
message: | ||
'Passing an async function to .forEach() prevents promise rejections from being handled. Use asyncForEach() or similar helper from "@kbn/std" instead.', | ||
}, | ||
], | ||
}, | ||
], | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
import * as Rx from 'rxjs'; | ||
|
||
import { asyncForEach, asyncForEachWithLimit } from './for_each'; | ||
import { list, sleep } from './test_helpers'; | ||
|
||
jest.mock('./observable'); | ||
const mockMapWithLimit$: jest.Mock = jest.requireMock('./observable').mapWithLimit$; | ||
|
||
beforeEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
describe('asyncForEachWithLimit', () => { | ||
it('calls mapWithLimit$ and resolves with undefined when it completes', async () => { | ||
const iter = list(10); | ||
const limit = 5; | ||
const fn = jest.fn(); | ||
|
||
const result$ = new Rx.Subject(); | ||
mockMapWithLimit$.mockImplementation(() => result$); | ||
const promise = asyncForEachWithLimit(iter, limit, fn); | ||
|
||
let resolved = false; | ||
promise.then(() => (resolved = true)); | ||
|
||
expect(mockMapWithLimit$).toHaveBeenCalledTimes(1); | ||
expect(mockMapWithLimit$).toHaveBeenCalledWith(iter, limit, fn); | ||
|
||
expect(resolved).toBe(false); | ||
result$.next(1); | ||
result$.next(2); | ||
result$.next(3); | ||
|
||
await sleep(10); | ||
expect(resolved).toBe(false); | ||
|
||
result$.complete(); | ||
await expect(promise).resolves.toBe(undefined); | ||
}); | ||
|
||
it('resolves when iterator is empty', async () => { | ||
mockMapWithLimit$.mockImplementation((x) => Rx.from(x)); | ||
await expect(asyncForEachWithLimit([], 100, async () => 'foo')).resolves.toBe(undefined); | ||
}); | ||
}); | ||
|
||
describe('asyncForEach', () => { | ||
it('calls mapWithLimit$ without limit and resolves with undefined when it completes', async () => { | ||
spalger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const iter = list(10); | ||
const fn = jest.fn(); | ||
|
||
const result$ = new Rx.Subject(); | ||
mockMapWithLimit$.mockImplementation(() => result$); | ||
const promise = asyncForEach(iter, fn); | ||
|
||
let resolved = false; | ||
promise.then(() => (resolved = true)); | ||
|
||
expect(mockMapWithLimit$).toHaveBeenCalledTimes(1); | ||
expect(mockMapWithLimit$).toHaveBeenCalledWith(iter, Infinity, fn); | ||
|
||
expect(resolved).toBe(false); | ||
result$.next(1); | ||
result$.next(2); | ||
result$.next(3); | ||
|
||
await sleep(10); | ||
expect(resolved).toBe(false); | ||
|
||
result$.complete(); | ||
await expect(promise).resolves.toBe(undefined); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
import { defaultIfEmpty } from 'rxjs/operators'; | ||
|
||
import { lastValueFrom } from '../rxjs_7'; | ||
import { mapWithLimit$ } from './observable'; | ||
import { IterableInput, AsyncMapFn } from './types'; | ||
|
||
/** | ||
* Creates a promise which resolves with `undefined` after calling `fn` for each | ||
* item in `iterable`. `fn` can return either a Promise or Observable. If `fn` | ||
* returns observables then they will properly abort if an error occurs. | ||
* | ||
* @param iterable Items to iterate | ||
* @param fn Function to call for each item | ||
*/ | ||
export async function asyncForEach<T>(iterable: IterableInput<T>, fn: AsyncMapFn<T, any>) { | ||
await lastValueFrom(mapWithLimit$(iterable, Infinity, fn).pipe(defaultIfEmpty())); | ||
} | ||
|
||
/** | ||
* Creates a promise which resolves with `undefined` after calling `fn` for each | ||
* item in `iterable`. `fn` can return either a Promise or Observable. If `fn` | ||
* returns observables then they will properly abort if an error occurs. | ||
* | ||
* The number of concurrent executions of `fn` is limited by `limit`. | ||
* | ||
* @param iterable Items to iterate | ||
* @param limit Maximum number of operations to run in parallel | ||
* @param fn Function to call for each item | ||
*/ | ||
export async function asyncForEachWithLimit<T>( | ||
iterable: IterableInput<T>, | ||
limit: number, | ||
spalger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
fn: AsyncMapFn<T, any> | ||
) { | ||
await lastValueFrom(mapWithLimit$(iterable, limit, fn).pipe(defaultIfEmpty())); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
export * from './observable'; | ||
export * from './for_each'; | ||
export * from './map'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
import * as Rx from 'rxjs'; | ||
import { mapTo } from 'rxjs/operators'; | ||
|
||
import { asyncMap, asyncMapWithLimit } from './map'; | ||
import { list } from './test_helpers'; | ||
|
||
jest.mock('./observable'); | ||
const mapWithLimit$: jest.Mock = jest.requireMock('./observable').mapWithLimit$; | ||
mapWithLimit$.mockImplementation(jest.requireActual('./observable').mapWithLimit$); | ||
|
||
beforeEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
describe('asyncMapWithLimit', () => { | ||
it('calls mapWithLimit$ and resolves with properly sorted results', async () => { | ||
const iter = list(10); | ||
const limit = 5; | ||
const fn = jest.fn((n) => (n % 2 ? Rx.timer(n) : Rx.timer(n * 4)).pipe(mapTo(n))); | ||
const result = await asyncMapWithLimit(iter, limit, fn); | ||
|
||
expect(result).toMatchInlineSnapshot(` | ||
Array [ | ||
0, | ||
1, | ||
2, | ||
3, | ||
4, | ||
5, | ||
6, | ||
7, | ||
8, | ||
9, | ||
] | ||
`); | ||
|
||
expect(mapWithLimit$).toHaveBeenCalledTimes(1); | ||
expect(mapWithLimit$).toHaveBeenCalledWith(iter, limit, expect.any(Function)); | ||
}); | ||
|
||
it.each([ | ||
[list(0), []] as const, | ||
[list(1), ['foo']] as const, | ||
[list(2), ['foo', 'foo']] as const, | ||
])('resolves when iterator is %p', async (input, output) => { | ||
await expect(asyncMapWithLimit(input, 100, async () => 'foo')).resolves.toEqual(output); | ||
}); | ||
}); | ||
|
||
describe('asyncMap', () => { | ||
it('calls mapWithLimit$ without limit and resolves with undefined when it completes', async () => { | ||
const iter = list(10); | ||
const fn = jest.fn((n) => (n % 2 ? Rx.timer(n) : Rx.timer(n * 4)).pipe(mapTo(n))); | ||
const result = await asyncMap(iter, fn); | ||
|
||
expect(result).toMatchInlineSnapshot(` | ||
Array [ | ||
0, | ||
1, | ||
2, | ||
3, | ||
4, | ||
5, | ||
6, | ||
7, | ||
8, | ||
9, | ||
] | ||
`); | ||
|
||
expect(mapWithLimit$).toHaveBeenCalledTimes(1); | ||
expect(mapWithLimit$).toHaveBeenCalledWith(iter, Infinity, expect.any(Function)); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe we should expand the rule to cover other expressions, like this one #98463 (comment)
I'm not sure whether no-floating-promises implements the same check, but we can give it a try.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I'll look at a similar solution for that. I don't want to overload this rule though and have a special error message for that scenario.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would you suggest implementing a custom rule for every use-case when
(...args: any[]) => Promise<any>
is used instead of(...args: any[]) => void
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do think that having custom error messages for each situation would be nice, though I'm not convinced there are that many scenarios right now. It's possibly a bigger more wide issue than I'm thinking though.