diff --git a/packages/core/src/application/application_ref.ts b/packages/core/src/application/application_ref.ts index 74198a30a3cc21..cd2f2b4d3478ba 100644 --- a/packages/core/src/application/application_ref.ts +++ b/packages/core/src/application/application_ref.ts @@ -29,6 +29,7 @@ import {ComponentFactoryResolver} from '../linker/component_factory_resolver'; import {NgModuleFactory, NgModuleRef} from '../linker/ng_module_factory'; import {ViewRef} from '../linker/view_ref'; import {isComponentResourceResolutionQueueEmpty, resolveComponentResources} from '../metadata/resource_loading'; +import {AfterRenderEventManager} from '../render3/after_render_hooks'; import {assertNgModuleType} from '../render3/assert'; import {ComponentFactory as R3ComponentFactory} from '../render3/component_ref'; import {isStandalone} from '../render3/definition'; @@ -324,6 +325,7 @@ export class ApplicationRef { _views: InternalViewRef[] = []; private readonly internalErrorHandler = inject(INTERNAL_APPLICATION_ERROR_HANDLER); private readonly zoneIsStable = inject(ZONE_IS_STABLE_OBSERVABLE); + private readonly afterRenderEffectManager = inject(AfterRenderEventManager, {optional: true}); /** * Indicates whether this instance was destroyed. @@ -561,6 +563,17 @@ export class ApplicationRef { // Attention: Don't rethrow as it could cancel subscriptions to Observables! this.internalErrorHandler(e); } finally { + try { + const callbacksExecuted = this.afterRenderEffectManager?.execute(); + if ((typeof ngDevMode === 'undefined' || ngDevMode) && callbacksExecuted) { + for (let view of this._views) { + view.checkNoChanges(); + } + } + } catch (e) { + this.internalErrorHandler(e); + } + this._runningTick = false; } } diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 2ccbd00a93a039..65446c546e535f 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -30,8 +30,7 @@ export const enum RuntimeErrorCode { // Change Detection Errors EXPRESSION_CHANGED_AFTER_CHECKED = -100, RECURSIVE_APPLICATION_REF_TICK = 101, - RECURSIVE_APPLICATION_RENDER = 102, - INFINITE_CHANGE_DETECTION = 103, + INFINITE_CHANGE_DETECTION = 102, // Dependency Injection Errors CYCLIC_DI_DEPENDENCY = -200, diff --git a/packages/core/src/render3/after_render_hooks.ts b/packages/core/src/render3/after_render_hooks.ts index 58f45f8f6d93a0..06ff2dc3230862 100644 --- a/packages/core/src/render3/after_render_hooks.ts +++ b/packages/core/src/render3/after_render_hooks.ts @@ -349,13 +349,6 @@ class AfterRenderCallback { * Implements `afterRender` and `afterNextRender` callback handler logic. */ interface AfterRenderCallbackHandler { - /** - * Validate that it's safe for a render operation to begin, - * throwing if not. Not guaranteed to be called if a render - * operation is started before handler was registered. - */ - validateBegin(): void; - /** * Register a new callback. */ @@ -367,9 +360,9 @@ interface AfterRenderCallbackHandler { unregister(callback: AfterRenderCallback): void; /** - * Execute callbacks. + * Execute callbacks. Returns `true` if any callbacks were executed. */ - execute(): void; + execute(): boolean; /** * Perform any necessary cleanup. @@ -392,16 +385,6 @@ class AfterRenderCallbackHandlerImpl implements AfterRenderCallbackHandler { }; private deferredCallbacks = new Set(); - validateBegin(): void { - if (this.executingCallbacks) { - throw new RuntimeError( - RuntimeErrorCode.RECURSIVE_APPLICATION_RENDER, - ngDevMode && - 'A new render operation began before the previous operation ended. ' + - 'Did you trigger change detection from afterRender or afterNextRender?'); - } - } - register(callback: AfterRenderCallback): void { // If we're currently running callbacks, new callbacks should be deferred // until the next render operation. @@ -414,10 +397,12 @@ class AfterRenderCallbackHandlerImpl implements AfterRenderCallbackHandler { this.deferredCallbacks.delete(callback); } - execute(): void { + execute(): boolean { + let callbacksExecuted = false; this.executingCallbacks = true; for (const bucket of Object.values(this.buckets)) { for (const callback of bucket) { + callbacksExecuted = true; callback.invoke(); } } @@ -427,6 +412,7 @@ class AfterRenderCallbackHandlerImpl implements AfterRenderCallbackHandler { this.buckets[callback.phase].add(callback); } this.deferredCallbacks.clear(); + return callbacksExecuted; } destroy(): void { @@ -442,8 +428,6 @@ class AfterRenderCallbackHandlerImpl implements AfterRenderCallbackHandler { * Delegates to an optional `AfterRenderCallbackHandler` for implementation. */ export class AfterRenderEventManager { - private renderDepth = 0; - /* @internal */ handler: AfterRenderCallbackHandler|null = null; @@ -451,32 +435,19 @@ export class AfterRenderEventManager { internalCallbacks: VoidFunction[] = []; /** - * Mark the beginning of a render operation (i.e. CD cycle). - * Throws if called while executing callbacks. + * Executes callbacks. Returns `true` if any callbacks executed. */ - begin() { - this.handler?.validateBegin(); - this.renderDepth++; - } - - /** - * Mark the end of a render operation. Callbacks will be - * executed if there are no more pending operations. - */ - end() { - ngDevMode && assertGreaterThan(this.renderDepth, 0, 'renderDepth must be greater than 0'); - this.renderDepth--; - - if (this.renderDepth === 0) { - // Note: internal callbacks power `internalAfterNextRender`. Since internal callbacks - // are fairly trivial, they are kept separate so that `AfterRenderCallbackHandlerImpl` - // can still be tree-shaken unless used by the application. - for (const callback of this.internalCallbacks) { - callback(); - } - this.internalCallbacks.length = 0; - this.handler?.execute(); + execute(): boolean { + // Note: internal callbacks power `internalAfterNextRender`. Since internal callbacks + // are fairly trivial, they are kept separate so that `AfterRenderCallbackHandlerImpl` + // can still be tree-shaken unless used by the application. + const callbacks = [...this.internalCallbacks]; + this.internalCallbacks.length = 0; + for (const callback of callbacks) { + callback(); } + const handlerCallbacksExecuted = this.handler?.execute(); + return !!handlerCallbacksExecuted || callbacks.length > 0; } ngOnDestroy() { diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index 83afd626428e85..255f2008a48b0b 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -30,7 +30,6 @@ const MAXIMUM_REFRESH_RERUNS = 100; export function detectChangesInternal(lView: LView, notifyErrorHandler = true) { const environment = lView[ENVIRONMENT]; const rendererFactory = environment.rendererFactory; - const afterRenderEventManager = environment.afterRenderEventManager; // Check no changes mode is a dev only mode used to verify that bindings have not changed // since they were assigned. We do not want to invoke renderer factory functions in that mode @@ -39,7 +38,6 @@ export function detectChangesInternal(lView: LView, notifyErrorHandler = true) { if (!checkNoChangesMode) { rendererFactory.begin?.(); - afterRenderEventManager?.begin(); } try { @@ -56,9 +54,6 @@ export function detectChangesInternal(lView: LView, notifyErrorHandler = true) { // One final flush of the effects queue to catch any effects created in `ngAfterViewInit` or // other post-order hooks. environment.inlineEffectRunner?.flush(); - - // Invoke all callbacks registered via `after*Render`, if needed. - afterRenderEventManager?.end(); } } } diff --git a/packages/core/test/acceptance/after_render_hook_spec.ts b/packages/core/test/acceptance/after_render_hook_spec.ts index 2815d4e07f399b..098beef78ac732 100644 --- a/packages/core/test/acceptance/after_render_hook_spec.ts +++ b/packages/core/test/acceptance/after_render_hook_spec.ts @@ -7,9 +7,19 @@ */ import {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '@angular/common/src/platform_id'; -import {afterNextRender, afterRender, AfterRenderPhase, AfterRenderRef, ChangeDetectorRef, Component, computed, effect, ErrorHandler, inject, Injector, NgZone, PLATFORM_ID, ViewContainerRef, ɵinternalAfterNextRender as internalAfterNextRender} from '@angular/core'; +import {afterNextRender, afterRender, AfterRenderPhase, AfterRenderRef, ApplicationRef, ChangeDetectorRef, Component, computed, createComponent, effect, ErrorHandler, inject, Injector, NgZone, PLATFORM_ID, Type, ViewContainerRef, ɵinternalAfterNextRender as internalAfterNextRender} from '@angular/core'; +import {NoopNgZone} from '@angular/core/src/zone/ng_zone'; import {TestBed} from '@angular/core/testing'; +import {EnvironmentInjector} from '../../src/di'; + +function createAndAttachComponent(component: Type) { + const componentRef = + createComponent(component, {environmentInjector: TestBed.inject(EnvironmentInjector)}); + TestBed.inject(ApplicationRef).attachView(componentRef.hostView); + return componentRef; +} + describe('after render hooks', () => { describe('browser', () => { const COMMON_CONFIGURATION = { @@ -59,13 +69,13 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); // It hasn't run at all expect(log).toEqual([]); // Running change detection once - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(log).toEqual([ 'internalAfterNextRender #1', 'internalAfterNextRender #2', @@ -82,7 +92,7 @@ describe('after render hooks', () => { // Running change detection again log.length = 0; - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(log).toEqual([ 'afterRender (EarlyRead)', 'afterRender (Write)', @@ -122,8 +132,8 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); - const compInstance = fixture.componentInstance; + const component = createAndAttachComponent(Comp); + const compInstance = component.instance; const viewContainerRef = compInstance.viewContainerRef; const dynamicCompRef = viewContainerRef.createComponent(DynamicComp); @@ -133,34 +143,25 @@ describe('after render hooks', () => { // Running change detection at the dynamicCompRef level dynamicCompRef.changeDetectorRef.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(1); - expect(compInstance.afterRenderCount).toBe(1); + expect(dynamicCompRef.instance.afterRenderCount).toBe(0); + expect(compInstance.afterRenderCount).toBe(0); // Running change detection at the compInstance level compInstance.changeDetectorRef.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(2); - expect(compInstance.afterRenderCount).toBe(2); - - // Running change detection at the fixture level (first time) - fixture.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(3); - expect(compInstance.afterRenderCount).toBe(3); + expect(dynamicCompRef.instance.afterRenderCount).toBe(0); + expect(compInstance.afterRenderCount).toBe(0); - // Running change detection at the fixture level (second time) - fixture.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(4); - expect(compInstance.afterRenderCount).toBe(4); + // Running change detection at the Application level + TestBed.inject(ApplicationRef).tick(); + expect(dynamicCompRef.instance.afterRenderCount).toBe(1); + expect(compInstance.afterRenderCount).toBe(1); - // Running change detection at the fixture level (third time) - fixture.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(5); - expect(compInstance.afterRenderCount).toBe(5); // Running change detection after removing view. viewContainerRef.remove(); - fixture.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(5); - expect(compInstance.afterRenderCount).toBe(6); + TestBed.inject(ApplicationRef).tick(); + expect(dynamicCompRef.instance.afterRenderCount).toBe(1); + expect(compInstance.afterRenderCount).toBe(2); }); it('should run all hooks after outer change detection', () => { @@ -199,13 +200,42 @@ describe('after render hooks', () => { declarations: [ChildComp, ParentComp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(ParentComp); + createAndAttachComponent(ParentComp); expect(log).toEqual([]); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(log).toEqual(['pre-cd', 'post-cd', 'parent-comp', 'child-comp']); }); + + it('should run hooks once after tick even if there are multiple root views', () => { + let log: string[] = []; + + @Component({ + standalone: true, + template: ``, + }) + class MyComp { + constructor() { + afterRender(() => { + log.push('render'); + }); + } + } + + TestBed.configureTestingModule({ + // NgZone can make counting hard because it runs ApplicationRef.tick automatically. + providers: [{provide: NgZone, useClass: NoopNgZone}, ...COMMON_CONFIGURATION.providers], + }); + expect(log).toEqual([]); + const appRef = TestBed.inject(ApplicationRef); + appRef.attachView(TestBed.createComponent(MyComp).componentRef.hostView); + appRef.attachView(TestBed.createComponent(MyComp).componentRef.hostView); + appRef.attachView(TestBed.createComponent(MyComp).componentRef.hostView); + appRef.tick(); + expect(log.length).toEqual(3); + }); + it('should unsubscribe when calling destroy', () => { let hookRef: AfterRenderRef|null = null; let afterRenderCount = 0; @@ -223,50 +253,20 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); expect(afterRenderCount).toBe(0); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(afterRenderCount).toBe(1); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(afterRenderCount).toBe(2); hookRef!.destroy(); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(afterRenderCount).toBe(2); }); - it('should throw if called recursively', () => { - class RethrowErrorHandler extends ErrorHandler { - override handleError(error: any): void { - throw error; - } - } - - @Component({ - selector: 'comp', - providers: [{provide: ErrorHandler, useFactory: () => new RethrowErrorHandler()}] - }) - class Comp { - changeDetectorRef = inject(ChangeDetectorRef); - - constructor() { - afterRender(() => { - this.changeDetectorRef.detectChanges(); - }); - } - } - - TestBed.configureTestingModule({ - declarations: [Comp], - ...COMMON_CONFIGURATION, - }); - const fixture = TestBed.createComponent(Comp); - expect(() => fixture.detectChanges()) - .toThrowError(/A new render operation began before the previous operation ended./); - }); - it('should defer nested hooks to the next cycle', () => { let outerHookCount = 0; let innerHookCount = 0; @@ -289,24 +289,24 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); // It hasn't run at all expect(outerHookCount).toBe(0); expect(innerHookCount).toBe(0); // Running change detection (first time) - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(outerHookCount).toBe(1); expect(innerHookCount).toBe(0); // Running change detection (second time) - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(outerHookCount).toBe(2); expect(innerHookCount).toBe(1); // Running change detection (third time) - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(outerHookCount).toBe(3); expect(innerHookCount).toBe(2); }); @@ -327,10 +327,10 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); expect(zoneLog).toEqual([]); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(zoneLog).toEqual([false]); }); @@ -371,10 +371,10 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); expect(log).toEqual([]); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(log).toEqual(['pass 1', 'fail 1', 'pass 2', 'fail 2']); }); @@ -431,10 +431,10 @@ describe('after render hooks', () => { declarations: [Root, CompA, CompB], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Root); + createAndAttachComponent(Root); expect(log).toEqual([]); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(log).toEqual([ 'early-read-1', 'early-read-2', 'write-1', 'write-2', 'mixed-read-write-1', 'mixed-read-write-2', 'read-1', 'read-2' @@ -527,8 +527,8 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); - const compInstance = fixture.componentInstance; + const component = createAndAttachComponent(Comp); + const compInstance = component.instance; const viewContainerRef = compInstance.viewContainerRef; const dynamicCompRef = viewContainerRef.createComponent(DynamicComp); @@ -538,32 +538,22 @@ describe('after render hooks', () => { // Running change detection at the dynamicCompRef level dynamicCompRef.changeDetectorRef.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(1); - expect(compInstance.afterRenderCount).toBe(1); + expect(dynamicCompRef.instance.afterRenderCount).toBe(0); + expect(compInstance.afterRenderCount).toBe(0); // Running change detection at the compInstance level compInstance.changeDetectorRef.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(1); - expect(compInstance.afterRenderCount).toBe(1); - - // Running change detection at the fixture level (first time) - fixture.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(1); - expect(compInstance.afterRenderCount).toBe(1); - - // Running change detection at the fixture level (second time) - fixture.detectChanges(); - expect(dynamicCompRef.instance.afterRenderCount).toBe(1); - expect(compInstance.afterRenderCount).toBe(1); + expect(dynamicCompRef.instance.afterRenderCount).toBe(0); + expect(compInstance.afterRenderCount).toBe(0); - // Running change detection at the fixture level (third time) - fixture.detectChanges(); + // Running change detection at the Application level + TestBed.inject(ApplicationRef).tick(); expect(dynamicCompRef.instance.afterRenderCount).toBe(1); expect(compInstance.afterRenderCount).toBe(1); // Running change detection after removing view. viewContainerRef.remove(); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(dynamicCompRef.instance.afterRenderCount).toBe(1); expect(compInstance.afterRenderCount).toBe(1); }); @@ -604,10 +594,10 @@ describe('after render hooks', () => { declarations: [ChildComp, ParentComp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(ParentComp); + createAndAttachComponent(ParentComp); expect(log).toEqual([]); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(log).toEqual(['pre-cd', 'post-cd', 'parent-comp', 'child-comp']); }); @@ -628,11 +618,11 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); expect(afterRenderCount).toBe(0); hookRef!.destroy(); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(afterRenderCount).toBe(0); }); @@ -645,25 +635,29 @@ describe('after render hooks', () => { @Component({ selector: 'comp', - providers: [{provide: ErrorHandler, useFactory: () => new RethrowErrorHandler()}] }) class Comp { - changeDetectorRef = inject(ChangeDetectorRef); + appRef = inject(ApplicationRef); + injector = inject(EnvironmentInjector) - constructor() { + ngOnInit() { afterNextRender(() => { - this.changeDetectorRef.detectChanges(); - }); + this.appRef.tick(); + }, {injector: this.injector}); } } TestBed.configureTestingModule({ declarations: [Comp], ...COMMON_CONFIGURATION, + providers: [ + {provide: ErrorHandler, useClass: RethrowErrorHandler}, + ...COMMON_CONFIGURATION.providers + ] }); - const fixture = TestBed.createComponent(Comp); - expect(() => fixture.detectChanges()) - .toThrowError(/A new render operation began before the previous operation ended./); + createAndAttachComponent(Comp); + expect(() => TestBed.inject(ApplicationRef).tick()) + .toThrowError(/ApplicationRef.tick is called recursively/); }); it('should defer nested hooks to the next cycle', () => { @@ -689,24 +683,24 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); // It hasn't run at all expect(outerHookCount).toBe(0); expect(innerHookCount).toBe(0); // Running change detection (first time) - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(outerHookCount).toBe(1); expect(innerHookCount).toBe(0); // Running change detection (second time) - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(outerHookCount).toBe(1); expect(innerHookCount).toBe(1); // Running change detection (third time) - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(outerHookCount).toBe(1); expect(innerHookCount).toBe(1); }); @@ -727,10 +721,10 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); expect(zoneLog).toEqual([]); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(zoneLog).toEqual([false]); }); @@ -771,10 +765,10 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); + createAndAttachComponent(Comp); expect(log).toEqual([]); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(log).toEqual(['pass 1', 'fail 1', 'pass 2', 'fail 2']); }); @@ -831,10 +825,10 @@ describe('after render hooks', () => { declarations: [Root, CompA, CompB], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Root); + createAndAttachComponent(Root); expect(log).toEqual([]); - fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); expect(log).toEqual([ 'early-read-1', 'early-read-2', 'write-1', 'write-2', 'mixed-read-write-1', 'mixed-read-write-2', 'read-1', 'read-2' @@ -865,8 +859,8 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); - fixture.detectChanges(); + createAndAttachComponent(Comp); + TestBed.inject(ApplicationRef).tick(); expect(afterRenderCount).toBe(0); }); }); @@ -888,8 +882,8 @@ describe('after render hooks', () => { declarations: [Comp], ...COMMON_CONFIGURATION, }); - const fixture = TestBed.createComponent(Comp); - fixture.detectChanges(); + createAndAttachComponent(Comp); + TestBed.inject(ApplicationRef).tick(); expect(afterRenderCount).toBe(0); }); }); diff --git a/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts b/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts index 597ef2f9f2553b..5cd2df106a2403 100644 --- a/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts +++ b/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts @@ -8,7 +8,7 @@ import {NgFor, NgIf} from '@angular/common'; import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id'; -import {afterNextRender, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, Directive, inject, Input, PLATFORM_ID, signal, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import {afterNextRender, ApplicationRef, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, Directive, EnvironmentInjector, inject, Input, PLATFORM_ID, signal, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; import {ReactiveNode, SIGNAL} from '@angular/core/primitives/signals'; import {TestBed} from '@angular/core/testing'; @@ -899,17 +899,22 @@ describe('OnPush components with signals', () => { }) class TestCmp { counter = counter; - constructor() { + injector = inject(EnvironmentInjector); + ngOnInit() { afterNextRender(() => { this.counter.set(1); - }); + }, {injector: this.injector}); } } TestBed.configureTestingModule( {providers: [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}]}); const fixture = TestBed.createComponent(TestCmp); - expect(() => fixture.detectChanges()).toThrowError(/ExpressionChanged/); + const appRef = TestBed.inject(ApplicationRef); + appRef.attachView(fixture.componentRef.hostView); + const spy = spyOn(console, 'error'); + appRef.tick(); + expect(spy.calls.first().args[1]).toMatch(/ExpressionChanged/); }); it('destroys all signal consumers when destroying the view tree', () => { diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index 94a10299979c62..3d49951c33e6ff 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -7,7 +7,7 @@ */ import {ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID} from '@angular/common'; -import {Attribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, ErrorHandler, inject, Input, NgZone, Pipe, PipeTransform, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core'; +import {ApplicationRef, Attribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, createComponent, Directive, EnvironmentInjector, ErrorHandler, inject, Input, NgZone, Pipe, PipeTransform, PLATFORM_ID, QueryList, Type, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core'; import {getComponentDef} from '@angular/core/src/render3/definition'; import {ComponentFixture, DeferBlockBehavior, fakeAsync, flush, TestBed, tick} from '@angular/core/testing'; @@ -2720,6 +2720,7 @@ describe('@defer', () => { expect(spy).toHaveBeenCalledWith('keydown', jasmine.any(Function), jasmine.any(Object)); })); + // TODO(atscott): This should work without NgZone it('should bind the trigger events inside the NgZone', fakeAsync(() => { @Component({ standalone: true, @@ -2734,14 +2735,19 @@ describe('@defer', () => { class MyCmp { } + const appRef = TestBed.inject(ApplicationRef); const eventsInZone: Record = {}; - const fixture = TestBed.createComponent(MyCmp); - const button = fixture.nativeElement.querySelector('button'); + const componentRef = + createComponent(MyCmp, {environmentInjector: TestBed.inject(EnvironmentInjector)}); + const button = componentRef.location.nativeElement.querySelector('button'); spyOn(button, 'addEventListener').and.callFake((name: string) => { eventsInZone[name] = NgZone.isInAngularZone(); }); - fixture.detectChanges(); + appRef.attachView(componentRef.hostView); + TestBed.inject(NgZone).run(() => { + appRef.tick(); + }) expect(eventsInZone).toEqual({click: true, keydown: true}); })); @@ -3047,6 +3053,7 @@ describe('@defer', () => { expect(spy).toHaveBeenCalledWith('focusin', jasmine.any(Function), jasmine.any(Object)); })); + // TODO(atscott): This should work without NgZone it('should bind the trigger events inside the NgZone', fakeAsync(() => { @Component({ standalone: true, @@ -3061,14 +3068,19 @@ describe('@defer', () => { class MyCmp { } + const appRef = TestBed.inject(ApplicationRef); const eventsInZone: Record = {}; - const fixture = TestBed.createComponent(MyCmp); - const button = fixture.nativeElement.querySelector('button'); + const component = + createComponent(MyCmp, {environmentInjector: TestBed.inject(EnvironmentInjector)}); + const button = component.location.nativeElement.querySelector('button'); spyOn(button, 'addEventListener').and.callFake((name: string) => { eventsInZone[name] = NgZone.isInAngularZone(); }); - fixture.detectChanges(); + appRef.attachView(component.hostView); + TestBed.inject(NgZone).run(() => { + appRef.tick(); + }); expect(eventsInZone).toEqual({ mouseenter: true, diff --git a/packages/core/testing/src/component_fixture.ts b/packages/core/testing/src/component_fixture.ts index f177eda55010c1..7e391ded308851 100644 --- a/packages/core/testing/src/component_fixture.ts +++ b/packages/core/testing/src/component_fixture.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, inject, NgZone, RendererFactory2, ɵDeferBlockDetails as DeferBlockDetails, ɵgetDeferBlocks as getDeferBlocks, ɵZoneAwareQueueingScheduler as ZoneAwareQueueingScheduler} from '@angular/core'; +import {ApplicationRef, ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, inject, NgZone, RendererFactory2, ɵDeferBlockDetails as DeferBlockDetails, ɵgetDeferBlocks as getDeferBlocks, ɵZoneAwareQueueingScheduler as ZoneAwareQueueingScheduler} from '@angular/core'; import {Subscription} from 'rxjs'; import {DeferBlockFixture} from './defer'; @@ -54,6 +54,13 @@ export class ComponentFixture { private _autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? false; private effectRunner = inject(ZoneAwareQueueingScheduler, {optional: true}); private _subscriptions = new Subscription(); + // Inject ApplicationRef to ensure NgZone stableness causes after render hooks to run + // This will likely happen as a result of fixture.detectChanges because it calls ngZone.run + // This is a crazy way of doing things but hey, it's the world we live in. + // The zoneless scheduler should instead do this more imperatively by attaching + // the `ComponentRef` to `ApplicationRef` and calling `appRef.tick` as the `detectChanges` + // behavior. + private appRef = inject(ApplicationRef); /** @nodoc */ constructor(public componentRef: ComponentRef) {