refactor(core): Move change detection scheduler implementation to core (#53579)

This commit moves the implementation of the change detection scheduler
used for testing to angular/core along with a (private export) provider function.

Note: Naming of the provider function is absolutely not final (and not
public API). I would prefer one that did not mention "zones"
but the easiest thing for now is to have a "Zone" and "Zoneless" naming
scheme.

PR Close #53579
This commit is contained in:
Andrew Scott 2023-12-14 13:35:24 -08:00
parent 60dfcc209e
commit 5978b3d132
4 changed files with 107 additions and 65 deletions

View file

@ -0,0 +1,51 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ApplicationRef} from '../../application/application_ref';
import {EnvironmentProviders, inject, Injectable, makeEnvironmentProviders} from '../../di';
import {PendingTasks} from '../../pending_tasks';
import {NgZone, NoopNgZone} from '../../zone/ng_zone';
import {ChangeDetectionScheduler} from './zoneless_scheduling';
@Injectable({providedIn: 'root'})
class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
private appRef = inject(ApplicationRef);
private taskService = inject(PendingTasks);
private pendingRenderTaskId: number|null = null;
notify(): void {
if (this.pendingRenderTaskId !== null) return;
this.pendingRenderTaskId = this.taskService.add();
setTimeout(() => {
try {
if (!this.appRef.destroyed) {
this.appRef.tick();
}
} finally {
// If this is the last task, the service will synchronously emit a stable notification. If
// there is a subscriber that then acts in a way that tries to notify the scheduler again,
// we need to be able to respond to schedule a new change detection. Therefore, we should
// clear the task ID before removing it from the pending tasks (or the tasks service should
// not synchronously emit stable, similar to how Zone stableness only happens if it's still
// stable after a microtask).
const taskId = this.pendingRenderTaskId!;
this.pendingRenderTaskId = null;
this.taskService.remove(taskId);
}
});
}
}
export function provideZonelessChangeDetection(): EnvironmentProviders {
return makeEnvironmentProviders([
{provide: ChangeDetectionScheduler, useExisting: ChangeDetectionSchedulerImpl},
{provide: NgZone, useClass: NoopNgZone},
]);
}

View file

@ -13,6 +13,7 @@ export {internalCreateApplication as ɵinternalCreateApplication} from './applic
export {defaultIterableDiffers as ɵdefaultIterableDiffers, defaultKeyValueDiffers as ɵdefaultKeyValueDiffers} from './change_detection/change_detection';
export {getEnsureDirtyViewsAreAlwaysReachable as ɵgetEnsureDirtyViewsAreAlwaysReachable, setEnsureDirtyViewsAreAlwaysReachable as ɵsetEnsureDirtyViewsAreAlwaysReachable} from './change_detection/flags';
export {ChangeDetectionScheduler as ɵChangeDetectionScheduler} from './change_detection/scheduling/zoneless_scheduling';
export {provideZonelessChangeDetection as ɵprovideZonelessChangeDetection} from './change_detection/scheduling/zoneless_scheduling_impl';
export {Console as ɵConsole} from './console';
export {DeferBlockDetails as ɵDeferBlockDetails, getDeferBlocks as ɵgetDeferBlocks} from './defer/discovery';
export {renderDeferBlockState as ɵrenderDeferBlockState, triggerResourceLoading as ɵtriggerResourceLoading} from './defer/instructions';

View file

@ -1922,6 +1922,9 @@
{
"name": "init_zoneless_scheduling"
},
{
"name": "init_zoneless_scheduling_impl"
},
{
"name": "initializeDirectives"
},

View file

@ -7,42 +7,17 @@
*/
import {AsyncPipe} from '@angular/common';
import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
import {ApplicationRef, ChangeDetectorRef,ErrorHandler, Component, ComponentRef, createComponent, DebugElement, ElementRef, EnvironmentInjector, getDebugNode, inject, Injectable, Input, NgZone, PLATFORM_ID, signal, TemplateRef, Type, ViewChild, ViewContainerRef, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵNoopNgZone, ɵPendingTasks as PendingTasks} from '@angular/core';
import {ApplicationRef, ChangeDetectorRef, Component, ComponentRef, createComponent, DebugElement, ElementRef, EnvironmentInjector, ErrorHandler, getDebugNode, inject, Injectable, Input, NgZone, PLATFORM_ID, signal, TemplateRef, Type, ViewChild, ViewContainerRef, ɵprovideZonelessChangeDetection as provideZonelessChangeDetection} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {TestBed} from '@angular/core/testing';
import {BehaviorSubject, firstValueFrom} from 'rxjs';
import {filter} from 'rxjs/operators';
@Injectable({providedIn: 'root'})
class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
private appRef = inject(ApplicationRef);
private taskService = inject(PendingTasks);
private pendingRenderTaskId: number|null = null;
notify(): void {
if (this.pendingRenderTaskId !== null) return;
this.pendingRenderTaskId = this.taskService.add();
setTimeout(() => {
try {
if (!this.appRef.destroyed) {
this.appRef.tick();
}
} finally {
this.taskService.remove(this.pendingRenderTaskId!);
this.pendingRenderTaskId = null;
}
});
}
}
import {filter, take, tap} from 'rxjs/operators';
describe('Angular with NoopNgZone', () => {
function whenStable(): Promise<boolean> {
return firstValueFrom(TestBed.inject(EnvironmentInjector).get(ApplicationRef)
.isStable.pipe(filter(stable => stable)));
return firstValueFrom(TestBed.inject(EnvironmentInjector)
.get(ApplicationRef)
.isStable.pipe(filter(stable => stable)));
}
function isStable(): boolean {
@ -60,42 +35,33 @@ describe('Angular with NoopNgZone', () => {
}
describe('notifies scheduler', () => {
let scheduler: ChangeDetectionSchedulerImpl;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{provide: NgZone, useClass: ɵNoopNgZone},
{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID},
{provide: ChangeDetectionScheduler, useExisting: ChangeDetectionSchedulerImpl},
]
TestBed.configureTestingModule({providers: [provideZonelessChangeDetection()]});
});
it('contributes to application stableness', async () => {
const val = signal('initial');
@Component({template: '{{val()}}', standalone: true})
class TestComponent {
val = val;
}
const environmentInjector = TestBed.inject(EnvironmentInjector);
const component = createComponent(TestComponent, {environmentInjector});
const appRef = environmentInjector.get(ApplicationRef);
appRef.attachView(component.hostView);
expect(isStable()).toBeFalse();
// Cause another pending CD immediately after render and verify app has not stabilized
await whenStable().then(() => {
val.set('new');
});
scheduler = TestBed.inject(ChangeDetectionSchedulerImpl);
expect(isStable()).toBeFalse();
await whenStable();
expect(isStable()).toBeTrue();
});
it('contributes to application stableness', async () => {
const val = signal('initial');
@Component({template: '{{val()}}', standalone: true})
class TestComponent {
val = val;
}
const environmentInjector = TestBed.inject(EnvironmentInjector);
const component = createComponent(TestComponent, {environmentInjector});
const appRef = environmentInjector.get(ApplicationRef);
appRef.attachView(component.hostView);
expect(isStable()).toBeFalse();
// Cause another pending CD immediately after render and verify app has not stabilized
await whenStable().then(() => {
val.set('new');
});
expect(isStable()).toBeFalse();
await whenStable();
expect(isStable()).toBeTrue();
});
it('when signal updates', async () => {
const val = signal('initial');
@Component({template: '{{val()}}', standalone: true})
@ -323,6 +289,29 @@ describe('Angular with NoopNgZone', () => {
appRef.attachView(component.hostView);
expect(isStable()).toBe(true);
});
it('when a stable subscription synchronously causes another notification', async () => {
const val = signal('initial');
@Component({template: '{{val()}}', standalone: true})
class TestComponent {
val = val;
}
const component = await createAndAttachComponent(TestComponent);
expect(component.location.nativeElement.innerText).toEqual('initial');
val.set('new');
await TestBed.inject(ApplicationRef)
.isStable
.pipe(
filter(stable => stable),
take(1),
tap(() => val.set('newer')),
)
.toPromise();
await whenStable();
expect(component.location.nativeElement.innerText).toEqual('newer');
});
});
it('can recover when an error is re-thrown by the ErrorHandler', async () => {
@ -341,9 +330,7 @@ describe('Angular with NoopNgZone', () => {
}
TestBed.configureTestingModule({
providers: [
{provide: NgZone, useClass: ɵNoopNgZone},
{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID},
{provide: ChangeDetectionScheduler, useExisting: ChangeDetectionSchedulerImpl},
provideZonelessChangeDetection(),
{
provide: ErrorHandler, useClass: class extends ErrorHandler {
override handleError(error: any): void {