Skip to content

Commit

Permalink
feat(core): add afterRender and afterNextRender (#50607)
Browse files Browse the repository at this point in the history
Add and expose the after*Render functions as developer preview

PR Close #50607
  • Loading branch information
devknoll authored and alxhub committed Aug 1, 2023
1 parent 8913d3e commit e53d4ec
Show file tree
Hide file tree
Showing 26 changed files with 950 additions and 30 deletions.
2 changes: 2 additions & 0 deletions goldens/public-api/core/errors.md
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions goldens/public-api/core/index.md
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core.ts
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/core_render3_private_export.ts
Expand Up @@ -282,6 +282,6 @@ export {
export {
noSideEffects as ɵnoSideEffects,
} from './util/closure';

export { AfterRenderEventManager as ɵAfterRenderEventManager } from './render3/after_render_hooks';

// clang-format on
1 change: 1 addition & 0 deletions packages/core/src/errors.ts
Expand Up @@ -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,
Expand Down
18 changes: 5 additions & 13 deletions packages/core/src/hydration/api.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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});
Expand Down Expand Up @@ -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();
}
},
Expand All @@ -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 () => {
Expand Down
258 changes: 258 additions & 0 deletions 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
*
* <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(),
});
}
4 changes: 4 additions & 0 deletions packages/core/src/render3/component_ref.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit e53d4ec

Please sign in to comment.