diff --git a/goldens/public-api/core/errors.md b/goldens/public-api/core/errors.md index 77492e276c3de..dce5a8b3e5f7a 100644 --- a/goldens/public-api/core/errors.md +++ b/goldens/public-api/core/errors.md @@ -107,6 +107,8 @@ export const enum RuntimeErrorCode { // (undocumented) RECURSIVE_APPLICATION_REF_TICK = 101, // (undocumented) + RECURSIVE_APPLICATION_RENDER = 102, + // (undocumented) RENDERER_NOT_FOUND = 407, // (undocumented) REQUIRE_SYNC_WITHOUT_SYNC_EMIT = 601, diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index caa8e70e1a0d2..e140ed71b2d4d 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -24,6 +24,22 @@ export interface AfterContentInit { ngAfterContentInit(): void; } +// @public +export function afterNextRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef; + +// @public +export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef; + +// @public +export interface AfterRenderOptions { + injector?: Injector; +} + +// @public +export interface AfterRenderRef { + destroy(): void; +} + // @public export interface AfterViewChecked { ngAfterViewChecked(): void; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 7d80a026ba4ad..4fcd62b29875f 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -40,6 +40,7 @@ export {Sanitizer} from './sanitization/sanitizer'; export {createNgModule, createNgModuleRef, createEnvironmentInjector} from './render3/ng_module_ref'; export {createComponent, reflectComponentType, ComponentMirror} from './render3/component'; export {isStandalone} from './render3/definition'; +export {AfterRenderRef, AfterRenderOptions, afterRender, afterNextRender} from './render3/after_render_hooks'; export {ApplicationConfig, mergeApplicationConfig} from './application_config'; export {makeStateKey, StateKey, TransferState} from './transfer_state'; export {booleanAttribute, numberAttribute} from './util/coercion'; diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index e05c964e0091d..27816dccb312b 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -282,6 +282,6 @@ export { export { noSideEffects as ɵnoSideEffects, } from './util/closure'; - +export { AfterRenderEventManager as ɵAfterRenderEventManager } from './render3/after_render_hooks'; // clang-format on diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 44091732ae149..074571c07d4fa 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -30,6 +30,7 @@ export const enum RuntimeErrorCode { // Change Detection Errors EXPRESSION_CHANGED_AFTER_CHECKED = -100, RECURSIVE_APPLICATION_REF_TICK = 101, + RECURSIVE_APPLICATION_RENDER = 102, // Dependency Injection Errors CYCLIC_DI_DEPENDENCY = -200, diff --git a/packages/core/src/hydration/api.ts b/packages/core/src/hydration/api.ts index 1ea53959fc69d..b726b95f27ddf 100644 --- a/packages/core/src/hydration/api.ts +++ b/packages/core/src/hydration/api.ts @@ -20,6 +20,7 @@ import {enableLocateOrCreateElementContainerNodeImpl} from '../render3/instructi import {enableApplyRootElementTransformImpl} from '../render3/instructions/shared'; import {enableLocateOrCreateContainerAnchorImpl} from '../render3/instructions/template'; import {enableLocateOrCreateTextNodeImpl} from '../render3/instructions/text'; +import {isPlatformBrowser} from '../render3/util/misc_utils'; import {TransferState} from '../transfer_state'; import {NgZone} from '../zone'; @@ -66,15 +67,6 @@ function enableHydrationRuntimeSupport() { } } -/** - * Detects whether the code is invoked in a browser. - * Later on, this check should be replaced with a tree-shakable - * flag (e.g. `!isServer`). - */ -function isBrowser(): boolean { - return inject(PLATFORM_ID) === 'browser'; -} - /** * Outputs a message with hydration stats into a console. */ @@ -129,7 +121,7 @@ export function withDomHydration(): EnvironmentProviders { provide: IS_HYDRATION_DOM_REUSE_ENABLED, useFactory: () => { let isEnabled = true; - if (isBrowser()) { + if (isPlatformBrowser()) { // On the client, verify that the server response contains // hydration annotations. Otherwise, keep hydration disabled. const transferState = inject(TransferState, {optional: true}); @@ -161,7 +153,7 @@ export function withDomHydration(): EnvironmentProviders { // on the client. Moving forward, the `isBrowser` check should // be replaced with a tree-shakable alternative (e.g. `isServer` // flag). - if (isBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED)) { + if (isPlatformBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED)) { enableHydrationRuntimeSupport(); } }, @@ -174,13 +166,13 @@ export function withDomHydration(): EnvironmentProviders { // environment and when hydration is configured properly. // On a server, an application is rendered from scratch, // so the host content needs to be empty. - return isBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED); + return isPlatformBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED); } }, { provide: APP_BOOTSTRAP_LISTENER, useFactory: () => { - if (isBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED)) { + if (isPlatformBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED)) { const appRef = inject(ApplicationRef); const injector = inject(Injector); return () => { diff --git a/packages/core/src/render3/after_render_hooks.ts b/packages/core/src/render3/after_render_hooks.ts new file mode 100644 index 0000000000000..c583fe9d6f40e --- /dev/null +++ b/packages/core/src/render3/after_render_hooks.ts @@ -0,0 +1,258 @@ +/** + * @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 {assertInInjectionContext, Injector, ɵɵdefineInjectable} from '../di'; +import {inject} from '../di/injector_compatibility'; +import {RuntimeError, RuntimeErrorCode} from '../errors'; +import {DestroyRef} from '../linker/destroy_ref'; + +import {isPlatformBrowser} from './util/misc_utils'; + +/** + * Options passed to `afterRender` and `afterNextRender`. + * + * @developerPreview + */ +export interface AfterRenderOptions { + /** + * The `Injector` to use during creation. + * + * If this is not provided, the current injection context will be used instead (via `inject`). + */ + injector?: Injector; +} + +/** + * A callback that runs after render. + * + * @developerPreview + */ +export interface AfterRenderRef { + /** + * Shut down the callback, preventing it from being called again. + */ + destroy(): void; +} + +/** + * Register a callback to be invoked each time the application + * finishes rendering. + * + * Note that the callback will run + * - in the order it was registered + * - once per render + * - on browser platforms only + * + *
+ * + * Components are not guaranteed to be [hydrated](guide/hydration) before the callback runs. + * You must use caution when directly reading or writing the DOM and layout. + * + *
+ * + * @param callback A callback function to register + * + * @usageNotes + * + * Use `afterRender` to read or write the DOM after each render. + * + * ### Example + * ```ts + * @Component({ + * selector: 'my-cmp', + * template: `{{ ... }}`, + * }) + * export class MyComponent { + * @ViewChild('content') contentRef: ElementRef; + * + * constructor() { + * afterRender(() => { + * console.log('content height: ' + this.contentRef.nativeElement.scrollHeight); + * }); + * } + * } + * ``` + * + * @developerPreview + */ +export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef { + !options && assertInInjectionContext(afterRender); + const injector = options?.injector ?? inject(Injector); + + if (!isPlatformBrowser(injector)) { + return {destroy() {}}; + } + + let destroy: VoidFunction|undefined; + const unregisterFn = injector.get(DestroyRef).onDestroy(() => destroy?.()); + const manager = injector.get(AfterRenderEventManager); + const instance = new AfterRenderCallback(callback); + + destroy = () => { + manager.unregister(instance); + unregisterFn(); + }; + manager.register(instance); + return {destroy}; +} + +/** + * Register a callback to be invoked the next time the application + * finishes rendering. + * + * Note that the callback will run + * - in the order it was registered + * - on browser platforms only + * + *
+ * + * Components are not guaranteed to be [hydrated](guide/hydration) before the callback runs. + * You must use caution when directly reading or writing the DOM and layout. + * + *
+ * + * @param callback A callback function to register + * + * @usageNotes + * + * Use `afterNextRender` to read or write the DOM once, + * for example to initialize a non-Angular library. + * + * ### Example + * ```ts + * @Component({ + * selector: 'my-chart-cmp', + * template: `
{{ ... }}
`, + * }) + * export class MyChartCmp { + * @ViewChild('chart') chartRef: ElementRef; + * chart: MyChart|null; + * + * constructor() { + * afterNextRender(() => { + * this.chart = new MyChart(this.chartRef.nativeElement); + * }); + * } + * } + * ``` + * + * @developerPreview + */ +export function afterNextRender( + callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef { + !options && assertInInjectionContext(afterNextRender); + const injector = options?.injector ?? inject(Injector); + + if (!isPlatformBrowser(injector)) { + return {destroy() {}}; + } + + let destroy: VoidFunction|undefined; + const unregisterFn = injector.get(DestroyRef).onDestroy(() => destroy?.()); + const manager = injector.get(AfterRenderEventManager); + const instance = new AfterRenderCallback(() => { + destroy?.(); + callback(); + }); + + destroy = () => { + manager.unregister(instance); + unregisterFn(); + }; + manager.register(instance); + return {destroy}; +} + +/** + * A wrapper around a function to be used as an after render callback. + * @private + */ +class AfterRenderCallback { + private callback: VoidFunction; + + constructor(callback: VoidFunction) { + this.callback = callback; + } + + invoke() { + this.callback(); + } +} + +/** + * Implements `afterRender` and `afterNextRender` callback manager logic. + */ +export class AfterRenderEventManager { + private callbacks = new Set(); + private deferredCallbacks = new Set(); + private renderDepth = 0; + private runningCallbacks = false; + + /** + * Mark the beginning of a render operation (i.e. CD cycle). + * Throws if called from an `afterRender` callback. + */ + begin() { + if (this.runningCallbacks) { + 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?'); + } + + this.renderDepth++; + } + + /** + * Mark the end of a render operation. Registered callbacks + * are invoked if there are no more pending operations. + */ + end() { + this.renderDepth--; + + if (this.renderDepth === 0) { + try { + this.runningCallbacks = true; + for (const callback of this.callbacks) { + callback.invoke(); + } + } finally { + this.runningCallbacks = false; + for (const callback of this.deferredCallbacks) { + this.callbacks.add(callback); + } + this.deferredCallbacks.clear(); + } + } + } + + register(callback: AfterRenderCallback) { + // If we're currently running callbacks, new callbacks should be deferred + // until the next render operation. + const target = this.runningCallbacks ? this.deferredCallbacks : this.callbacks; + target.add(callback); + } + + unregister(callback: AfterRenderCallback) { + this.callbacks.delete(callback); + this.deferredCallbacks.delete(callback); + } + + ngOnDestroy() { + this.callbacks.clear(); + this.deferredCallbacks.clear(); + } + + /** @nocollapse */ + static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({ + token: AfterRenderEventManager, + providedIn: 'root', + factory: () => new AfterRenderEventManager(), + }); +} diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 075be85432d8f..7f83210259c4d 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -26,6 +26,7 @@ import {assertDefined, assertGreaterThan, assertIndexInRange} from '../util/asse import {VERSION} from '../version'; import {NOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR} from '../view/provider_flags'; +import {AfterRenderEventManager} from './after_render_hooks'; import {assertComponentType} from './assert'; import {attachPatchData} from './context_discovery'; import {getComponentDef} from './definition'; @@ -189,10 +190,13 @@ export class ComponentFactory extends AbstractComponentFactory { const effectManager = rootViewInjector.get(EffectManager, null); + const afterRenderEventManager = rootViewInjector.get(AfterRenderEventManager, null); + const environment: LViewEnvironment = { rendererFactory, sanitizer, effectManager, + afterRenderEventManager, }; const hostRenderer = rendererFactory.createRenderer(null, this.componentDef); diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index 5fafd2981b884..337e7ae1a01c3 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -21,14 +21,20 @@ import {executeTemplate, executeViewQueryFn, handleError, processHostBindingOpCo export function detectChangesInternal( tView: TView, lView: LView, context: T, notifyErrorHandler = true) { - const rendererFactory = lView[ENVIRONMENT].rendererFactory; + 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 // to avoid any possible side-effects. const checkNoChangesMode = !!ngDevMode && isInCheckNoChangesMode(); - if (!checkNoChangesMode && rendererFactory.begin) rendererFactory.begin(); + if (!checkNoChangesMode) { + rendererFactory.begin?.(); + afterRenderEventManager?.begin(); + } + try { refreshView(tView, lView, tView.template, context); } catch (error) { @@ -37,11 +43,16 @@ export function detectChangesInternal( } throw error; } finally { - if (!checkNoChangesMode && rendererFactory.end) rendererFactory.end(); + if (!checkNoChangesMode) { + rendererFactory.end?.(); - // One final flush of the effects queue to catch any effects created in `ngAfterViewInit` or - // other post-order hooks. - !checkNoChangesMode && lView[ENVIRONMENT].effectManager?.flush(); + // One final flush of the effects queue to catch any effects created in `ngAfterViewInit` or + // other post-order hooks. + environment.effectManager?.flush(); + + // Invoke all callbacks registered via `after*Render`, if needed. + afterRenderEventManager?.end(); + } } } diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 42e6809e8c15e..02a35f7955047 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -13,6 +13,7 @@ import {SchemaMetadata} from '../../metadata/schema'; import {Sanitizer} from '../../sanitization/sanitizer'; import type {ReactiveLViewConsumer} from '../reactive_lview_consumer'; import type {EffectManager} from '../reactivity/effect'; +import type {AfterRenderEventManager} from '../after_render_hooks'; import {LContainer} from './container'; import {ComponentDef, ComponentTemplate, DirectiveDef, DirectiveDefList, HostBindingsFunction, PipeDef, PipeDefList, ViewQueriesFunction} from './definition'; @@ -371,6 +372,9 @@ export interface LViewEnvironment { /** Container for reactivity system `effect`s. */ effectManager: EffectManager|null; + + /** Container for after render hooks */ + afterRenderEventManager: AfterRenderEventManager|null; } /** Flags associated with an LView (saved in LView[FLAGS]) */ diff --git a/packages/core/src/render3/util/misc_utils.ts b/packages/core/src/render3/util/misc_utils.ts index 44446f23d2d4c..595f494d0bd3b 100644 --- a/packages/core/src/render3/util/misc_utils.ts +++ b/packages/core/src/render3/util/misc_utils.ts @@ -6,9 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ +import {PLATFORM_ID} from '../../application_tokens'; +import {Injector} from '../../di'; +import {inject} from '../../di/injector_compatibility'; import {RElement} from '../interfaces/renderer_dom'; - /** * * @codeGenApi @@ -59,3 +61,12 @@ export function maybeUnwrapFn(value: T|(() => T)): T { return value; } } + +/** + * Detects whether the code is invoked in a browser. + * Later on, this check should be replaced with a tree-shakable + * flag (e.g. `!isServer`). + */ +export function isPlatformBrowser(injector?: Injector): boolean { + return (injector ?? inject(Injector)).get(PLATFORM_ID) === 'browser'; +} diff --git a/packages/core/test/acceptance/after_render_hook_spec.ts b/packages/core/test/acceptance/after_render_hook_spec.ts new file mode 100644 index 0000000000000..5e3e6b97619b1 --- /dev/null +++ b/packages/core/test/acceptance/after_render_hook_spec.ts @@ -0,0 +1,488 @@ +/** + * @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 {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '@angular/common/src/platform_id'; +import {afterNextRender, afterRender, AfterRenderRef, ChangeDetectorRef, Component, inject, Injector, PLATFORM_ID, ViewContainerRef} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; + +describe('after render hooks', () => { + describe('browser', () => { + const COMMON_CONFIGURATION = { + providers: [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}] + }; + + describe('afterRender', () => { + it('should run with the correct timing', () => { + @Component({selector: 'dynamic-comp'}) + class DynamicComp { + afterRenderCount = 0; + + constructor() { + afterRender(() => { + this.afterRenderCount++; + }); + } + } + + @Component({selector: 'comp'}) + class Comp { + afterRenderCount = 0; + changeDetectorRef = inject(ChangeDetectorRef); + viewContainerRef = inject(ViewContainerRef); + + constructor() { + afterRender(() => { + this.afterRenderCount++; + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(Comp); + const compInstance = fixture.componentInstance; + const viewContainerRef = compInstance.viewContainerRef; + const dynamicCompRef = viewContainerRef.createComponent(DynamicComp); + + // It hasn't run at all + expect(dynamicCompRef.instance.afterRenderCount).toBe(0); + expect(compInstance.afterRenderCount).toBe(0); + + // Running change detection at the dynamicCompRef level + dynamicCompRef.changeDetectorRef.detectChanges(); + expect(dynamicCompRef.instance.afterRenderCount).toBe(1); + expect(compInstance.afterRenderCount).toBe(1); + + // 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); + + // 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 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); + }); + + it('should run all hooks after outer change detection', () => { + let log: string[] = []; + + @Component({selector: 'child-comp'}) + class ChildComp { + constructor() { + afterRender(() => { + log.push('child-comp'); + }); + } + } + + @Component({ + selector: 'parent', + template: ``, + }) + class ParentComp { + changeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + afterRender(() => { + log.push('parent-comp'); + }); + } + + ngOnInit() { + log.push('pre-cd'); + this.changeDetectorRef.detectChanges(); + log.push('post-cd'); + } + } + + TestBed.configureTestingModule({ + declarations: [ChildComp, ParentComp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(ParentComp); + expect(log).toEqual([]); + + fixture.detectChanges(); + expect(log).toEqual(['pre-cd', 'post-cd', 'parent-comp', 'child-comp']); + }); + + it('should unsubscribe when calling destroy', () => { + let hookRef: AfterRenderRef|null = null; + let afterRenderCount = 0; + + @Component({selector: 'comp'}) + class Comp { + constructor() { + hookRef = afterRender(() => { + afterRenderCount++; + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(Comp); + expect(afterRenderCount).toBe(0); + + fixture.detectChanges(); + expect(afterRenderCount).toBe(1); + + fixture.detectChanges(); + expect(afterRenderCount).toBe(2); + hookRef!.destroy(); + + fixture.detectChanges(); + expect(afterRenderCount).toBe(2); + }); + + it('should throw if called recursively', () => { + @Component({selector: 'comp'}) + 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; + + @Component({selector: 'comp'}) + class Comp { + injector = inject(Injector); + + constructor() { + afterRender(() => { + outerHookCount++; + afterNextRender(() => { + innerHookCount++; + }, {injector: this.injector}); + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(Comp); + + // It hasn't run at all + expect(outerHookCount).toBe(0); + expect(innerHookCount).toBe(0); + + // Running change detection (first time) + fixture.detectChanges(); + expect(outerHookCount).toBe(1); + expect(innerHookCount).toBe(0); + + // Running change detection (second time) + fixture.detectChanges(); + expect(outerHookCount).toBe(2); + expect(innerHookCount).toBe(1); + + // Running change detection (third time) + fixture.detectChanges(); + expect(outerHookCount).toBe(3); + expect(innerHookCount).toBe(2); + }); + }); + + describe('afterNextRender', () => { + it('should run with the correct timing', () => { + @Component({selector: 'dynamic-comp'}) + class DynamicComp { + afterRenderCount = 0; + + constructor() { + afterNextRender(() => { + this.afterRenderCount++; + }); + } + } + + @Component({selector: 'comp'}) + class Comp { + afterRenderCount = 0; + changeDetectorRef = inject(ChangeDetectorRef); + viewContainerRef = inject(ViewContainerRef); + + constructor() { + afterNextRender(() => { + this.afterRenderCount++; + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(Comp); + const compInstance = fixture.componentInstance; + const viewContainerRef = compInstance.viewContainerRef; + const dynamicCompRef = viewContainerRef.createComponent(DynamicComp); + + // It hasn't run at all + expect(dynamicCompRef.instance.afterRenderCount).toBe(0); + expect(compInstance.afterRenderCount).toBe(0); + + // Running change detection at the dynamicCompRef level + dynamicCompRef.changeDetectorRef.detectChanges(); + expect(dynamicCompRef.instance.afterRenderCount).toBe(1); + expect(compInstance.afterRenderCount).toBe(1); + + // 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); + + // Running change detection at the fixture level (third time) + fixture.detectChanges(); + expect(dynamicCompRef.instance.afterRenderCount).toBe(1); + expect(compInstance.afterRenderCount).toBe(1); + + // Running change detection after removing view. + viewContainerRef.remove(); + fixture.detectChanges(); + expect(dynamicCompRef.instance.afterRenderCount).toBe(1); + expect(compInstance.afterRenderCount).toBe(1); + }); + + it('should run all hooks after outer change detection', () => { + let log: string[] = []; + + @Component({selector: 'child-comp'}) + class ChildComp { + constructor() { + afterNextRender(() => { + log.push('child-comp'); + }); + } + } + + @Component({ + selector: 'parent', + template: ``, + }) + class ParentComp { + changeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + afterNextRender(() => { + log.push('parent-comp'); + }); + } + + ngOnInit() { + log.push('pre-cd'); + this.changeDetectorRef.detectChanges(); + log.push('post-cd'); + } + } + + TestBed.configureTestingModule({ + declarations: [ChildComp, ParentComp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(ParentComp); + expect(log).toEqual([]); + + fixture.detectChanges(); + expect(log).toEqual(['pre-cd', 'post-cd', 'parent-comp', 'child-comp']); + }); + + it('should unsubscribe when calling destroy', () => { + let hookRef: AfterRenderRef|null = null; + let afterRenderCount = 0; + + @Component({selector: 'comp'}) + class Comp { + constructor() { + hookRef = afterNextRender(() => { + afterRenderCount++; + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(Comp); + expect(afterRenderCount).toBe(0); + + hookRef!.destroy(); + fixture.detectChanges(); + expect(afterRenderCount).toBe(0); + }); + + it('should throw if called recursively', () => { + @Component({selector: 'comp'}) + class Comp { + changeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + afterNextRender(() => { + 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; + + @Component({selector: 'comp'}) + class Comp { + injector = inject(Injector); + + constructor() { + afterNextRender(() => { + outerHookCount++; + + afterNextRender(() => { + innerHookCount++; + }, {injector: this.injector}); + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(Comp); + + // It hasn't run at all + expect(outerHookCount).toBe(0); + expect(innerHookCount).toBe(0); + + // Running change detection (first time) + fixture.detectChanges(); + expect(outerHookCount).toBe(1); + expect(innerHookCount).toBe(0); + + // Running change detection (second time) + fixture.detectChanges(); + expect(outerHookCount).toBe(1); + expect(innerHookCount).toBe(1); + + // Running change detection (third time) + fixture.detectChanges(); + expect(outerHookCount).toBe(1); + expect(innerHookCount).toBe(1); + }); + }); + }); + + describe('server', () => { + const COMMON_CONFIGURATION = { + providers: [{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}] + }; + + describe('afterRender', () => { + it('should not run', () => { + let afterRenderCount = 0; + + @Component({selector: 'comp'}) + class Comp { + constructor() { + afterRender(() => { + afterRenderCount++; + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(Comp); + fixture.detectChanges(); + expect(afterRenderCount).toBe(0); + }); + }); + + describe('afterNextRender', () => { + it('should not run', () => { + let afterRenderCount = 0; + + @Component({selector: 'comp'}) + class Comp { + constructor() { + afterNextRender(() => { + afterRenderCount++; + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + const fixture = TestBed.createComponent(Comp); + fixture.detectChanges(); + expect(afterRenderCount).toBe(0); + }); + }); + }); +}); diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index c33202ab95c14..94decc1372d59 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -14,6 +14,9 @@ { "name": "APP_INITIALIZER" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnimationAstBuilderContext" }, diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index 2919a639badb6..ed401a45b8555 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -17,6 +17,9 @@ { "name": "APP_INITIALIZER" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnimationAstBuilderContext" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 4ff7c8816ea89..08febf73a492a 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -11,6 +11,9 @@ { "name": "APP_INITIALIZER" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnonymousSubject" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index c4f96b11b9082..13f67dc3297c7 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -23,6 +23,9 @@ { "name": "AbstractFormGroupDirective" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnonymousSubject" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 6f51c095a5347..4da6e37fcf593 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -26,6 +26,9 @@ { "name": "AbstractValidatorDirective" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnonymousSubject" }, diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 041b8b5c28098..0b1f4367ff2ee 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -8,6 +8,9 @@ { "name": "APP_INITIALIZER" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnonymousSubject" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 950131bf78491..9d5f7bae9cef9 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -11,6 +11,9 @@ { "name": "APP_INITIALIZER" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnonymousSubject" }, @@ -974,9 +977,6 @@ { "name": "isArrayLike" }, - { - "name": "isBrowser" - }, { "name": "isComponentDef" }, @@ -1010,6 +1010,9 @@ { "name": "isObject" }, + { + "name": "isPlatformBrowser" + }, { "name": "isPlatformServer" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 9f66f87b7f0d3..277a8cf769a0b 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -20,6 +20,9 @@ { "name": "ActivatedRouteSnapshot" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnonymousSubject" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 579f91b07d9c3..a02726790ad86 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -8,6 +8,9 @@ { "name": "APP_INITIALIZER" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnonymousSubject" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index b9cf18838e892..a22cc1b79c449 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -11,6 +11,9 @@ { "name": "APP_INITIALIZER" }, + { + "name": "AfterRenderEventManager" + }, { "name": "AnonymousSubject" }, diff --git a/packages/core/test/render3/di_spec.ts b/packages/core/test/render3/di_spec.ts index d24fef3af33fc..b8d6e8442b868 100644 --- a/packages/core/test/render3/di_spec.ts +++ b/packages/core/test/render3/di_spec.ts @@ -142,9 +142,13 @@ describe('di', () => { const contentView = createLView( null, createTView(TViewType.Component, null, null, 1, 0, null, null, null, null, null, null), - {}, LViewFlags.CheckAlways, null, null, - {rendererFactory: {} as any, sanitizer: null, effectManager: null}, {} as any, null, null, - null); + {}, LViewFlags.CheckAlways, null, null, { + rendererFactory: {} as any, + sanitizer: null, + effectManager: null, + afterRenderEventManager: null + }, + {} as any, null, null, null); enterView(contentView); try { const parentTNode = diff --git a/packages/core/test/render3/instructions/shared_spec.ts b/packages/core/test/render3/instructions/shared_spec.ts index 7bfb2bafd137c..0c37ebce81b58 100644 --- a/packages/core/test/render3/instructions/shared_spec.ts +++ b/packages/core/test/render3/instructions/shared_spec.ts @@ -43,7 +43,8 @@ export function enterViewWithOneDiv() { const tNode = tView.firstChild = createTNode(tView, null!, TNodeType.Element, 0, 'div', null); const lView = createLView( null, tView, null, LViewFlags.CheckAlways, null, null, - {rendererFactory, sanitizer: null, effectManager: null}, renderer, null, null, null); + {rendererFactory, sanitizer: null, effectManager: null, afterRenderEventManager: null}, + renderer, null, null, null); lView[HEADER_OFFSET] = div; tView.data[HEADER_OFFSET] = tNode; enterView(lView); diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts index db0023f9bbed2..de88a4317147c 100644 --- a/packages/core/test/render3/view_fixture.ts +++ b/packages/core/test/render3/view_fixture.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Sanitizer, Type} from '@angular/core'; +import {Sanitizer, Type, ɵAfterRenderEventManager as AfterRenderEventManager} from '@angular/core'; import {EffectManager} from '@angular/core/src/render3/reactivity/effect'; import {stringifyElement} from '@angular/platform-browser/testing/src/browser_util'; @@ -76,6 +76,7 @@ export class ViewFixture { rendererFactory, sanitizer: sanitizer || null, effectManager: new EffectManager(), + afterRenderEventManager: new AfterRenderEventManager(), }, hostRenderer, null, null, null); diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 557e6a2f114ef..88675099e44c0 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -10,7 +10,7 @@ import '@angular/localize/init'; import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet, PlatformLocation} from '@angular/common'; import {MockPlatformLocation} from '@angular/common/testing'; -import {ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument} from '@angular/core'; +import {afterRender, ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument} from '@angular/core'; import {Console} from '@angular/core/src/console'; import {getComponentDef} from '@angular/core/src/render3/definition'; import {NoopNgZone} from '@angular/core/src/zone/ng_zone'; @@ -3249,6 +3249,102 @@ describe('platform-server hydration integration', () => { expect(clientContents).toContain('This is a CLIENT-ONLY content'); expect(clientContents).not.toContain('This is a SERVER-ONLY content'); }); + + it('should trigger change detection after cleanup (immediate)', async () => { + const observedChildCountLog: number[] = []; + + @Component({ + standalone: true, + selector: 'app', + imports: [NgIf], + template: ` + This is a SERVER-ONLY content + This is a CLIENT-ONLY content + `, + }) + class SimpleComponent { + isServer = isPlatformServer(inject(PLATFORM_ID)); + elementRef = inject(ElementRef); + + constructor() { + afterRender(() => { + observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); + }); + } + } + + const html = await ssr(SimpleComponent); + let ssrContents = getAppContents(html); + + expect(ssrContents).toContain(' { + const observedChildCountLog: number[] = []; + + @Component({ + standalone: true, + selector: 'app', + imports: [NgIf], + template: ` + This is a SERVER-ONLY content + This is a CLIENT-ONLY content + `, + }) + class SimpleComponent { + isServer = isPlatformServer(inject(PLATFORM_ID)); + elementRef = inject(ElementRef); + + constructor() { + afterRender(() => { + observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); + }); + + // Create a dummy promise to prevent stabilization + new Promise(resolve => { + setTimeout(resolve, 0); + }); + } + } + + const html = await ssr(SimpleComponent); + let ssrContents = getAppContents(html); + + expect(ssrContents).toContain(' {