Repository navigation
ɵzoneWrap ignores a handler passed as an observer object, and holds a wrapped promise forever once its injector is destroyed #3828
Description
Version info
Angular: 22.2
Firebase: 12.19.0
AngularFire: 22.0.0-rc.0, and main
Description
Two defects in ɵzoneWrap (src/zones.ts), the function the generated @angular/fire/* wrappers go through.
1. A handler passed as an object is not wrapped. Firebase accepts a subscription handler either as a function, onSnapshot(ref, snapshot => ...), or as an observer object, onSnapshot(ref, { next: snapshot => ... }). The wrapper rewrites an argument only when typeof is 'function', so the object form reaches the SDK untouched:
- No pending task is registered, so server rendering does not wait for the first value.
inject()throws inside the handler, because nothing re-enters the injection context.- In a zone.js app the handler runs outside the Angular zone, because the wrapper calls Firebase inside
runOutsideAngularand then never brings the object's members back in. The unwrapped Firebase call made from in-zone code keeps its handler in the zone, so the wrapper makes the object form worse than not wrapping at all.
Eleven wrapped functions accept the object form: onAuthStateChanged, onIdTokenChanged, onTokenChanged, onSnapshot, onSnapshotResume, onSnapshotsInSync, onMessage, onRegistered, onUnregistered, onConfigUpdate (which accepts only the object form) and data-connect's subscribe (whose object uses onNext, onErr and onComplete). The three messaging functions are generated with blockUntilFirst: false, so the pending task point does not apply to them.
2. A wrapped promise never settles after its injector is destroyed.
- When a wrapped call returns a Promise, the wrapper resolves its own promise through
runInInjectionContext(injector, () => run(() => resolve(value))). - Neither layer reaches the caller: a
.thencallback runs as a later microtask after the injection context is torn down, and zone.js runs it in the zone where.thenwas attached, not whereresolvewas called. - The one thing the layers do is throw:
runInInjectionContextchecksassertNotDestroyed, so when the Firebase promise settles afterApplicationRef.destroy()or aTestBedreset, the callback throwsNG0205beforeresolveruns and the caller's promise never settles.
How to reproduce these conditions
On a zone.js Angular 22.2 app and a zoneless one, against the Firestore emulator, call the real functions through ɵzoneWrap the way a constructor does, inside the Angular zone and an injection context, and record inside the handler whether a pending task was registered, whether inject() works and whether NgZone.isInAngularZone() is true:
onSnapshot(ref, { next, error })andonSnapshotsInSync(firestore, { next, error }): no task in either app,inject()throws in both, and in the zone.js app the handler runs outside the Angular zone. A plain property written from the handler does not render there, in a component with default change detection.onSnapshot(ref, next), the function form: one task, released when the handler ran,inject()works, and in the zone.js app the handler runs inside the zone.- The unwrapped
onSnapshotwith either form of handler, called from the same place:inject()throws, but in the zone.js app the handler stays inside the zone.
For the promise defect, a spec: wrap a function that resolves after 20 ms with ɵzoneWrap(fn, true), call it inside TestBed.runInInjectionContext, call TestBed.resetTestingModule(), then await the returned promise. It never settles and the spec times out after 5 seconds.
Expected behavior
A handler passed as an observer object gets what a handler passed as a function gets: the pending task, the Angular zone and a working inject(). A wrapped promise settles whenever the Firebase promise settles.
Actual behavior
The object form gets none of the three, and a wrapped promise that settles after its injector is destroyed never settles.
Activity