Skip to content

Commit 7206aef

Browse files
feat(zones)!: deliver Observable read values as microtasks (#3833)
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
1 parent 5f7f422 commit 7206aef

2 files changed

Lines changed: 53 additions & 5 deletions

File tree

‎src/zones.spec.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { EnvironmentInjector, NgZone, runInInjectionContext } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { ɵAngularFireSchedulers, ɵzoneWrap } from '@angular/fire';
4+
import { Observable, Subject, asapScheduler } from 'rxjs';
5+
import { AsapScheduler } from 'rxjs/internal/scheduler/AsapScheduler';
6+
import { take } from 'rxjs/operators';
7+
8+
describe('ɵzoneWrap delivery timing', () => {
9+
10+
let source: Subject<number>;
11+
let stream: Observable<number>;
12+
let ngZone: NgZone;
13+
14+
beforeEach(() => {
15+
source = new Subject<number>();
16+
ngZone = TestBed.inject(NgZone);
17+
const read = ɵzoneWrap(() => source.asObservable(), true);
18+
stream = runInInjectionContext(TestBed.inject(EnvironmentInjector), () => ngZone.run(() => read()));
19+
});
20+
21+
it('delivers an Observable value asynchronously, inside the Angular zone, before a zero-delay timer registered when the source emitted', async () => {
22+
const delivery = await new Promise<{ delivered: boolean; afterEmit: boolean; inAngularZone: boolean }>(resolve => {
23+
let emitted = false;
24+
stream.pipe(take(1)).subscribe(() => resolve({ delivered: true, afterEmit: emitted, inAngularZone: NgZone.isInAngularZone() }));
25+
ngZone.runOutsideAngular(() => {
26+
// The timer races the delivery, so a dropped or late value fails here, not on the spec timeout.
27+
setTimeout(() => resolve({ delivered: false, afterEmit: emitted, inAngularZone: false }), 0);
28+
source.next(1);
29+
emitted = true;
30+
});
31+
});
32+
expect(delivery.delivered).withContext('delivered before the zero-delay timer').toBe(true);
33+
expect(delivery.afterEmit).withContext('never synchronous').toBe(true);
34+
expect(delivery.inAngularZone).withContext('delivered inside the Angular zone').toBe(true);
35+
});
36+
37+
it('delivers through its own asap scheduler, not the one rxjs shares with every other caller', () => {
38+
const delegate = Reflect.get(TestBed.inject(ɵAngularFireSchedulers).insideAngular, 'delegate');
39+
expect(delegate).toBeInstanceOf(AsapScheduler);
40+
expect(delegate).not.toBe(asapScheduler);
41+
});
42+
43+
});

‎src/zones.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import {
1414
SchedulerAction,
1515
SchedulerLike,
1616
Subscription,
17-
asyncScheduler,
1817
queueScheduler
1918
} from 'rxjs';
19+
import { AsapAction } from 'rxjs/internal/scheduler/AsapAction';
20+
import { AsapScheduler } from 'rxjs/internal/scheduler/AsapScheduler';
2021
import { observeOn, subscribeOn } from 'rxjs/operators';
2122

2223
declare const Zone: {current: unknown} | undefined;
2324

25+
const microtaskScheduler = new AsapScheduler(AsapAction);
26+
2427
export enum LogLevel {
2528
"SILENT" = 0,
2629
"WARN" = 1,
@@ -57,9 +60,11 @@ export class ɵZoneScheduler implements SchedulerLike {
5760
}
5861
};
5962

60-
// Scheduling itself needs to be run in zone to ensure setInterval calls for async scheduling are done
61-
// inside the correct zone. This scheduler needs to schedule asynchronously always to ensure that
62-
// firebase emissions are never synchronous. Specifying a delay causes issues with the queueScheduler delegate.
63+
/* Delivery re-enters the target zone through runGuarded above, whatever zone the delegate's
64+
* microtask or timer runs in. The delegate must always schedule asynchronously, so firebase
65+
* emissions are never synchronous, and with the asap delegate a positive delay falls back to a
66+
* timer, so callers must keep the delay at zero to keep microtask delivery. Specifying a delay
67+
* causes issues with the queueScheduler delegate. */
6368
return this.delegate.schedule(workInZone, delay, state);
6469
}
6570
}
@@ -79,7 +84,7 @@ export class ɵAngularFireSchedulers {
7984
this.insideAngular = ngZone.run(
8085
() => new ɵZoneScheduler(
8186
typeof Zone === 'undefined' ? undefined : Zone.current,
82-
asyncScheduler
87+
microtaskScheduler
8388
)
8489
);
8590
}

0 commit comments

Comments
 (0)