feat(core): add afterRender and afterNextRender (#50607)

Add and expose the after*Render functions as developer preview

PR Close #50607
This commit is contained in:
Gerald Monaco 2023-06-07 22:21:55 +00:00 committed by Alex Rickabaugh
parent 8913d3e407
commit e53d4ecf4c
26 changed files with 950 additions and 30 deletions

View file

@ -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,

View file

@ -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;

View file

@ -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';

View file

@ -282,6 +282,6 @@ export {
export {
noSideEffects as ɵnoSideEffects,
} from './util/closure';
export { AfterRenderEventManager as ɵAfterRenderEventManager } from './render3/after_render_hooks';
// clang-format on

View file

@ -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,

View file

@ -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 () => {

View file

@ -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
*
* <div class="alert is-important">
*
* 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.
*
* </div>
*
* @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: `<span #content>{{ ... }}</span>`,
* })
* 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
*
* <div class="alert is-important">
*
* 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.
*
* </div>
*
* @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: `<div #chart>{{ ... }}</div>`,
* })
* 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<AfterRenderCallback>();
private deferredCallbacks = new Set<AfterRenderCallback>();
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(),
});
}

View file

@ -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<T> extends AbstractComponentFactory<T> {
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);

View file

@ -21,14 +21,20 @@ import {executeTemplate, executeViewQueryFn, handleError, processHostBindingOpCo
export function detectChangesInternal<T>(
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<T>(
}
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();
}
}
}

View file

@ -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]) */

View file

@ -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<T>(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';
}

View file

@ -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: `<child-comp></child-comp>`,
})
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: `<child-comp></child-comp>`,
})
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);
});
});
});
});

View file

@ -14,6 +14,9 @@
{
"name": "APP_INITIALIZER"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnimationAstBuilderContext"
},

View file

@ -17,6 +17,9 @@
{
"name": "APP_INITIALIZER"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnimationAstBuilderContext"
},

View file

@ -11,6 +11,9 @@
{
"name": "APP_INITIALIZER"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnonymousSubject"
},

View file

@ -23,6 +23,9 @@
{
"name": "AbstractFormGroupDirective"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnonymousSubject"
},

View file

@ -26,6 +26,9 @@
{
"name": "AbstractValidatorDirective"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnonymousSubject"
},

View file

@ -8,6 +8,9 @@
{
"name": "APP_INITIALIZER"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnonymousSubject"
},

View file

@ -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"
},

View file

@ -20,6 +20,9 @@
{
"name": "ActivatedRouteSnapshot"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnonymousSubject"
},

View file

@ -8,6 +8,9 @@
{
"name": "APP_INITIALIZER"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnonymousSubject"
},

View file

@ -11,6 +11,9 @@
{
"name": "APP_INITIALIZER"
},
{
"name": "AfterRenderEventManager"
},
{
"name": "AnonymousSubject"
},

View file

@ -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 =

View file

@ -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);

View file

@ -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);

View file

@ -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('<i>This is a CLIENT-ONLY content</i>');
expect(clientContents).not.toContain('<b>This is a SERVER-ONLY content</b>');
});
it('should trigger change detection after cleanup (immediate)', async () => {
const observedChildCountLog: number[] = [];
@Component({
standalone: true,
selector: 'app',
imports: [NgIf],
template: `
<span *ngIf="isServer">This is a SERVER-ONLY content</span>
<span *ngIf="!isServer">This is a CLIENT-ONLY content</span>
`,
})
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('<app ngh');
resetTViewsFor(SimpleComponent);
// Before hydration
expect(observedChildCountLog).toEqual([]);
const appRef = await hydrate(html, SimpleComponent);
await whenStable(appRef);
// afterRender should be triggered by:
// 1.) Bootstrap
// 2.) Microtask empty event
// 3.) Stabilization + cleanup
expect(observedChildCountLog).toEqual([2, 2, 1]);
});
it('should trigger change detection after cleanup (deferred)', async () => {
const observedChildCountLog: number[] = [];
@Component({
standalone: true,
selector: 'app',
imports: [NgIf],
template: `
<span *ngIf="isServer">This is a SERVER-ONLY content</span>
<span *ngIf="!isServer">This is a CLIENT-ONLY content</span>
`,
})
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<void>(resolve => {
setTimeout(resolve, 0);
});
}
}
const html = await ssr(SimpleComponent);
let ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
resetTViewsFor(SimpleComponent);
// Before hydration
expect(observedChildCountLog).toEqual([]);
const appRef = await hydrate(html, SimpleComponent);
// afterRender should be triggered by:
// 1.) Bootstrap
// 2.) Microtask empty event
expect(observedChildCountLog).toEqual([2, 2]);
await whenStable(appRef);
// afterRender should be triggered by:
// 3.) Microtask empty event
// 4.) Stabilization + cleanup
expect(observedChildCountLog).toEqual([2, 2, 2, 1]);
});
});
describe('content projection', () => {