From a2188c1891eb7814f4738fe9543cdf96e4a5222e Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Fri, 30 Oct 2026 10:55:22 -0700 Subject: [PATCH] feat(zones)!: deliver Observable read values as microtasks The insideAngular scheduler was built over asyncScheduler, so every value from an Observable read reached its subscriber one setInterval turn after Firebase handed it over. A private AsapScheduler instance delivers at the next microtask checkpoint instead, still never synchronously. It is private because rxjs's shared asapScheduler cancels the rest of a batch when one action throws. Measured on Angular 22.2 apps, zone.js and zoneless: delivery moves ahead of a zero-delay timer in every run, change detection and ordering are unchanged, and a server render reports stable about a millisecond sooner. The compat AngularFireAuth streams move too. BREAKING CHANGE: Observable reads (collectionData, docData, authState, user, objectVal, listVal and the rest) now deliver at the next microtask checkpoint instead of after a zero-delay timer. A test that waits only for microtasks now sees a value whose source emits in a microtask. Code that orders its own setTimeout against a read's value sees the timer run after the value, not before. In a zoneless server render, a value arriving between stability and serialization reaches a hand-written TransferState entry but not the HTML. Fixes #3832 --- src/zones.spec.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/zones.ts | 15 ++++++++++----- 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 src/zones.spec.ts diff --git a/src/zones.spec.ts b/src/zones.spec.ts new file mode 100644 index 000000000..920598f21 --- /dev/null +++ b/src/zones.spec.ts @@ -0,0 +1,43 @@ +import { EnvironmentInjector, NgZone, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ɵAngularFireSchedulers, ɵzoneWrap } from '@angular/fire'; +import { Observable, Subject, asapScheduler } from 'rxjs'; +import { AsapScheduler } from 'rxjs/internal/scheduler/AsapScheduler'; +import { take } from 'rxjs/operators'; + +describe('ɵzoneWrap delivery timing', () => { + + let source: Subject; + let stream: Observable; + let ngZone: NgZone; + + beforeEach(() => { + source = new Subject(); + ngZone = TestBed.inject(NgZone); + const read = ɵzoneWrap(() => source.asObservable(), true); + stream = runInInjectionContext(TestBed.inject(EnvironmentInjector), () => ngZone.run(() => read())); + }); + + it('delivers an Observable value asynchronously, inside the Angular zone, before a zero-delay timer registered when the source emitted', async () => { + const delivery = await new Promise<{ delivered: boolean; afterEmit: boolean; inAngularZone: boolean }>(resolve => { + let emitted = false; + stream.pipe(take(1)).subscribe(() => resolve({ delivered: true, afterEmit: emitted, inAngularZone: NgZone.isInAngularZone() })); + ngZone.runOutsideAngular(() => { + // The timer races the delivery, so a dropped or late value fails here, not on the spec timeout. + setTimeout(() => resolve({ delivered: false, afterEmit: emitted, inAngularZone: false }), 0); + source.next(1); + emitted = true; + }); + }); + expect(delivery.delivered).withContext('delivered before the zero-delay timer').toBe(true); + expect(delivery.afterEmit).withContext('never synchronous').toBe(true); + expect(delivery.inAngularZone).withContext('delivered inside the Angular zone').toBe(true); + }); + + it('delivers through its own asap scheduler, not the one rxjs shares with every other caller', () => { + const delegate = Reflect.get(TestBed.inject(ɵAngularFireSchedulers).insideAngular, 'delegate'); + expect(delegate).toBeInstanceOf(AsapScheduler); + expect(delegate).not.toBe(asapScheduler); + }); + +}); diff --git a/src/zones.ts b/src/zones.ts index 62e97d2c7..c91b3f21b 100644 --- a/src/zones.ts +++ b/src/zones.ts @@ -14,13 +14,16 @@ import { SchedulerAction, SchedulerLike, Subscription, - asyncScheduler, queueScheduler } from 'rxjs'; +import { AsapAction } from 'rxjs/internal/scheduler/AsapAction'; +import { AsapScheduler } from 'rxjs/internal/scheduler/AsapScheduler'; import { observeOn, subscribeOn } from 'rxjs/operators'; declare const Zone: {current: unknown} | undefined; +const microtaskScheduler = new AsapScheduler(AsapAction); + export enum LogLevel { "SILENT" = 0, "WARN" = 1, @@ -57,9 +60,11 @@ export class ɵZoneScheduler implements SchedulerLike { } }; - // Scheduling itself needs to be run in zone to ensure setInterval calls for async scheduling are done - // inside the correct zone. This scheduler needs to schedule asynchronously always to ensure that - // firebase emissions are never synchronous. Specifying a delay causes issues with the queueScheduler delegate. + /* Delivery re-enters the target zone through runGuarded above, whatever zone the delegate's + * microtask or timer runs in. The delegate must always schedule asynchronously, so firebase + * emissions are never synchronous, and with the asap delegate a positive delay falls back to a + * timer, so callers must keep the delay at zero to keep microtask delivery. Specifying a delay + * causes issues with the queueScheduler delegate. */ return this.delegate.schedule(workInZone, delay, state); } } @@ -79,7 +84,7 @@ export class ɵAngularFireSchedulers { this.insideAngular = ngZone.run( () => new ɵZoneScheduler( typeof Zone === 'undefined' ? undefined : Zone.current, - asyncScheduler + microtaskScheduler ) ); }