Repository navigation
fix(core): wrap observer-object handlers like functions, and let a wrapped promise settle after teardown - #3829
Open
armando-navarro wants to merge 2 commits into
Open
armando-navarro wants to merge 2 commits into
armando-navarro wants to merge 2 commits into
Conversation
Firebase accepts a subscription handler either as a function or as an object with next, error and complete members (data-connect spells them onNext, onErr, onComplete). The zone wrapper only recognized the function, so an observer object reached the SDK untouched: no pending task held server rendering, inject() threw inside the handler, and in a zone.js app the handler ran outside the Angular zone, where the wrapper itself had moved the call. The object is now replaced by a copy whose function members are wrapped the same way, with this bound to the caller's object. onConfigUpdate takes only the object form and its handler fires only on a remote template change, so it joins the blockUntilFirst: false list along with onTokenChanged, whose first callback a first visit may never request. Both would otherwise hold the application unstable forever once their observers are wrapped.
When a wrapped call returns a Promise, the wrapper resolved its own promise through runInInjectionContext and NgZone.run. Neither reaches the caller: a .then callback runs as a later microtask after the injection context is torn down, and zone.js runs it in the zone where .then was attached, not where resolve was called. The only effect the two layers had was on teardown. runInInjectionContext throws once its injector is destroyed, so a Firebase promise settling after ApplicationRef.destroy() or a TestBed reset threw before resolve ran and the caller's promise never settled. The pending task still opens on the call and closes on settlement.
tyler-reitz
approved these changes
Oct 30, 2026
tyler-reitz
left a comment
Collaborator
There was a problem hiding this comment.
Approving. A fresh build regenerates both files byte-identical to the PR, Chrome headless passes 140/140, and rebuilding with main's src/zones.ts fails exactly the 14 zones.spec.ts cases the body names.
Non-blocking nits:
- The PR title is
fix:but both commits usefix(core):, which is what the squash should carry. Worth retitling before merge. src/zones.tsadds/* */comments among the existing//ones. The content is worth keeping; only the style differs.isObserverLikereads members withReflect.get, so it also matches anext,errororcompleteinherited through the prototype, and any wrapped call's object argument that has one gets copied. I don't know of a Firebase argument type like that, but I haven't audited every wrapped signature, so this is a question rather than a finding.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3828
ɵzoneWrapnow treats an argument that is an object with a function-valuednext,error,complete,onNext,onErroronCompletemember the way it treats a function argument: a copy of the object is handed to Firebase with each of those members wrapped, bound to the caller's object.onConfigUpdate(remote-config) andonTokenChanged(app-check) getblockUntilFirst: false, because wrapping their observers attruewould hold the application unstable until a callback a normal load may never produce.runInInjectionContextandNgZone.runlayers that never reached the caller and threw once the injector was destroyed.Checklist
ɵzoneWrapignores a handler passed as an observer object, and holds a wrapped promise forever once its injector is destroyed #3828docs/zones.md, saying the re-entry holds for a handler passed as an object too, goes in the single rc.1 docs PR, to be opened before the22.0.0-rc.1tag.src/zones.spec.ts, twelve cases run underprovideZoneChangeDetection()and underprovideZonelessChangeDetection().yarn install,yarn testrun successfully?: yes, with npm:npm ci,npm run build,npm test(which runsbuild:jasmine,test:node-esmandtest:chrome-headless), plusnpm run test:nodeandnpm run lint.Changes
Before this change the argument loop in
ɵzoneWraptestedtypeof _arguments[i] === 'function'and nothing else, so an observer object reached the SDK untouched. The issue lists the eleven affected functions and what each form lost.The first commit:
isObserverLike, true for a non-null object with a function under any of the six member names, andzoneWrapObserver, which returns a copy of the object with those members replaced by the same wrapper a function argument gets..bind(observer)keepsthisfor a class instance or an RxJSSubject.onConfigUpdate: { blockUntilFirst: false, logLevel: LogLevel.WARN }to the remote-config block oftools/build.ts.onTokenChanged, whose first callback a first visit may never request.onTokenChangedentry and also touches the same remote-config hunks intools/build.tsandsrc/remote-config/firebase.ts, so whichever of the two lands second rebases/merges those hunks.The second commit:
ret.then((it) => runInInjectionContext(injector, () => run(() => resolve(it))), ...)withret.then(resolve, reject).assertNotDestroyedcheck is what hung a promise settling after teardown (the issue has the details).What callers can notice
onSnapshot(ref, { next })and the other object-form calls now hold server rendering until the first value, run their members inside the Angular zone, and can callinject()inside them, exactly like the function form.onTokenChangedwith a function handler no longer holds the application unstable while waiting for a token a first visit may never request.onConfigUpdate, which never held it (its only form was never wrapped), does not start to now that its observer is.inject()inside it are the same as before.Verification
src/zones.spec.tsagainst the unmodified build: 14 of 24 fail, the observer-object, copy,onNext, throwing-handler and promise cases, while the ten others, the function-form cases among them, pass. With the change: 24 of 24 pass.@angular/fire/firestoreand@angular/fire/remote-configentry points: the object form now matches the function form on pending task, zone andinject()in both apps, andonConfigUpdateregisters no task.getDocregisters and releases its task and resolves with the document, a rejecting wrapped call releases its task and the rejection reaches the caller, the caller's continuation sees the same zone and the sameinject()failure as before, and a wrapped call made inside a childEnvironmentInjectorthat is destroyed before the promise settles resolves.npm run build,npm test(build:jasmine,test:node-esm,test:chrome-headless),npm run test:nodeandnpm run lintpass on the final commit.Backport
Non-breaking, and
src/zones.tsis identical on21.1.x. A cherry-pick PR against21.1.xfollows at a later date, after a run on an Angular 21 app.