Skip to content

feat(zones)!: remove callback pending tasks right after the callback - #3837

Merged
armando-navarro merged 4 commits into
angular:mainfrom
armando-navarro:a95-callback-task-microtask
Nov 6, 2026
Merged

armando-navarro merged 4 commits into
angular:mainfrom
armando-navarro:a95-callback-task-microtask

Conversation

@armando-navarro

Copy link
Copy Markdown
Collaborator

Checklist

Description

Removes the pending task a callback-taking wrapper registers in a finally right after the callback body, and right after the unsubscribe call, instead of through setTimeout(taskDone, 0) in src/zones.ts. Deletes the 2021 TODO next to the wrapping call that asked for this.

  • The finally runs whether the callback returns or throws, so a throwing callback does not leave its task behind, which the timer also guaranteed.
  • This is the same way Angular's own pendingUntilEvent works: it forwards the value and then removes its task synchronously.
  • Nothing else in the wrapper changes: the task is still registered at call time, the callback still runs inside the Angular zone and the injection context, and the Observable and Promise branches are untouched.

What this grants, measured on Angular 22.2 zone.js and zoneless apps, before and after:

  • The task is gone before any setTimeout(fn, 0) callback registered at delivery, in every run: a synthetic callback in Chrome and Firefox, the unsubscribe path, and the real onSnapshot against the Firestore emulator. With Realtime Database on cached data, where the SDK invokes the callback inside the registering call, the task is removed inside that call.
  • A zone.js server render reports stable about a millisecond sooner after a single callback read's first value. In a zoneless render the difference is within noise: the callback's own signal write already schedules Angular's tick, which bounds stability either way.
  • In a zone.js app, a callback delivered inside the Angular zone no longer causes a third change-detection pass.

What does not change:

  • The DOM updates on delivery, and the number of change-detection passes for a callback delivered from outside Angular.
  • No task is left behind in the four callback cases probed for fix: stop twelve wrapped calls from holding the app unstable #3827 against the emulators.
  • When whenStable() resolves under fake timers. Angular's change-detection tick is itself a timer, so a test that advances fake timers before awaiting whenStable() still needs the advance, before and after.

What upgrading apps may notice:

  • whenStable() and server rendering follow a callback read's first value one setTimeout(fn, 0) delay sooner.
  • A test that inspects pending tasks directly sees the wrapper's task gone as soon as the callback has run, without a timer advance.

Verification:

  • Four new specs in src/zones.spec.ts, each observing the remover function PendingTasks.add() returns. All four fail on main and pass here:
    • the task is removed after the callback body and before any microtask the body queued, before a setTimeout(fn, 0) callback registered just before delivery
    • the same when the SDK invokes the callback inside the registering call
    • the same on unsubscribe
    • a throwing callback does not leave its task behind
  • npm run lint, npm run test:node, npm run test:chrome-headless and npm run test:firefox-headless pass on the branch.

A wrapped callback read (onSnapshot, onAuthStateChanged, onValue and the
other callback-taking functions wrapped with a pending task) removed
Angular's pending task through setTimeout(taskDone, 0), so the task
outlived the first callback by one setTimeout(fn, 0) delay, and the
same on unsubscribe, next to a 2021 TODO asking for a microtask instead.
The task is now removed right after the callback body and right after
the unsubscribe call, the same way Angular's own pendingUntilEvent
removes its task, inside a finally so a callback that throws does not
leave its task behind.

The task now goes away before any setTimeout(fn, 0) callback registered
when the callback was delivered, and a zone.js server render reports
stable about a millisecond sooner after a single callback read's first
value.

BREAKING CHANGE: a callback read's pending task is removed right after
its first callback runs, and right after unsubscribe, instead of one
setTimeout(fn, 0) delay later. whenStable() and server rendering can
resolve up to that much sooner after the first value. Angular's own
change-detection tick still runs on a timer, so a test that advances
fake timers before awaiting whenStable() still needs the advance.
@armando-navarro armando-navarro added breaking change Upgrading apps must change something. Call it out in the release notes bump: major Size of the change: breaking, ships only in a major release comp: ssr Server-side rendering, hydration, @angular/ssr interop. comp: zones Change detection / zone.js / zoneless. type: feature New capability or enhancement. labels Nov 5, 2026
@armando-navarro armando-navarro added this to the 22.0.0-rc.1 milestone Nov 5, 2026
armando-navarro added a commit to armando-navarro/angularfire that referenced this pull request Nov 5, 2026
angular#3837 removes a callback read's pending task right after the callback
instead of one setTimeout(fn, 0) delay later. As a breaking change it
needs a section in the upgrade guide: what moves sooner, and which
tests notice.

Refs angular#3836

@tyler-reitz tyler-reitz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for one spec.

The unsubscribe spec passes even when the task is never released. Deleting taskDone() from the unsubscribe finally and rebuilding leaves karma at 142/142, because the 500ms fallback in observeRelease() resolves to the same all-false value that spec expects. The other three assert afterCallbackBody: true, which the fallback can't produce. Having the remover resolve with released: true and the fallback with released: false would separate the two.

Non-blocking: this conflicts with #3829 in src/zones.ts and src/zones.spec.ts. When resolving, I'd put #3829's injector.destroyed early return inside the new try, so a handler that runs after teardown still releases its task.

Otherwise checked: all four new specs fail against main's zones.ts, and the branch builds with no generated-file drift and passes 142/142 locally.

…rotask

angular#3829's destroyed-injector early return now sits inside the try, so the
finally removes the pending task on that path too. The argument loop is
main's, without the TODO this branch deleted. angular#3829's spec comment about
a 0 ms timer now gives the reason the per-spec counter still holds.

# Conflicts:
#	src/zones.spec.ts
#	src/zones.ts
The unsubscribe spec expected all four recorded fields false, which is what
the 500 ms fallback records when the remover is never called, so it
passed with the unsubscribe removal deleted. Each result now carries
released, true from the remover and false from the fallback.

A new spec removes the task for a handler that fires after its child
environment injector is destroyed. It fails if the destroyed-injector
early return is moved outside the try.
@armando-navarro

Copy link
Copy Markdown
Collaborator Author

Thanks for catching that, @tyler-reitz. Both points are in.

Unsubscribe spec

  • Each recorded result now carries released, true from the remover and false from the 500 ms fallback, as you suggested.
  • With taskDone() deleted from the unsubscribe finally, the unsubscribe spec now fails (Expected $.released = false to equal true.).

Merge with #3829

  • The injector.destroyed early return sits inside the try, so a handler that runs after teardown still removes its task.
  • A new spec covers it with a child environment injector, destroyed before the handler fires. With the early return moved outside the try, it fails in both change-detection modes.
  • An app hits this only when an environment injector is destroyed while the app keeps running, such as route injectors under withAutoCleanupInjectors().
  • fix(core): wrap observer-object handlers like functions, and let a wrapped promise settle after teardown #3829's spec comment about a 0 ms timer is reworded to match the new removal.

@armando-navarro
armando-navarro merged commit 34d0d84 into angular:main Nov 6, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Upgrading apps must change something. Call it out in the release notes bump: major Size of the change: breaking, ships only in a major release comp: ssr Server-side rendering, hydration, @angular/ssr interop. comp: zones Change detection / zone.js / zoneless. type: feature New capability or enhancement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants