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 ) ); }