diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts new file mode 100644 index 00000000000..defb254fbf4 --- /dev/null +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts @@ -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}, + ]); +} diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 21c8d7112f8..00d35972081 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -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'; diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 74def80b8f3..fe6fe284b2b 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -1922,6 +1922,9 @@ { "name": "init_zoneless_scheduling" }, + { + "name": "init_zoneless_scheduling_impl" + }, { "name": "initializeDirectives" }, diff --git a/packages/core/test/change_detection_scheduler_spec.ts b/packages/core/test/change_detection_scheduler_spec.ts index 23a9141d952..08b1b49edb0 100644 --- a/packages/core/test/change_detection_scheduler_spec.ts +++ b/packages/core/test/change_detection_scheduler_spec.ts @@ -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 { - 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 {