Closed macjohnny closed 4 years ago
Thanks. This looks good to me. I'll have a closer look at the implementation a little later, but I have a few initial comments:
I'd like to change the rule's name to make it obvious that this is an Angular-specific rule.
This package has another Angular-specific rule - rxjs-prefer-async-pipe
- so I'm going to deprecate and rename that one to rxjs-prefer-angular-async-pipe
.
Could you rename this PR's rule to rxjs-prefer-angular-takeuntil
?
I think it would be better to match the components using a selector that looks for the Component
decorator, like this:
ClassDeclaration:has(Decorator[expression.expression.name='Component'])
@cartant thanks for your comments
Could you rename this PR's rule to rxjs-prefer-angular-takeuntil?
sure, I will do that tomorrow. How about rxjs-prefer-angular-takeuntil-before-subscribe
?
I think it would be better to match the components using a selector that looks for the Component decorator, like this:
ClassDeclaration:has(Decorator[expression.expression.name='Component'])
great, this is what I was looking for, since I copied the idea from
Regarding you Gitter question, I don't think this rule should do anything other than ensure there is a non-nested takeUntil
in the operators passed to the pipe
call that returns the observable upon which subscribe
is called. I don't think it should look at the position of takeUntil
. It should, instead, leave that the other rule.
Regarding you Gitter question, I don't think this rule should do anything other than ensure there is a non-nested
takeUntil
in the operators passed to thepipe
call that returns the observable upon whichsubscribe
is called. I don't think it should look at the position oftakeUntil
. It should, instead, leave that the other rule.
@cartant am I right that your statement refers to the rxjs-no-unsafe-takeuntil
, not to the newly proposed rxjs-prefer-angular-takeuntil-before-subscribe
rule?
I think that rxjs-prefer-angular-takeuntil-before-subscribe
would make rxjs-no-unsafe-takeuntil
obsolete, as it ensures the use of a takeUntil
operator right before a subscribe, and in addition before implicitly subscribing operators without teardown-logic:
private operatorsRequiringPrecedingTakeuntil: string[] = [
"publish",
"publishBehavior",
"publishLast",
"publishReplay",
"shareReplay"
];
Moreover, it ensures the correct argument is used in the takeUntil()
operator and that this argument is also called with .next()
and .complete()
in an ngOnDestroy()
method.
This strict enforcement of the takeUntil
usage should help to avoid 99% of memory leaks in angular components due to missing unsubscribe logic.
It won't make it obsolete because this rule is Angular-only. It is also highly likely that Angular-specific rules will be removed when this package is moved into the RxJS repo. TBH, I think they should probably be elsewhere.
I've read through the code and I have some suggestions - mainly to do with the options - I'll write them up on the weekend. Right now, I'm too tired. Thanks for the work you've put into this. I'm sure some people will find it useful.
Hey 👋
Thanks for your contribution! This actually is a rule I tried using in an Angular project... but ended up dropping altogether in favour of thorough reviews :)
In my (little) experience, the caveat you mentioned runs a bit deeper:
http
calls aren't the sole inconvenience: off the top of my head, the Angular SnackBars and Dialogs also use a one-off Observable
(for Dialog response, and potential SnackBar action) that does get properly completed whenever the corresponding component gets destroyed.Additionally, you may already be using something like:
.pipe(
...,
take(1)
).subscribe(...)
... or first()
, or maybe other operators I can't recall right now, as well as your own custom operator, like some hypothetical takeUntilIrrelevant()
.
While the first point is somewhat moot, I think this rule would benefit from allowing the consumer to customise the list of operators that are required to be placed immediately before subscribe
.
@ccjmne thanks for your remarks
This actually is a rule I tried using in an Angular project... but ended up dropping altogether in favour of thorough reviews :)
I agree, a thorough code review is invaluable, but even there a missing ngOnDestroy
might not immediately be caught...
In my (little) experience, the caveat you mentioned runs a bit deeper:
- The
http
calls aren't the sole inconvenience: off the top of my head, the Angular SnackBars and Dialogs also use a one-offObservable
(for Dialog response, and potential SnackBar action) that does get properly completed whenever the corresponding component gets destroyed.
Absolutely, the caveat applies to any observable that is guaranteed to complete, but since not effort apart from copy-pasting (I usually have a code-snippet in my IDE for that), I think it is bearable.
Additionally, you may already be using something like:
.pipe( ..., take(1) ).subscribe(...)
... or
first()
, or maybe other operators I can't recall right now, as well as your own custom operator, like some hypotheticaltakeUntilIrrelevant()
.
even in this situation a takeUntil
is necessary. consider this example:
myFormControl = new FormControl();
this.myFormControl.valueChanges.pipe(take(1)).subscribe(...);
If the source observable never emits, the subscription remains. There is even an edge-case (although not a memory leak) with an observable that is guaranteed to complete:
this.httpClient.get('/some-path').subscribe(() => {
...
this.changeDetectorRef.detectChanges();
})
this will result in an error if the request takes long time and the component is destroyed e.g. due to navigation, since the view does not exist anymore.
While the first point is somewhat moot, I think this rule would benefit from allowing the consumer to customise the list of operators that are required to be placed immediately before
subscribe
.
what would be an example that would need customization?
Yes, I thought of the case where, with take(1)
, if the source Observable never emits, the subscription remains. But when you know you're consuming from a BehaviorSubject
, it just feels a bit bad to be instructed to do:
.pipe(
take(1),
takeUntil(this.destroyed$)
)
... merely because you couldn't tell your linter that you know what you're doing.
The edge case you mentioned is a good point, though!
About examples that need customisation... I actually wrote a little helper function that takes an OnDestroy
and manages its own "destroy$" Subject so I don't have to deal with it.
I export it as takeUntilDestroy
and use it like:
.pipe(
...,
takeUntilDestroy(this)
).subscribe(...)
@ccjmne
Yes, I thought of the case where, with take(1), if the source Observable never emits, the subscription remains. But when you know you're consuming from a BehaviorSubject, it just feels a bit bad to be instructed to do:
.pipe( take(1), takeUntil(this.destroyed$) )
Omitting takeUntil(this.destroyed$)
in this situation can still be dangerous if someone changes the source to e.g. a ReplaySubject
and the consumers don't notice that...
About examples that need customisation... I actually wrote a little helper function that takes an OnDestroy and manages its own "destroy$" Subject so I don't have to deal with it.
I agree there are ways to reduce the code to write by hand, but on the other hand it also makes it harder to track whether ngOnDestroy
is implemented and correctly calls destroy$.next()
and destroy.complete()
.
While testing this rule with one of our projects, we discovered a component that was missing the ngOnDestroy
method.
@macjohnny You seem to not have quite understood my last point, which was to have ngOnDestroy
automatically decorated (conceptually) so as to always have it properly implemented, and not having to drag along my own destroy$
property in each and every component.
I feel you are very defensive in this whole exchange, while I am actually quite supportive and just meant to say that:
I think this rule would benefit from allowing the consumer to customise the list of operators that are required to be placed immediately before
subscribe
.
@ccjmne I did get the idea of the alternative ngOnDestroy implementation / usage, but I think this would be a custom case that would require a different rule to ensure that there is no memory leak.
I didn't mean to be defensive, I just tried to evaluate whether and why the strict enforcement of takeUntil
can help to avoid memory leaks.
This rule is helpful in most Angular projects to avoid memory leaks.
As long as you stick to the standard pattern using takeUntil(this.destroy$)
and calling this.destroy$.next(); this.destroy$.complete();
in ngOnDstroy()
this rule should work.
@ccjmne I've seen a project where people wrap the entire observable into an unsubscribe method:
this.unsubscribeOnDestroy(this.observable).subscribe()
. In this case a custom list of operators will not help. I think this rule should simply cover the standard pattern because it's impossible to know what patterns people might have come up with to handle subscriptions in their projects.
I agree with the suggestion in the comment above: I think the scope of this rule should be as tight as possible. I've been thinking about it and I would also prefer it if it didn't require configuration.
My thoughts:
ngOnDestroy
method and if found, inferred the subject name from the method's implementation.subscribe
calls and should effect failures for any that don't include a non-nested takeUntil
operator that's passed the name that was inferred from the ngOnDestroy
implementation.I would prefer the rule implementation to not ignore subscriptions that include take(1)
or first
, etc. TBH, those don't really do the same thing as takeUntil
as there is no cancellation upon destruction.
@cartant thanks for your suggestions, I updated the rule accordingly
I agree with the suggestion in the comment above: I think the scope of this rule should be as tight as possible. I've been thinking about it and I would also prefer it if it didn't require configuration.
I removed the configuration options
My thoughts:
- I'd prefer it if the rule started out be looking for the
ngOnDestroy
method and if found, inferred the subject name from the method's implementation.- It could then look for
subscribe
calls and should effect failures for any that don't include a non-nestedtakeUntil
operator that's passed the name that was inferred from thengOnDestroy
implementation.
I changed it to the following behavior: when looking for .subscribe()
calls and asserting that the last preceding operator is takeUntil
, its argument (e.g. this.destroy$
) is asserted to be a property access expression, and the property name is added to a list of used destroy subject names.
Each name in that list is then checked to be invoked with .next()
and .complete()
in the ngOnDestroy
method.
I would prefer this rule to be kept as simple as possible. I would prefer it to not check the order of the operators within a pipe
call. There is another rule that can be used to ensure that takeUntil
is used in a safe position. I would prefer this rule to simply check that takeUntil
is a non-nested operator in observable chains to which subscribe
calls are made.
I think that is reasonable. IMO, it makes little sense to have the logic duplicated across multiple rules.
To me, this rule's responsibilities are to ensure that takeUntil
is used wherever an explicit subscribe
call is made and that the subjects are notified in ngOnDestroy
.
@macjohnny Thanks for the work that you put into this. I merged your work into a separate branch, made some changes and then merged it into master
- they are your commits, so you are now a contributor. Thanks again.
The rule is now named rxjs-prefer-angular-takeuntil
and it makes no attempt to check the order in which takeUntil
operators are used. Instead, it should be used in conjunction with rxjs-no-unsafe-takeuntil
.
The implementation itself is now a little more general and handles some additional scenarios.
This package now contains three (mutually-exclusive) Angular-related rules:
rxjs-prefer-angular-async-pipe
which encourages using Component
composition to avoid explicit subscribe
calls within components; andrxjs-prefer-angular-composition
which enforces Subscription
composition for explicit subscribe
calls within components.When/if this package is merged into the official RxJS I might split these Angular-specific rules into a separate package. FWIW, the ESLint versions are going to be in eslint-plugin-rxjs-angular
- not in eslint-plugin-rxjs
.
4.27.0
has been published and it includes this rule.
Description
This rule tries to avoid memory leaks in angular components when calling
.subscribe()
without properly unsubscribing by enforcing the application of thetakeUntil
operator before the.subscribe()
as well as before certain operators (publish
,publishBehavior
,publishLast
,publishReplay
,shareReplay
) and ensuring the component implements thengOnDestroy
method invokingthis.destroy$.next()
andthis.destroy$.complete()
.Examples
This should trigger an error:
while this should be fine:
This pattern is also used e.g. here https://github.com/angular/components/blob/8da64f4db8d3241024721df8ca3a36b2f47875f5/src/material/select/select.ts#L526-L528
Alternatives
Although a reactive programming approach using the
| async
pipe without ever calling.subscribe()
in the component is more feasible, some code-bases still use the.subscribe()
approach.Related
https://github.com/cartant/rxjs-tslint-rules/issues/91
Caveats
This rule is also triggered by observables that are guaranteed to complete, e.g.
However, adding
takeUntil()
in this situation although it would not be needed only adds little overhead, while strictly avoiding memory leaks, so this should be a small trade-off.TODO
takeUntil
before operators likeshareReplay(1)
this.destroy$.next()
andthis.destroy$.complete()