Skip to content

Spurious NG0101 when ApplicationRef.tick() is called outside the Angular zone and an effect is flushed #70984

Description

@arturovt

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

core

Is this a regression?

No

Description

Calling ApplicationRef.tick() from outside the Angular zone reports a
spurious NG0101: ApplicationRef.tick is called recursively, even though the
app never calls tick() recursively. Running work outside the zone and
ticking manually is a normal performance pattern.

During that tick Angular re-enters the Angular zone by itself. Effects capture
Zone.current when created, so both ZoneAwareEffectScheduler.flush() and
runEffectsInView() run them via zone.run(...). Because the tick started
outside the zone, leaving that zone.run drops NgZone._nesting back to 0,
so checkStable emits onMicrotaskEmpty synchronously in the middle of the
running tick.

NgZoneChangeDetectionScheduler's handler only guards on
changeDetectionScheduler.runningTick, which is true only for ticks started
by ChangeDetectionSchedulerImpl. For an explicit ApplicationRef.tick() it
is false, so the handler calls _tick() again and tickImpl throws.
Compare ChangeDetectionSchedulerImpl.shouldScheduleTick(), which checks both
this.runningTick and this.appRef._runningTick.

The error is caught and routed to the ErrorHandler, so nothing actually
breaks: ViewTreeGlobal is set before the throw and rendering is unaffected.
It is pure noise. In production the message is stripped and the stack is
almost entirely zone.js frames, so it is untraceable. We have been getting
these in Rollbar from a large zone-based app and could not identify a culprit.

Please provide a link to a minimal reproduction of the bug

https://github.com/arturovt/ng0101-recursive-tick-repro

Please provide the exception or error you saw

ERROR RuntimeError: NG0101: ApplicationRef.tick is called recursively

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


Anything else?

The setTimeout in the reproduction is load-bearing. A click handler runs as
a task of the Angular zone, so _nesting stays above 0 and checkStable
never fires. Scheduling the timer outside the zone makes the callback run at
nesting 0, which is where a real app sits when it ticks from a rAF, an
unpatched timer or a third party callback.

Both effect paths reproduce: root effects via EffectScheduler.flush() and
view effects via runEffectsInView().

I have a fix ready and will open a PR: also check applicationRef._runningTick
in the onMicrotaskEmpty handler, and set ViewTreeGlobal instead of starting
a nested tick. That is exactly what the current code does immediately before it
throws, so rendering behaviour is unchanged.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimegemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions