From 5242c41ab48f46b543dd3efbdb64304ff7048fb8 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 4 Jun 2024 13:01:37 -0400 Subject: [PATCH 1/7] feat(eslint-plugin): [no-floating-promises] add checkAllThenables option --- .../docs/rules/no-floating-promises.mdx | 47 ++++++++++++++++ .../src/rules/no-floating-promises.ts | 42 +++++++++++--- .../no-floating-promises.shot | 44 ++++++++++++++- .../tests/rules/no-floating-promises.test.ts | 56 +++++++++++++++++++ 4 files changed, 177 insertions(+), 12 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-floating-promises.mdx b/packages/eslint-plugin/docs/rules/no-floating-promises.mdx index e3f047e6e4f0..f5fd9476632d 100644 --- a/packages/eslint-plugin/docs/rules/no-floating-promises.mdx +++ b/packages/eslint-plugin/docs/rules/no-floating-promises.mdx @@ -84,6 +84,53 @@ await Promise.all([1, 2, 3].map(async x => x + 1)); ## Options +### `checkThenables` + +A ["Thenable"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables) value is an object which has a `then` method, such as a `Promise`. +Other Thenables include TypeScript's built-in `PromiseLike` interface and any custom object that happens to have a `.then()`. + +The `checkAllThenable` option triggers `no-floating-promises` to also consider all values that happen to be Thenable, not just Promises. +This can be useful if your code works with older `Promise` polyfills instead of the native `Promise` class. + + + + +```ts option='{"checkThenables": true}' +declare function createPromiseLike(): PromiseLike; + +createPromiseLike(); + +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +createMyThenable(); +``` + + + + +```ts option='{"checkThenables": true}' +declare function createPromiseLike(): PromiseLike; + +await createPromiseLike(); + +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +await createMyThenable(); +``` + + + + +> This option is enabled by default in v7 but will be turned off by default in v8. + ### `ignoreVoid` This option, which is `true` by default, allows you to stop the rule reporting promises consumed with void operator. diff --git a/packages/eslint-plugin/src/rules/no-floating-promises.ts b/packages/eslint-plugin/src/rules/no-floating-promises.ts index 1ae5e602ae0e..c7a0f847538e 100644 --- a/packages/eslint-plugin/src/rules/no-floating-promises.ts +++ b/packages/eslint-plugin/src/rules/no-floating-promises.ts @@ -8,6 +8,7 @@ import { createRule, getOperatorPrecedence, getParserServices, + isSymbolFromDefaultLibrary, OperatorPrecedence, readonlynessOptionsDefaults, readonlynessOptionsSchema, @@ -16,9 +17,10 @@ import { type Options = [ { - ignoreVoid?: boolean; - ignoreIIFE?: boolean; allowForKnownSafePromises?: TypeOrValueSpecifier[]; + checkThenables?: boolean; + ignoreIIFE?: boolean; + ignoreVoid?: boolean; }, ]; @@ -75,6 +77,12 @@ export default createRule({ { type: 'object', properties: { + allowForKnownSafePromises: readonlynessOptionsSchema.properties.allow, + checkThenables: { + description: + 'Whether to check all "Thenable"s, not just the built-in Promise type.', + type: 'boolean', + }, ignoreVoid: { description: 'Whether to ignore `void` expressions.', type: 'boolean', @@ -84,7 +92,6 @@ export default createRule({ 'Whether to ignore async IIFEs (Immediately Invoked Function Expressions).', type: 'boolean', }, - allowForKnownSafePromises: readonlynessOptionsSchema.properties.allow, }, additionalProperties: false, }, @@ -93,15 +100,18 @@ export default createRule({ }, defaultOptions: [ { + allowForKnownSafePromises: readonlynessOptionsDefaults.allow, + checkThenables: true, ignoreVoid: true, ignoreIIFE: false, - allowForKnownSafePromises: readonlynessOptionsDefaults.allow, }, ], create(context, [options]) { const services = getParserServices(context); const checker = services.program.getTypeChecker(); + const { checkThenables } = options; + // TODO: #5439 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const allowForKnownSafePromises = options.allowForKnownSafePromises!; @@ -356,14 +366,10 @@ export default createRule({ return false; } - // Modified from tsutils.isThenable() to only consider thenables which can be - // rejected/caught via a second parameter. Original source (MIT licensed): - // - // https://github.com/ajafff/tsutils/blob/49d0d31050b44b81e918eae4fbaf1dfe7b7286af/util/type.ts#L95-L125 function isPromiseLike(node: ts.Node, type?: ts.Type): boolean { type ??= checker.getTypeAtLocation(node); - // Ignore anything specified by `allowForKnownSafePromises` option. + // The highest priority it so allow anything allowlisted if ( allowForKnownSafePromises.some(allowedType => typeMatchesSpecifier(type, allowedType, services.program), @@ -372,6 +378,24 @@ export default createRule({ return false; } + // Otherwise, we always consider the built-in Promise to be Promise-like... + const symbol = type.getSymbol(); + if ( + symbol?.name === 'Promise' && + isSymbolFromDefaultLibrary(services.program, symbol) + ) { + return true; + } + + // ...and only check all Thenables if explicitly told to + if (!checkThenables) { + return false; + } + + // Modified from tsutils.isThenable() to only consider thenables which can be + // rejected/caught via a second parameter. Original source (MIT licensed): + // + // https://github.com/ajafff/tsutils/blob/49d0d31050b44b81e918eae4fbaf1dfe7b7286af/util/type.ts#L95-L125 for (const ty of tsutils.unionTypeParts(checker.getApparentType(type))) { const then = ty.getProperty('then'); if (then === undefined) { diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot index 44e303fc3131..5dafc5366a02 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot @@ -50,6 +50,44 @@ await Promise.all([1, 2, 3].map(async x => x + 1)); `; exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 3`] = ` +"Incorrect +Options: {"checkThenables": true} + +declare function createPromiseLike(): PromiseLike; + +createPromiseLike(); +~~~~~~~~~~~~~~~~~~~~ Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the \`void\` operator. + +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +createMyThenable(); +~~~~~~~~~~~~~~~~~~~ Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the \`void\` operator. +" +`; + +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 4`] = ` +"Correct +Options: {"checkThenables": true} + +declare function createPromiseLike(): PromiseLike; + +await createPromiseLike(); + +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +await createMyThenable(); +" +`; + +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 5`] = ` "Options: { "ignoreVoid": true } async function returnsPromise() { @@ -61,7 +99,7 @@ void Promise.reject('value'); " `; -exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 4`] = ` +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 6`] = ` "Options: { "ignoreIIFE": true } await (async function () { @@ -74,7 +112,7 @@ await (async function () { " `; -exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 5`] = ` +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 7`] = ` "Incorrect Options: {"allowForKnownSafePromises":[{"from":"file","name":"SafePromise"},{"from":"lib","name":"PromiseLike"},{"from":"package","name":"Bar","package":"bar-lib"}]} @@ -91,7 +129,7 @@ returnsPromise(); " `; -exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 6`] = ` +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 8`] = ` "Correct Options: {"allowForKnownSafePromises":[{"from":"file","name":"SafePromise"},{"from":"lib","name":"PromiseLike"},{"from":"package","name":"Bar","package":"bar-lib"}]} diff --git a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts index e6087e512265..3d4ce04ff7e7 100644 --- a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts +++ b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts @@ -733,6 +733,41 @@ promise().then(() => {}); }, ], }, + { + code: ` +interface SafePromise extends Promise { + brand: 'safe'; +} + +declare const createSafePromise: () => SafePromise; +createSafePromise(); + `, + options: [ + { + allowForKnownSafePromises: [{ from: 'file', name: 'SafePromise' }], + checkThenables: true, + }, + ], + }, + { + code: ` +declare const createPromise: () => PromiseLike; +createPromise(); + `, + options: [{ checkThenables: false }], + }, + { + code: ` +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +createMyThenable(); + `, + options: [{ checkThenables: false }], + }, ], invalid: [ @@ -2181,5 +2216,26 @@ myTag\`abc\`; options: [{ allowForKnownSafePromises: [{ from: 'file', name: 'Foo' }] }], errors: [{ line: 4, messageId: 'floatingVoid' }], }, + { + code: ` +declare const createPromise: () => PromiseLike; +createPromise(); + `, + errors: [{ line: 3, messageId: 'floatingVoid' }], + options: [{ checkThenables: true }], + }, + { + code: ` +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +createMyThenable(); + `, + errors: [{ line: 8, messageId: 'floatingVoid' }], + options: [{ checkThenables: true }], + }, ], }); From dfd8c4112f99e8311293c081157e3733a7343fe7 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 4 Jun 2024 17:07:04 -0400 Subject: [PATCH 2/7] update docs snapshots --- packages/eslint-plugin/src/rules/no-floating-promises.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/no-floating-promises.ts b/packages/eslint-plugin/src/rules/no-floating-promises.ts index c7a0f847538e..67af21d6019d 100644 --- a/packages/eslint-plugin/src/rules/no-floating-promises.ts +++ b/packages/eslint-plugin/src/rules/no-floating-promises.ts @@ -369,7 +369,7 @@ export default createRule({ function isPromiseLike(node: ts.Node, type?: ts.Type): boolean { type ??= checker.getTypeAtLocation(node); - // The highest priority it so allow anything allowlisted + // The highest priority is to allow anything allowlisted if ( allowForKnownSafePromises.some(allowedType => typeMatchesSpecifier(type, allowedType, services.program), From 83b264ec7817fdd38673e0bb541f4cc32cb96294 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 4 Jun 2024 17:08:23 -0400 Subject: [PATCH 3/7] update snapshots --- .../tests/schema-snapshots/no-floating-promises.shot | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-floating-promises.shot b/packages/eslint-plugin/tests/schema-snapshots/no-floating-promises.shot index a708c7001d5b..fc91d05a7b53 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-floating-promises.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-floating-promises.shot @@ -102,6 +102,10 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos }, "type": "array" }, + "checkThenables": { + "description": "Whether to check all \\"Thenable\\"s, not just the built-in Promise type.", + "type": "boolean" + }, "ignoreIIFE": { "description": "Whether to ignore async IIFEs (Immediately Invoked Function Expressions).", "type": "boolean" @@ -137,6 +141,8 @@ type Options = [ } | string )[]; + /** Whether to check all "Thenable"s, not just the built-in Promise type. */ + checkThenables?: boolean; /** Whether to ignore async IIFEs (Immediately Invoked Function Expressions). */ ignoreIIFE?: boolean; /** Whether to ignore \`void\` expressions. */ From 8813a1042e37df87de5e41eb8ef24c39d1709bf6 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 4 Jun 2024 17:09:58 -0400 Subject: [PATCH 4/7] update snapshots --- packages/eslint-plugin/docs/rules/no-floating-promises.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-floating-promises.mdx b/packages/eslint-plugin/docs/rules/no-floating-promises.mdx index f5fd9476632d..4d41cfd16541 100644 --- a/packages/eslint-plugin/docs/rules/no-floating-promises.mdx +++ b/packages/eslint-plugin/docs/rules/no-floating-promises.mdx @@ -89,7 +89,7 @@ await Promise.all([1, 2, 3].map(async x => x + 1)); A ["Thenable"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables) value is an object which has a `then` method, such as a `Promise`. Other Thenables include TypeScript's built-in `PromiseLike` interface and any custom object that happens to have a `.then()`. -The `checkAllThenable` option triggers `no-floating-promises` to also consider all values that happen to be Thenable, not just Promises. +The `checkAllThenable` option triggers `no-floating-promises` to also consider all values that happen to be Thenables with `.then()` two parameters, not just Promises. This can be useful if your code works with older `Promise` polyfills instead of the native `Promise` class. From d66dbea2c6fe5028eb81e2d78752a31a53296de8 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Mon, 1 Jul 2024 07:02:20 -0400 Subject: [PATCH 5/7] invalid tests part one: still Promise --- .../tests/rules/no-floating-promises.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts index 3d4ce04ff7e7..3a8d4af757fe 100644 --- a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts +++ b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts @@ -2237,5 +2237,13 @@ createMyThenable(); errors: [{ line: 8, messageId: 'floatingVoid' }], options: [{ checkThenables: true }], }, + { + code: ` +declare const createPromise: () => Promise; +createPromise(); + `, + errors: [{ line: 3, messageId: 'floatingVoid' }], + options: [{ checkThenables: false }], + }, ], }); From 6078b07bd1ea53373d4eb1b008393b7b64988953 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Mon, 1 Jul 2024 07:47:33 -0400 Subject: [PATCH 6/7] isBuiltinSymbolLike --- .../src/rules/no-floating-promises.ts | 11 +++++----- .../tests/rules/no-floating-promises.test.ts | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-floating-promises.ts b/packages/eslint-plugin/src/rules/no-floating-promises.ts index 67af21d6019d..b49516184317 100644 --- a/packages/eslint-plugin/src/rules/no-floating-promises.ts +++ b/packages/eslint-plugin/src/rules/no-floating-promises.ts @@ -8,7 +8,7 @@ import { createRule, getOperatorPrecedence, getParserServices, - isSymbolFromDefaultLibrary, + isBuiltinSymbolLike, OperatorPrecedence, readonlynessOptionsDefaults, readonlynessOptionsSchema, @@ -379,10 +379,11 @@ export default createRule({ } // Otherwise, we always consider the built-in Promise to be Promise-like... - const symbol = type.getSymbol(); + const typeParts = tsutils.unionTypeParts(checker.getApparentType(type)); if ( - symbol?.name === 'Promise' && - isSymbolFromDefaultLibrary(services.program, symbol) + typeParts.some(typePart => + isBuiltinSymbolLike(services.program, typePart, 'Promise'), + ) ) { return true; } @@ -396,7 +397,7 @@ export default createRule({ // rejected/caught via a second parameter. Original source (MIT licensed): // // https://github.com/ajafff/tsutils/blob/49d0d31050b44b81e918eae4fbaf1dfe7b7286af/util/type.ts#L95-L125 - for (const ty of tsutils.unionTypeParts(checker.getApparentType(type))) { + for (const ty of typeParts) { const then = ty.getProperty('then'); if (then === undefined) { continue; diff --git a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts index 3a8d4af757fe..7077b27bc39a 100644 --- a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts +++ b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts @@ -2245,5 +2245,25 @@ createPromise(); errors: [{ line: 3, messageId: 'floatingVoid' }], options: [{ checkThenables: false }], }, + { + code: ` +class MyPromise extends Promise {} +declare const createMyPromise: () => MyPromise; +createMyPromise(); + `, + errors: [{ line: 4, messageId: 'floatingVoid' }], + options: [{ checkThenables: false }], + }, + { + code: ` +class MyPromise extends Promise { + additional: string; +} +declare const createMyPromise: () => MyPromise; +createMyPromise(); + `, + errors: [{ line: 6, messageId: 'floatingVoid' }], + options: [{ checkThenables: false }], + }, ], }); From dac381ce883b4f84f830a7aa83e3883d235d571e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Sat, 6 Jul 2024 15:09:39 -0400 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: Joshua Chen --- packages/eslint-plugin/docs/rules/no-floating-promises.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-floating-promises.mdx b/packages/eslint-plugin/docs/rules/no-floating-promises.mdx index 4d41cfd16541..f2cb066ceb2d 100644 --- a/packages/eslint-plugin/docs/rules/no-floating-promises.mdx +++ b/packages/eslint-plugin/docs/rules/no-floating-promises.mdx @@ -89,7 +89,7 @@ await Promise.all([1, 2, 3].map(async x => x + 1)); A ["Thenable"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables) value is an object which has a `then` method, such as a `Promise`. Other Thenables include TypeScript's built-in `PromiseLike` interface and any custom object that happens to have a `.then()`. -The `checkAllThenable` option triggers `no-floating-promises` to also consider all values that happen to be Thenables with `.then()` two parameters, not just Promises. +The `checkThenables` option triggers `no-floating-promises` to also consider all values that satisfy the Thenable shape (a `.then()` method that takes two callback parameters), not just Promises. This can be useful if your code works with older `Promise` polyfills instead of the native `Promise` class. @@ -129,7 +129,9 @@ await createMyThenable(); -> This option is enabled by default in v7 but will be turned off by default in v8. +:::info +This option is enabled by default in v7 but will be turned off by default in v8. +::: ### `ignoreVoid`