Pewarta Proxy

📋 Server Request & Proxy Logs

Loading active proxy settings...
Developed by Khairul Adha (adhexa.id)
Mengambil log server...

⚙️ Pengaturan Proxy Target

Skip to content

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
angular:mainfrom
armando-navarro:a52-observer-handlers
Open

armando-navarro wants to merge 2 commits into
angular:mainfrom
armando-navarro:a52-observer-handlers

Conversation

@armando-navarro

Copy link
Copy Markdown
Collaborator

Fixes #3828

  • ɵzoneWrap now treats an argument that is an object with a function-valued next, error, complete, onNext, onErr or onComplete member 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) and onTokenChanged (app-check) get blockUntilFirst: false, because wrapping their observers at true would hold the application unstable until a callback a normal load may never produce.
  • The promise branch resolves and rejects directly, dropping the runInInjectionContext and NgZone.run layers that never reached the caller and threw once the injector was destroyed.

Checklist

  • Issue number for this PR: ɵzoneWrap ignores a handler passed as an observer object, and holds a wrapped promise forever once its injector is destroyed #3828
  • Docs included?: no. One sentence in docs/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 the 22.0.0-rc.1 tag.
  • Test units included?: yes, src/zones.spec.ts, twelve cases run under provideZoneChangeDetection() and under provideZonelessChangeDetection().
  • Breaking change?: no
  • In a clean directory, yarn install, yarn test run successfully?: yes, with npm: npm ci, npm run build, npm test (which runs build:jasmine, test:node-esm and test:chrome-headless), plus npm run test:node and npm run lint.

Changes

Before this change the argument loop in ɵzoneWrap tested typeof _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:

  • Adds isObserverLike, true for a non-null object with a function under any of the six member names, and zoneWrapObserver, which returns a copy of the object with those members replaced by the same wrapper a function argument gets.
    • The copy keeps the caller's object untouched, so one observer can be reused across calls, and .bind(observer) keeps this for a class instance or an RxJS Subject.
  • Adds onConfigUpdate: { blockUntilFirst: false, logLevel: LogLevel.WARN } to the remote-config block of tools/build.ts.
    • Its handler runs only when a fetched template differs from the active one.
    • Adds the same entry for app-check's onTokenChanged, whose first callback a first visit may never request.
    • Both regenerated files are included.
    • fix: stop twelve wrapped calls from holding the app unstable #3827 adds the identical onTokenChanged entry and also touches the same remote-config hunks in tools/build.ts and src/remote-config/firebase.ts, so whichever of the two lands second rebases/merges those hunks.

The second commit:

  • Replaces ret.then((it) => runInInjectionContext(injector, () => run(() => resolve(it))), ...) with ret.then(resolve, reject).
    • The two layers never reached the caller's continuation, and their assertNotDestroyed check is what hung a promise settling after teardown (the issue has the details).
    • The pending task still opens on the call and closes on settlement.

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 call inject() inside them, exactly like the function form.
  • onTokenChanged with 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.
  • A wrapped promise that settles after its injector is destroyed now settles. Nothing else about the promise branch changes: the continuation's zone and the behavior of inject() inside it are the same as before.
  • The SDK receives a copy of the observer rather than the caller's object. None of the eleven SDK functions compares the object's identity: each reads or binds its members, or keeps the object it received and removes that same one on unsubscribe.

Verification

  • src/zones.spec.ts against 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.
  • Full karma suite in headless Chrome with the change: all 140 run specs pass.
  • Measured in Chrome on a zone.js Angular 22.2 app and a zoneless one with the built tarball installed, against the Firestore emulator, through the generated @angular/fire/firestore and @angular/fire/remote-config entry points: the object form now matches the function form on pending task, zone and inject() in both apps, and onConfigUpdate registers no task.
  • Same apps, the promise branch: getDoc registers 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 same inject() failure as before, and a wrapped call made inside a child EnvironmentInjector that is destroyed before the promise settles resolves.
  • npm run build, npm test (build:jasmine, test:node-esm, test:chrome-headless), npm run test:node and npm run lint pass on the final commit.

Backport

Non-breaking, and src/zones.ts is identical on 21.1.x. A cherry-pick PR against 21.1.x follows at a later date, after a run on an Angular 21 app.

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.
@armando-navarro armando-navarro added backport: 21.1.x Cherry-pick onto the 21.1.x branch for a 21.1 minor release bump: patch Size of the change: a fix or docs change that reaches users comp: app-check App Check (src/app-check). comp: core FirebaseApp DI core (src/app). comp: firestore Cloud Firestore (src/firestore). comp: remote-config Remote Config (src/remote-config). comp: zones Change detection / zone.js / zoneless. type: bug Defect: expected behavior doesn't happen. labels Oct 30, 2026

@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.

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 use fix(core):, which is what the squash should carry. Worth retitling before merge.
  • src/zones.ts adds /* */ comments among the existing // ones. The content is worth keeping; only the style differs.
  • isObserverLike reads members with Reflect.get, so it also matches a next, error or complete inherited 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.

@armando-navarro armando-navarro changed the title fix: wrap observer-object handlers like functions, and let a wrapped promise settle after teardown fix(core): wrap observer-object handlers like functions, and let a wrapped promise settle after teardown Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport: 21.1.x Cherry-pick onto the 21.1.x branch for a 21.1 minor release bump: patch Size of the change: a fix or docs change that reaches users comp: app-check App Check (src/app-check). comp: core FirebaseApp DI core (src/app). comp: firestore Cloud Firestore (src/firestore). comp: remote-config Remote Config (src/remote-config). comp: zones Change detection / zone.js / zoneless. type: bug Defect: expected behavior doesn't happen.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ɵzoneWrap ignores a handler passed as an observer object, and holds a wrapped promise forever once its injector is destroyed

2 participants