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(' {