Angular Zone seems to have problems with the new rxjs firstValueFrom leading to infinite dirty check loop

This issue has been tracked since 2022-09-19.

Which @angular/* package(s) are the source of the bug?

core, zone.js

Is this a regression?

Yes

Description

See reproduction:
https://stackblitz.com/edit/angular-hurts

Please provide a link to a minimal reproduction of the bug

https://stackblitz.com/edit/angular-hurts

Please provide the exception or error you saw

Infinite load. No error.

Please provide the environment you discovered this bug in (run ng version)

Angular CLI: 14.0.7
Node: 16.16.0
Package Manager: npm 8.11.0
OS: darwin arm64

Angular: 14.2.2
... animations, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1402.3
@angular-devkit/build-angular   14.2.3
@angular-devkit/core            14.2.3
@angular-devkit/schematics      14.0.7
@angular/cli                    14.0.7
@schematics/angular             14.0.7
rxjs                            7.5.6
typescript                      4.7.4

Anything else?

No response

alxhub wrote this answer on 2022-09-19

Angular is working as expected here, although I can definitely understand the confusion.

The binding in the template uses a function call to create the Observable being subscribed to:

<hello *ngIf="angularHurtsMe() | async as a">

This function returns a new Observable every time it's called.

angularHurtsMe(): Observable<string> {
  return from(firstValueFrom(of('AAAAAAHHHHHHHH!!!!!')));
}

My suspicion is that firstValueFrom internally does a setTimeout or Promise.resolve or some other operation which schedules a new round of change detection. Thus the order of events is:

  • CD runs and calls angularHurtsMe() as per the binding.
  • async subscribes to the Observable.
  • firstValueFrom somehow triggers a new round of CD.
  • CD runs and calls angularHurtsMe() again, creating a new Observable.
  • async pipe unsubscribes from the old Observable and subscribes to the new one.
  • firstValueFrom runs again, triggering another round of CD.
  • Repeat ad infinitum.

The problem here is the use of a function call in a binding which is not idempotent - a common anti-pattern in Angular. Most of the time this results in ExpressionChangedAfterItHasBeenChecked, but in this case the behavior of firstValueFrom seems to result in infinite change detection instead.

The solution is to only create the Observable once:

angularHurtsMeNoMore = from(firstValueFrom(of('AAAAAAHHHHHHHH!!!!!')));

Binding to this Observable with the async pipe works as expected and doesn't result in infinite CD.

CodeBast4rd wrote this answer on 2022-09-19

Before opening the Issue I checked the implementation of firstValueFrom:

https://github.com/ReactiveX/rxjs/blob/c45f9d2a288e59c9ca4dacf17a91939f26388303/src/internal/firstValueFrom.ts#L56

And the only action they take is creating a new Promise which should in my Opinion not trigger a new change detection round. As the newly created Promise is a child Promise or microZoneTask.

The code in production was more similar to;
`
originSubject = new BehaviorSubject('I am the Origin');

async originOfPain(): Promise {
const originString = await firstValueFrom(this.originSubject);
return originString;
}
`

see the previously linked adjusted example.

And in the async await context I expect the Promises to behave like a then chain. Which should as I understand zone.js not trigger a global change detection if a new Promise is created in the Promise.then context.

Or are new Promise() polluting the global Promise handling as setTimeouts?

JoostK wrote this answer on 2022-09-19

Promises always resolve in a new microtick, so lastValueFrom will not be able to produce its value in the same microtick and therefore a new change detection run is being scheduled once the promise eventually resolves.

CodeBast4rd wrote this answer on 2022-09-19

But why is the firstValueFrom not part of the Parent Promise chain? The Promise chain should be resolved in the same tick as an immediate Context Que.

See for example:
https://blog.insiderattack.net/promises-next-ticks-and-immediates-nodejs-event-loop-part-3-9226cbe7a6aa

Edit:
see: https://stackblitz.com/edit/js-promises-and-ticks?file=index.js

JoostK wrote this answer on 2022-09-19

That StackBlitz does not behave like NodeJS; running in a StackBlitz webcontainer does show the expected outcome:

image

firstValueFrom is executed during a change detection cycle, but the resulting Promise will not resolve until the event loop has processed the accompanying microtick. Consequently, change detection has to rerun once the resolved becomes available, but that's only in a new microtick.

CodeBast4rd wrote this answer on 2022-09-21

@JoostK thanks for the further explanation. I checked also with only using one promise and older zone.js. I forgot it was a common behaviour of zone.js with promises because it does no longer occur with observables.

Edit:
What I don't understand is how the changeDetection: ChangeDetectionStrategy.OnPush does not resolve the issue when the component should be only checked on new inputs.

More Details About Repo
Owner Name angular
Repo Name angular
Full Name angular/angular
Language TypeScript
Created Date 2014-09-18
Updated Date 2022-09-30
Star Count 84091
Watcher Count 3064
Fork Count 22233
Issue Count 1203

YOU MAY BE INTERESTED

Issue Title Created Date Updated Date