Skip to content

Commit 1268dc8

Browse files
feat(no-floating-observables)!: replace no-ignored-observable (#55)
BREAKING CHANGE: `no-ignored-observable` is removed; use `no-floating-observables` instead. - Enhance floating observable logic to handle more than just call expressions. - Now that `no-ignored-observable` is included in the `strict` config, enhancing it will be more widely useful. - Add `ignoreVoid` option. When `false`, this rule will reach past the `void` operator and check its argument. Defaults to `true` (i.e. `void` can be used to ignore floating observables). - Rename rule from `no-ignored-observable` to `no-floating-observables`. - As mentioned in #43 , we should take advantage of this project being a fork to clarify rule names, and typescript-eslint has an existing convention for promises that would be nice to imitate for observables: one rule covers floating, the other covers misused. - An alternative is keeping the old rule as-is and deprecating it. However, this forked project is not v1 yet, so we should take advantage of prerelease to make breaking changes early instead of putting them off for a later, more painful date.
1 parent 1a2e615 commit 1268dc8

File tree

10 files changed

+288
-124
lines changed

10 files changed

+288
-124
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ There are some breaking changes:
1717
- If you need to continue using this old format, use the original `eslint-plugin-rxjs` or a different fork.
1818
- The plugin namespace specified in the `recommended` config was changed from `rxjs` to `rxjs-x`.
1919
- e.g. In your ESLint config, `rxjs/no-subject-value` should be renamed to `rxjs-x/no-subject-value`.
20+
- The rule `rxjs/no-ignored-observable` is renamed to `rxjs-x/no-floating-observables`.
2021

2122
A complete description of all changes are documented in the [CHANGELOG](CHANGELOG.md) file.
2223

@@ -88,10 +89,10 @@ The package includes the following rules.
8889
| [no-explicit-generics](docs/rules/no-explicit-generics.md) | Disallow unnecessary explicit generic type arguments. | 🔒 | | | | |
8990
| [no-exposed-subjects](docs/rules/no-exposed-subjects.md) | Disallow public and protected subjects. | 🔒 | | | 💭 | |
9091
| [no-finnish](docs/rules/no-finnish.md) | Disallow Finnish notation. | | | | 💭 | |
92+
| [no-floating-observables](docs/rules/no-floating-observables.md) | Require Observables to be handled appropriately. | 🔒 | | | 💭 | |
9193
| [no-ignored-default-value](docs/rules/no-ignored-default-value.md) | Disallow using `firstValueFrom`, `lastValueFrom`, `first`, and `last` without specifying a default value. | 🔒 | | | 💭 | |
9294
| [no-ignored-error](docs/rules/no-ignored-error.md) | Disallow calling `subscribe` without specifying an error handler. | 🔒 | | | 💭 | |
9395
| [no-ignored-notifier](docs/rules/no-ignored-notifier.md) | Disallow observables not composed from the `repeatWhen` or `retryWhen` notifier. | ✅ 🔒 | | | 💭 | |
94-
| [no-ignored-observable](docs/rules/no-ignored-observable.md) | Disallow ignoring observables returned by functions. | 🔒 | | | 💭 | |
9596
| [no-ignored-replay-buffer](docs/rules/no-ignored-replay-buffer.md) | Disallow using `ReplaySubject`, `publishReplay` or `shareReplay` without specifying the buffer size. | ✅ 🔒 | | | | |
9697
| [no-ignored-subscribe](docs/rules/no-ignored-subscribe.md) | Disallow calling `subscribe` without specifying arguments. | | | | 💭 | |
9798
| [no-ignored-subscription](docs/rules/no-ignored-subscription.md) | Disallow ignoring the subscription returned by `subscribe`. | | | | 💭 | |
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Require Observables to be handled appropriately (`rxjs-x/no-floating-observables`)
2+
3+
💼 This rule is enabled in the 🔒 `strict` config.
4+
5+
💭 This rule requires [type information](https://typescript-eslint.io/linting/typed-linting).
6+
7+
<!-- end auto-generated rule header -->
8+
9+
A "floating" observable is one that is created without any code set up to handle any errors it might emit.
10+
Like a floating Promise, floating observables can cause several issues, such as ignored errors, unhandled cold observables, and more.
11+
12+
This rule is like [no-floating-promises](https://typescript-eslint.io/rules/no-floating-promises/) but for Observables.
13+
This rule will report observable-valued statements that are not treated in one of the following ways:
14+
15+
- Calling its `.subscribe()`
16+
- `return`ing it
17+
- Wrapping it in `lastValueFrom` or `firstValueFrom` and `await`ing it
18+
- [`void`ing it](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/void)
19+
20+
> [!TIP]
21+
> `no-floating-observables` only detects apparently unhandled observable _statements_.
22+
23+
## Rule details
24+
25+
Examples of **incorrect** code for this rule:
26+
27+
```ts
28+
import { of } from "rxjs";
29+
of(42, 54);
30+
```
31+
32+
Examples of **correct** code for this rule:
33+
34+
```ts
35+
import { of } from "rxjs";
36+
const answers = of(42, 54);
37+
```
38+
39+
## Options
40+
41+
<!-- begin auto-generated rule options list -->
42+
43+
| Name | Description | Type | Default |
44+
| :----------- | :------------------------------------ | :------ | :------ |
45+
| `ignoreVoid` | Whether to ignore `void` expressions. | Boolean | `true` |
46+
47+
<!-- end auto-generated rule options list -->

docs/rules/no-ignored-observable.md

Lines changed: 0 additions & 25 deletions
This file was deleted.

src/configs/strict.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ export const createStrictConfig = (
1111
'rxjs-x/no-create': 'error',
1212
'rxjs-x/no-explicit-generics': 'error',
1313
'rxjs-x/no-exposed-subjects': 'error',
14+
'rxjs-x/no-floating-observables': 'error',
1415
'rxjs-x/no-ignored-default-value': 'error',
1516
'rxjs-x/no-ignored-error': 'error',
1617
'rxjs-x/no-ignored-notifier': 'error',
17-
'rxjs-x/no-ignored-observable': 'error',
1818
'rxjs-x/no-ignored-replay-buffer': 'error',
1919
'rxjs-x/no-ignored-takewhile-value': 'error',
2020
'rxjs-x/no-implicit-any-catch': ['error', {

src/etc/is.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export function isCallExpression(node: TSESTree.Node): node is TSESTree.CallExpr
3434
return node.type === AST_NODE_TYPES.CallExpression;
3535
}
3636

37+
export function isChainExpression(node: TSESTree.Node): node is TSESTree.ChainExpression {
38+
return node.type === AST_NODE_TYPES.ChainExpression;
39+
}
40+
3741
export function isExportNamedDeclaration(
3842
node: TSESTree.Node,
3943
): node is TSESTree.ExportNamedDeclaration {
@@ -124,6 +128,10 @@ export function isTSTypeReference(node: TSESTree.Node): node is TSESTree.TSTypeR
124128
return node.type === AST_NODE_TYPES.TSTypeReference;
125129
}
126130

131+
export function isUnaryExpression(node: TSESTree.Node): node is TSESTree.UnaryExpression {
132+
return node.type === AST_NODE_TYPES.UnaryExpression;
133+
}
134+
127135
export function isVariableDeclarator(
128136
node: TSESTree.Node,
129137
): node is TSESTree.VariableDeclarator {

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import { noCyclicActionRule } from './rules/no-cyclic-action';
1616
import { noExplicitGenericsRule } from './rules/no-explicit-generics';
1717
import { noExposedSubjectsRule } from './rules/no-exposed-subjects';
1818
import { noFinnishRule } from './rules/no-finnish';
19+
import { noFloatingObservablesRule } from './rules/no-floating-observables';
1920
import { noIgnoredDefaultValueRule } from './rules/no-ignored-default-value';
2021
import { noIgnoredErrorRule } from './rules/no-ignored-error';
2122
import { noIgnoredNotifierRule } from './rules/no-ignored-notifier';
22-
import { noIgnoredObservableRule } from './rules/no-ignored-observable';
2323
import { noIgnoredReplayBufferRule } from './rules/no-ignored-replay-buffer';
2424
import { noIgnoredSubscribeRule } from './rules/no-ignored-subscribe';
2525
import { noIgnoredSubscriptionRule } from './rules/no-ignored-subscription';
@@ -63,10 +63,10 @@ const plugin = {
6363
'no-explicit-generics': noExplicitGenericsRule,
6464
'no-exposed-subjects': noExposedSubjectsRule,
6565
'no-finnish': noFinnishRule,
66+
'no-floating-observables': noFloatingObservablesRule,
6667
'no-ignored-default-value': noIgnoredDefaultValueRule,
6768
'no-ignored-error': noIgnoredErrorRule,
6869
'no-ignored-notifier': noIgnoredNotifierRule,
69-
'no-ignored-observable': noIgnoredObservableRule,
7070
'no-ignored-replay-buffer': noIgnoredReplayBufferRule,
7171
'no-ignored-subscribe': noIgnoredSubscribeRule,
7272
'no-ignored-subscription': noIgnoredSubscriptionRule,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { TSESTree as es } from '@typescript-eslint/utils';
2+
import { getTypeServices, isCallExpression, isChainExpression, isUnaryExpression } from '../etc';
3+
import { ruleCreator } from '../utils';
4+
5+
const defaultOptions: readonly {
6+
ignoreVoid?: boolean;
7+
}[] = [];
8+
9+
const messageBase
10+
= 'Observables must be subscribed to, returned, converted to a promise and awaited, '
11+
+ 'or be explicitly marked as ignored with the `void` operator.';
12+
13+
const messageBaseNoVoid
14+
= 'Observables must be subscribed to, returned, or converted to a promise and awaited.';
15+
16+
export const noFloatingObservablesRule = ruleCreator({
17+
defaultOptions,
18+
meta: {
19+
docs: {
20+
description: 'Require Observables to be handled appropriately.',
21+
recommended: 'strict',
22+
requiresTypeChecking: true,
23+
},
24+
messages: {
25+
forbidden: messageBase,
26+
forbiddenNoVoid: messageBaseNoVoid,
27+
},
28+
schema: [
29+
{
30+
properties: {
31+
ignoreVoid: { type: 'boolean', default: true, description: 'Whether to ignore `void` expressions.' },
32+
},
33+
type: 'object',
34+
},
35+
],
36+
type: 'problem',
37+
},
38+
name: 'no-floating-observables',
39+
create: (context) => {
40+
const { couldBeObservable } = getTypeServices(context);
41+
const [config = {}] = context.options;
42+
const { ignoreVoid = true } = config;
43+
44+
function checkNode(node: es.CallExpression) {
45+
if (couldBeObservable(node)) {
46+
context.report({
47+
messageId: ignoreVoid ? 'forbidden' : 'forbiddenNoVoid',
48+
node,
49+
});
50+
}
51+
}
52+
53+
function checkVoid(node: es.UnaryExpression) {
54+
if (ignoreVoid) return;
55+
if (node.operator !== 'void') return;
56+
57+
let expression = node.argument;
58+
if (isChainExpression(expression)) {
59+
expression = expression.expression;
60+
}
61+
62+
if (!isCallExpression(expression)) return;
63+
checkNode(expression);
64+
}
65+
66+
return {
67+
'ExpressionStatement > CallExpression': (node: es.CallExpression) => {
68+
checkNode(node);
69+
},
70+
'ExpressionStatement > UnaryExpression': (node: es.UnaryExpression) => {
71+
checkVoid(node);
72+
},
73+
'ExpressionStatement > ChainExpression': (node: es.ChainExpression) => {
74+
if (!isCallExpression(node.expression)) return;
75+
76+
checkNode(node.expression);
77+
},
78+
'ExpressionStatement > SequenceExpression': (node: es.SequenceExpression) => {
79+
node.expressions.forEach(expression => {
80+
if (isCallExpression(expression)) {
81+
checkNode(expression);
82+
}
83+
});
84+
},
85+
'ExpressionStatement > ArrayExpression': (node: es.ArrayExpression) => {
86+
node.elements.forEach(expression => {
87+
if (!expression) return;
88+
if (isCallExpression(expression)) {
89+
checkNode(expression);
90+
} else if (isUnaryExpression(expression)) {
91+
checkVoid(expression);
92+
}
93+
});
94+
},
95+
};
96+
},
97+
});

src/rules/no-ignored-observable.ts

Lines changed: 0 additions & 34 deletions
This file was deleted.

0 commit comments

Comments
 (0)