Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8ebbae8

Browse files
atscottAndrewKushnir
authored andcommitted
feat(core): Add rxjs operator prevent app stability until an event (#56533)
This commit adds an operator to help rxjs observables important for rendering keep the application unstable (and prevent serialization) until there is an event (observable emits, completes, or errors, or the subscription is unsubscribed). This helps with SSR for zoneless and also helps for when operations are intentionally executed outside the Angular Zone but are still important for SSR (i.e. angularfire and the zoneWrap helper hacks). PR Close #56533
1 parent 888657a commit 8ebbae8

File tree

5 files changed

+318
-1
lines changed

5 files changed

+318
-1
lines changed

‎goldens/public-api/core/rxjs-interop/index.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export function outputFromObservable<T>(observable: Observable<T>, opts?: Output
2323
// @public
2424
export function outputToObservable<T>(ref: OutputRef<T>): Observable<T>;
2525

26+
// @public
27+
export function pendingUntilEvent<T>(injector?: Injector): MonoTypeOperatorFunction<T>;
28+
2629
// @public
2730
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T>;
2831

‎packages/core/rxjs-interop/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export {
1515
toObservableMicrotask as ɵtoObservableMicrotask,
1616
} from './to_observable';
1717
export {toSignal, ToSignalOptions} from './to_signal';
18+
export {pendingUntilEvent} from './pending_until_event';
1819
export {RxResourceOptions, rxResource} from './rx_resource';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {assertInInjectionContext, PendingTasks, inject, Injector} from '@angular/core';
10+
import {MonoTypeOperatorFunction, Observable} from 'rxjs';
11+
12+
/**
13+
* Operator which makes the application unstable until the observable emits, complets, errors, or is unsubscribed.
14+
*
15+
* Use this operator in observables whose subscriptions are important for rendering and should be included in SSR serialization.
16+
*
17+
* @param injector The `Injector` to use during creation. If this is not provided, the current injection context will be used instead (via `inject`).
18+
*
19+
* @experimental
20+
*/
21+
export function pendingUntilEvent<T>(injector?: Injector): MonoTypeOperatorFunction<T> {
22+
if (injector === undefined) {
23+
assertInInjectionContext(pendingUntilEvent);
24+
injector = inject(Injector);
25+
}
26+
const taskService = injector.get(PendingTasks);
27+
28+
return (sourceObservable) => {
29+
return new Observable<T>((originalSubscriber) => {
30+
// create a new task on subscription
31+
const removeTask = taskService.add();
32+
33+
let cleanedUp = false;
34+
function cleanupTask() {
35+
if (cleanedUp) {
36+
return;
37+
}
38+
39+
removeTask();
40+
cleanedUp = true;
41+
}
42+
43+
const innerSubscription = sourceObservable.subscribe({
44+
next: (v) => {
45+
originalSubscriber.next(v);
46+
cleanupTask();
47+
},
48+
complete: () => {
49+
originalSubscriber.complete();
50+
cleanupTask();
51+
},
52+
error: (e) => {
53+
originalSubscriber.error(e);
54+
cleanupTask();
55+
},
56+
});
57+
innerSubscription.add(() => {
58+
originalSubscriber.unsubscribe();
59+
cleanupTask();
60+
});
61+
return innerSubscription;
62+
});
63+
};
64+
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {EnvironmentInjector, ɵPendingTasks as PendingTasks, ApplicationRef} from '@angular/core';
10+
import {
11+
BehaviorSubject,
12+
EMPTY,
13+
Subject,
14+
catchError,
15+
delay,
16+
config,
17+
finalize,
18+
firstValueFrom,
19+
interval,
20+
of,
21+
} from 'rxjs';
22+
23+
import {pendingUntilEvent} from '@angular/core/rxjs-interop';
24+
import {TestBed} from '@angular/core/testing';
25+
26+
describe('pendingUntilEvent', () => {
27+
let taskService: PendingTasks;
28+
let injector: EnvironmentInjector;
29+
let appRef: ApplicationRef;
30+
beforeEach(() => {
31+
taskService = TestBed.inject(PendingTasks);
32+
injector = TestBed.inject(EnvironmentInjector);
33+
appRef = TestBed.inject(ApplicationRef);
34+
});
35+
36+
it('should not block stability until subscription', async () => {
37+
const originalSource = new BehaviorSubject(0);
38+
const delayedSource = originalSource.pipe(delay(5), pendingUntilEvent(injector));
39+
expect(taskService.hasPendingTasks.value).toEqual(false);
40+
41+
const emitPromise = firstValueFrom(delayedSource);
42+
expect(taskService.hasPendingTasks.value).toEqual(true);
43+
44+
await expectAsync(emitPromise).toBeResolvedTo(0);
45+
await expectAsync(appRef.whenStable()).toBeResolved();
46+
});
47+
48+
it('runs the subscription body before stability', async () => {
49+
const source = of(1).pipe(pendingUntilEvent(injector));
50+
51+
// stable before subscription
52+
expect(taskService.hasPendingTasks.value).toEqual(false);
53+
source.subscribe(() => {
54+
// unstable within synchronous subscription body
55+
expect(taskService.hasPendingTasks.value).toBe(true);
56+
});
57+
// stable after above synchronous subscription execution
58+
await expectAsync(appRef.whenStable()).toBeResolved();
59+
});
60+
61+
it('only blocks stability until first emit', async () => {
62+
const intervalSource = interval(5).pipe(pendingUntilEvent(injector));
63+
expect(taskService.hasPendingTasks.value).toEqual(false);
64+
65+
await new Promise<void>(async (resolve) => {
66+
const subscription = intervalSource.subscribe(async (v) => {
67+
if (v === 0) {
68+
expect(taskService.hasPendingTasks.value).toBe(true);
69+
} else {
70+
await expectAsync(appRef.whenStable()).toBeResolved();
71+
}
72+
if (v === 3) {
73+
subscription.unsubscribe();
74+
resolve();
75+
}
76+
});
77+
expect(taskService.hasPendingTasks.value).toBe(true);
78+
});
79+
});
80+
81+
it('should unblock stability on complete (but no emit)', async () => {
82+
const sub = new Subject();
83+
sub.asObservable().pipe(pendingUntilEvent(injector)).subscribe();
84+
expect(taskService.hasPendingTasks.value).toBe(true);
85+
sub.complete();
86+
await expectAsync(appRef.whenStable()).toBeResolved();
87+
});
88+
89+
it('should unblock stability on unsubscribe before emit', async () => {
90+
const sub = new Subject();
91+
const subscription = sub.asObservable().pipe(pendingUntilEvent(injector)).subscribe();
92+
expect(taskService.hasPendingTasks.value).toBe(true);
93+
subscription.unsubscribe();
94+
await expectAsync(appRef.whenStable()).toBeResolved();
95+
});
96+
97+
// Note that we cannot execute `finalize` operators that appear _after_ ours before
98+
// removing the pending task. We need to register the finalize operation on the subscription
99+
// as soon as the operator executes. A `finalize` operator later on in the stream will
100+
// be appear later in the finalizers list. These finalizers are both registered and executed
101+
// serially. We cannot execute our finalizer after other finalizers in the pipeline.
102+
it('should execute user finalize body before stability (as long as it appears first)', async () => {
103+
const sub = new Subject();
104+
let finalizeExecuted = false;
105+
const subscription = sub
106+
.asObservable()
107+
.pipe(
108+
finalize(() => {
109+
finalizeExecuted = true;
110+
expect(taskService.hasPendingTasks.value).toBe(true);
111+
}),
112+
pendingUntilEvent(injector),
113+
)
114+
.subscribe();
115+
expect(taskService.hasPendingTasks.value).toBe(true);
116+
subscription.unsubscribe();
117+
await expectAsync(appRef.whenStable()).toBeResolved();
118+
expect(finalizeExecuted).toBe(true);
119+
});
120+
121+
it('should not throw if application is destroyed before emit', async () => {
122+
const sub = new Subject<void>();
123+
sub.asObservable().pipe(pendingUntilEvent(injector)).subscribe();
124+
expect(taskService.hasPendingTasks.value).toBe(true);
125+
TestBed.resetTestingModule();
126+
await expectAsync(appRef.whenStable()).toBeResolved();
127+
sub.next();
128+
await expectAsync(appRef.whenStable()).toBeResolved();
129+
});
130+
131+
it('should unblock stability on error before emit', async () => {
132+
const sub = new Subject<void>();
133+
sub
134+
.asObservable()
135+
.pipe(
136+
pendingUntilEvent(injector),
137+
catchError(() => EMPTY),
138+
)
139+
.subscribe();
140+
expect(taskService.hasPendingTasks.value).toBe(true);
141+
sub.error(new Error('error in pipe'));
142+
await expectAsync(appRef.whenStable()).toBeResolved();
143+
sub.next();
144+
await expectAsync(appRef.whenStable()).toBeResolved();
145+
});
146+
147+
it('should unblock stability on error in subscription', async () => {
148+
function nextUncaughtError() {
149+
return new Promise((resolve) => {
150+
config.onUnhandledError = (e) => {
151+
config.onUnhandledError = null;
152+
resolve(e);
153+
};
154+
});
155+
}
156+
const sub = new Subject<void>();
157+
sub
158+
.asObservable()
159+
.pipe(pendingUntilEvent(injector))
160+
.subscribe({
161+
next: () => {
162+
throw new Error('oh noes');
163+
},
164+
});
165+
expect(taskService.hasPendingTasks.value).toBe(true);
166+
const errorPromise = nextUncaughtError();
167+
sub.next();
168+
await expectAsync(errorPromise).toBeResolved();
169+
await expectAsync(appRef.whenStable()).toBeResolved();
170+
171+
const errorPromise2 = nextUncaughtError();
172+
sub.next();
173+
await expectAsync(appRef.whenStable()).toBeResolved();
174+
await expectAsync(errorPromise2).toBeResolved();
175+
});
176+
177+
it('finalize and complete are delivered correctly', () => {
178+
const sub = new Subject<void>();
179+
let log: string[] = [];
180+
const obs1 = sub.asObservable().pipe(
181+
pendingUntilEvent(injector),
182+
finalize(() => {
183+
log.push('finalize');
184+
}),
185+
);
186+
187+
// complete after subscription
188+
obs1.subscribe({
189+
complete: () => {
190+
log.push('complete');
191+
},
192+
});
193+
sub.complete();
194+
expect(log).toEqual(['complete', 'finalize']);
195+
196+
// already completed before subscription
197+
log.length = 0;
198+
obs1.subscribe({
199+
complete: () => {
200+
log.push('complete');
201+
},
202+
});
203+
expect(log).toEqual(['complete', 'finalize']);
204+
205+
log.length = 0;
206+
new Subject()
207+
.asObservable()
208+
.pipe(
209+
pendingUntilEvent(injector),
210+
finalize(() => {
211+
log.push('finalize');
212+
}),
213+
)
214+
.subscribe({
215+
complete: () => {
216+
log.push('complete');
217+
},
218+
})
219+
.unsubscribe();
220+
expect(log).toEqual(['finalize']);
221+
});
222+
223+
it('should block stability for each new subscriber', async () => {
224+
const sub = new Subject<void>();
225+
const observable = sub.asObservable().pipe(delay(5), pendingUntilEvent(injector));
226+
227+
observable.subscribe();
228+
expect(taskService.hasPendingTasks.value).toBe(true);
229+
sub.next();
230+
observable.subscribe();
231+
// first subscription unblocks
232+
await new Promise((r) => setTimeout(r, 5));
233+
// still pending because the other subscribed after the emit
234+
expect(taskService.hasPendingTasks.value).toBe(true);
235+
236+
sub.next();
237+
await new Promise((r) => setTimeout(r, 3));
238+
observable.subscribe();
239+
sub.next();
240+
// second subscription unblocks
241+
await new Promise((r) => setTimeout(r, 2));
242+
// still pending because third subscription delay not finished
243+
expect(taskService.hasPendingTasks.value).toBe(true);
244+
245+
// finishes third subscription
246+
await new Promise((r) => setTimeout(r, 3));
247+
await expectAsync(appRef.whenStable()).toBeResolved();
248+
});
249+
});

‎packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
219219
}
220220

221221
private shouldScheduleTick(force: boolean): boolean {
222-
if (this.disableScheduling && !force) {
222+
if ((this.disableScheduling && !force) || this.appRef.destroyed) {
223223
return false;
224224
}
225225
// already scheduled or running

0 commit comments

Comments
 (0)