mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
8913d3e407
commit
e53d4ecf4c
26 changed files with 950 additions and 30 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -282,6 +282,6 @@ export {
|
|||
export {
|
||||
noSideEffects as ɵnoSideEffects,
|
||||
} from './util/closure';
|
||||
|
||||
export { AfterRenderEventManager as ɵAfterRenderEventManager } from './render3/after_render_hooks';
|
||||
|
||||
// clang-format on
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
258
packages/core/src/render3/after_render_hooks.ts
Normal file
258
packages/core/src/render3/after_render_hooks.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]) */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
488
packages/core/test/acceptance/after_render_hook_spec.ts
Normal file
488
packages/core/test/acceptance/after_render_hook_spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,9 @@
|
|||
{
|
||||
"name": "APP_INITIALIZER"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnimationAstBuilderContext"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
{
|
||||
"name": "APP_INITIALIZER"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnimationAstBuilderContext"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
{
|
||||
"name": "APP_INITIALIZER"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnonymousSubject"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@
|
|||
{
|
||||
"name": "AbstractFormGroupDirective"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnonymousSubject"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@
|
|||
{
|
||||
"name": "AbstractValidatorDirective"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnonymousSubject"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
{
|
||||
"name": "APP_INITIALIZER"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnonymousSubject"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@
|
|||
{
|
||||
"name": "ActivatedRouteSnapshot"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnonymousSubject"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
{
|
||||
"name": "APP_INITIALIZER"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnonymousSubject"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
{
|
||||
"name": "APP_INITIALIZER"
|
||||
},
|
||||
{
|
||||
"name": "AfterRenderEventManager"
|
||||
},
|
||||
{
|
||||
"name": "AnonymousSubject"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue