diff --git a/adev/src/content/guide/components/lifecycle.md b/adev/src/content/guide/components/lifecycle.md index 7dba6058ca14..36cb175f8794 100644 --- a/adev/src/content/guide/components/lifecycle.md +++ b/adev/src/content/guide/components/lifecycle.md @@ -239,30 +239,42 @@ Render callbacks do not run during server-side rendering or during build-time pr #### afterRender phases -When using `afterRender` or `afterNextRender`, you can optionally specify a `phase`. The phase -gives you control over the sequencing of DOM operations, letting you sequence _write_ operations -before _read_ operations in order to minimize -[layout thrashing](https://web.dev/avoid-large-complex-layouts-and-layout-thrashing). +When using `afterRender` or `afterNextRender`, you can optionally split the work into phases. The +phase gives you control over the sequencing of DOM operations, letting you sequence _write_ +operations before _read_ operations in order to minimize +[layout thrashing](https://web.dev/avoid-large-complex-layouts-and-layout-thrashing). In order to +communicate across phases, a phase function may return a result value that can be accessed in the +next phase. ```ts -import {Component, ElementRef, afterNextRender, AfterRenderPhase} from '@angular/core'; +import {Component, ElementRef, afterNextRender} from '@angular/core'; @Component({...}) export class UserProfile { + private prevPadding = 0; private elementHeight = 0; constructor(elementRef: ElementRef) { const nativeElement = elementRef.nativeElement; - // Use the `Write` phase to write to a geometric property. - afterNextRender(() => { - nativeElement.style.padding = computePadding(); - }, {phase: AfterRenderPhase.Write}); - - // Use the `Read` phase to read geometric properties after all writes have occurred. - afterNextRender(() => { - this.elementHeight = nativeElement.getBoundingClientRect().height; - }, {phase: AfterRenderPhase.Read}); + afterNextRender({ + // Use the `Write` phase to write to a geometric property. + write: () => { + const padding = computePadding(); + const changed = padding !== prevPadding; + if (changed) { + nativeElement.style.padding = padding; + } + return changed; // Communicate whether anything changed to the read phase. + }, + + // Use the `Read` phase to read geometric properties after all writes have occurred. + read: (didWrite) => { + if (didWrite) { + this.elementHeight = nativeElement.getBoundingClientRect().height; + } + } + }); } } ``` @@ -271,10 +283,10 @@ There are four phases, run in the following order: | Phase | Description | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `EarlyRead` | Use this phase to read any layout-affecting DOM properties and styles that are strictly necessary for subsequent calculation. Avoid this phase if possible, preferring the `Write` and `Read` phases. | -| `MixedReadWrite` | Default phase. Use for any operations need to both read and write layout-affecting properties and styles. Avoid this phase if possible, preferring the explicit `Write` and `Read` phases. | -| `Write` | Use this phase to write layout-affecting DOM properties and styles. | -| `Read` | Use this phase to read any layout-affecting DOM properties. | +| `earlyRead` | Use this phase to read any layout-affecting DOM properties and styles that are strictly necessary for subsequent calculation. Avoid this phase if possible, preferring the `write` and `read` phases. | +| `mixedReadWrite` | Default phase. Use for any operations need to both read and write layout-affecting properties and styles. Avoid this phase if possible, preferring the explicit `write` and `read` phases. | +| `write` | Use this phase to write layout-affecting DOM properties and styles. | +| `read` | Use this phase to read any layout-affecting DOM properties. | ## Lifecycle interfaces diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 65c9c1b1d8b3..a7636eedc1a1 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -27,19 +27,36 @@ export interface AfterContentInit { ngAfterContentInit(): void; } +// @public +export function afterNextRender(spec: { + earlyRead?: () => E; + write?: (...args: ɵFirstAvailable<[E]>) => W; + mixedReadWrite?: (...args: ɵFirstAvailable<[W, E]>) => M; + read?: (...args: ɵFirstAvailable<[M, W, E]>) => void; +}, opts?: Omit): AfterRenderRef; + // @public export function afterNextRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef; +// @public +export function afterRender(spec: { + earlyRead?: () => E; + write?: (...args: ɵFirstAvailable<[E]>) => W; + mixedReadWrite?: (...args: ɵFirstAvailable<[W, E]>) => M; + read?: (...args: ɵFirstAvailable<[M, W, E]>) => void; +}, opts?: Omit): AfterRenderRef; + // @public export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef; // @public export interface AfterRenderOptions { injector?: Injector; + // @deprecated phase?: AfterRenderPhase; } -// @public +// @public @deprecated export enum AfterRenderPhase { EarlyRead = 0, MixedReadWrite = 2, diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index b785be41623c..e7a278fb61dd 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -18,6 +18,7 @@ pkg_npm( validate = False, visibility = ["//packages/core:__pkg__"], deps = [ + "//packages/core/schematics/migrations/after-render-phase:bundle", "//packages/core/schematics/migrations/http-providers:bundle", "//packages/core/schematics/migrations/invalid-two-way-bindings:bundle", "//packages/core/schematics/ng-generate/control-flow-migration:bundle", diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index f102aea3a715..1ef729caa9bd 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -9,6 +9,11 @@ "version": "18.0.0", "description": "Replace deprecated HTTP related modules with provider functions", "factory": "./migrations/http-providers/bundle" + }, + "migration-after-render-phase": { + "version": "18.1.0", + "description": "Updates calls to afterRender with an explicit phase to the new API", + "factory": "./migrations/after-render-phase/bundle" } } } diff --git a/packages/core/schematics/migrations/after-render-phase/BUILD.bazel b/packages/core/schematics/migrations/after-render-phase/BUILD.bazel new file mode 100644 index 000000000000..7035156fc4db --- /dev/null +++ b/packages/core/schematics/migrations/after-render-phase/BUILD.bazel @@ -0,0 +1,33 @@ +load("//tools:defaults.bzl", "esbuild", "ts_library") + +package( + default_visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/google3:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], +) + +ts_library( + name = "after-render-phase", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + deps = [ + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) + +esbuild( + name = "bundle", + entry_point = ":index.ts", + external = [ + "@angular-devkit/*", + "typescript", + ], + format = "cjs", + platform = "node", + deps = [":after-render-phase"], +) diff --git a/packages/core/schematics/migrations/after-render-phase/index.ts b/packages/core/schematics/migrations/after-render-phase/index.ts new file mode 100644 index 000000000000..46bc036ce1e8 --- /dev/null +++ b/packages/core/schematics/migrations/after-render-phase/index.ts @@ -0,0 +1,58 @@ +/** + * @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 {Rule, SchematicsException, Tree, UpdateRecorder} from '@angular-devkit/schematics'; +import {relative} from 'path'; +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host'; +import {migrateFile} from './migration'; + +export default function (): Rule { + return async (tree: Tree) => { + const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + const allPaths = [...buildPaths, ...testPaths]; + + if (!allPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot run the afterRender phase migration.', + ); + } + + for (const tsconfigPath of allPaths) { + runMigration(tree, tsconfigPath, basePath); + } + }; +} + +function runMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const program = createMigrationProgram(tree, tsconfigPath, basePath); + const sourceFiles = program + .getSourceFiles() + .filter((sourceFile) => canMigrateFile(basePath, sourceFile, program)); + + for (const sourceFile of sourceFiles) { + let update: UpdateRecorder | null = null; + + const rewriter = (startPos: number, width: number, text: string | null) => { + if (update === null) { + // Lazily initialize update, because most files will not require migration. + update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + } + update.remove(startPos, width); + if (text !== null) { + update.insertLeft(startPos, text); + } + }; + migrateFile(sourceFile, program.getTypeChecker(), rewriter); + + if (update !== null) { + tree.commitUpdate(update); + } + } +} diff --git a/packages/core/schematics/migrations/after-render-phase/migration.ts b/packages/core/schematics/migrations/after-render-phase/migration.ts new file mode 100644 index 000000000000..6505e5b7a28b --- /dev/null +++ b/packages/core/schematics/migrations/after-render-phase/migration.ts @@ -0,0 +1,83 @@ +/** + * @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 ts from 'typescript'; +import {ChangeTracker} from '../../utils/change_tracker'; +import {getImportOfIdentifier, getImportSpecifier} from '../../utils/typescript/imports'; + +const CORE = '@angular/core'; +const AFTER_RENDER_PHASE_ENUM = 'AfterRenderPhase'; +const AFTER_RENDER_FNS = new Set(['afterRender', 'afterNextRender']); + +type RewriteFn = (startPos: number, width: number, text: string) => void; + +export function migrateFile( + sourceFile: ts.SourceFile, + typeChecker: ts.TypeChecker, + rewriteFn: RewriteFn, +) { + const changeTracker = new ChangeTracker(ts.createPrinter()); + const phaseEnum = getImportSpecifier(sourceFile, CORE, AFTER_RENDER_PHASE_ENUM); + + // Check if there are any imports of the `AfterRenderPhase` enum. + if (phaseEnum) { + // Remove the `AfterRenderPhase` enum import. + changeTracker.removeNode(phaseEnum); + ts.forEachChild(sourceFile, function visit(node: ts.Node) { + ts.forEachChild(node, visit); + + // Check if this is a function call of `afterRender` or `afterNextRender`. + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + AFTER_RENDER_FNS.has(getImportOfIdentifier(typeChecker, node.expression)?.name || '') + ) { + let phase: string | undefined; + const [callback, options] = node.arguments; + // Check if any `AfterRenderOptions` options were specified. + if (ts.isObjectLiteralExpression(options)) { + const phaseProp = options.properties.find((p) => p.name?.getText() === 'phase'); + // Check if the `phase` options is set. + if ( + phaseProp && + ts.isPropertyAssignment(phaseProp) && + ts.isPropertyAccessExpression(phaseProp.initializer) && + phaseProp.initializer.expression.getText() === AFTER_RENDER_PHASE_ENUM + ) { + phaseProp.initializer.expression; + phase = phaseProp.initializer.name.getText(); + // Remove the `phase` option. + if (options.properties.length === 1) { + changeTracker.removeNode(options); + } else { + const newOptions = ts.factory.createObjectLiteralExpression( + options.properties.filter((p) => p !== phaseProp), + ); + changeTracker.replaceNode(options, newOptions); + } + } + } + // If we found a phase, update the callback. + if (phase) { + phase = phase.substring(0, 1).toLocaleLowerCase() + phase.substring(1); + const spec = ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment(ts.factory.createIdentifier(phase), callback), + ]); + changeTracker.replaceNode(callback, spec); + } + } + }); + } + + // Write the changes. + for (const changesInFile of changeTracker.recordChanges().values()) { + for (const change of changesInFile) { + rewriteFn(change.start, change.removeLength ?? 0, change.text); + } + } +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index d8b566c0a941..975ea644eaa9 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -19,6 +19,8 @@ jasmine_node_test( data = [ "//packages/core/schematics:collection.json", "//packages/core/schematics:migrations.json", + "//packages/core/schematics/migrations/after-render-phase", + "//packages/core/schematics/migrations/after-render-phase:bundle", "//packages/core/schematics/migrations/http-providers", "//packages/core/schematics/migrations/http-providers:bundle", "//packages/core/schematics/migrations/invalid-two-way-bindings", diff --git a/packages/core/schematics/test/after_render_phase_spec.ts b/packages/core/schematics/test/after_render_phase_spec.ts new file mode 100644 index 000000000000..c43c4fee1337 --- /dev/null +++ b/packages/core/schematics/test/after_render_phase_spec.ts @@ -0,0 +1,176 @@ +/** + * @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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import {runfiles} from '@bazel/runfiles'; +import shx from 'shelljs'; + +describe('afterRender phase migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner.runSchematic('migration-after-render-phase', {}, tree); + } + + beforeEach(() => { + runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../migrations.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFile( + '/tsconfig.json', + JSON.stringify({ + compilerOptions: { + lib: ['es2015'], + strictNullChecks: true, + }, + }), + ); + + writeFile( + '/angular.json', + JSON.stringify({ + version: 1, + projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}}, + }), + ); + + previousWorkingDir = shx.pwd(); + tmpDirPath = getSystemPath(host.root); + + // Switch into the temporary directory path. This allows us to run + // the schematic against our custom unit test tree. + shx.cd(tmpDirPath); + }); + + it('should update afterRender phase flag', async () => { + writeFile( + '/index.ts', + ` + import { AfterRenderPhase, Directive, afterRender } from '@angular/core'; + + @Directive({ + selector: '[someDirective]' + }) + export class SomeDirective { + constructor() { + afterRender(() => { + console.log('read'); + }, {phase: AfterRenderPhase.Read}); + } + }`, + ); + + await runMigration(); + + const content = tree.readContent('/index.ts').replace(/\s+/g, ' '); + expect(content).not.toContain('AfterRenderPhase'); + expect(content).toContain(`afterRender({ read: () => { console.log('read'); } }, );`); + }); + + it('should update afterNextRender phase flag', async () => { + writeFile( + '/index.ts', + ` + import { AfterRenderPhase, Directive, afterNextRender } from '@angular/core'; + + @Directive({ + selector: '[someDirective]' + }) + export class SomeDirective { + constructor() { + afterNextRender(() => { + console.log('earlyRead'); + }, {phase: AfterRenderPhase.EarlyRead}); + } + }`, + ); + + await runMigration(); + + const content = tree.readContent('/index.ts').replace(/\s+/g, ' '); + expect(content).not.toContain('AfterRenderPhase'); + expect(content).toContain( + `afterNextRender({ earlyRead: () => { console.log('earlyRead'); } }, );`, + ); + }); + + it('should not update calls that do not specify phase flag', async () => { + const originalContent = ` + import { Directive, Injector, afterRender, afterNextRender, inject } from '@angular/core'; + + @Directive({ + selector: '[someDirective]' + }) + export class SomeDirective { + injector = inject(Injector); + + constructor() { + afterRender(() => { + console.log('default phase'); + }); + afterNextRender(() => { + console.log('default phase'); + }); + afterRender(() => { + console.log('default phase'); + }, {injector: this.injector}); + afterNextRender(() => { + console.log('default phase'); + }, {injector: this.injector}); + } + }`; + writeFile('/index.ts', originalContent); + + await runMigration(); + + const content = tree.readContent('/index.ts').replace(/\s+/g, ' '); + expect(content).toEqual(originalContent.replace(/\s+/g, ' ')); + }); + + it('should not change options other than phase', async () => { + writeFile( + '/index.ts', + ` + import { AfterRenderPhase, Directive, Injector, afterRender, inject } from '@angular/core'; + + @Directive({ + selector: '[someDirective]' + }) + export class SomeDirective { + injector = inject(Injector); + + constructor() { + afterRender(() => { + console.log('earlyRead'); + }, { + phase: AfterRenderPhase.EarlyRead, + injector: this.injector + }); + } + }`, + ); + + await runMigration(); + const content = tree.readContent('/index.ts').replace(/\s+/g, ' '); + expect(content).toContain( + `afterRender({ earlyRead: () => { console.log('earlyRead'); } }, { injector: this.injector });`, + ); + }); +}); diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 236faa476922..d670ff87684b 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -104,6 +104,7 @@ export { AfterRenderPhase, afterRender, afterNextRender, + ɵFirstAvailable, } from './render3/after_render_hooks'; export {ApplicationConfig, mergeApplicationConfig} from './application/application_config'; export {makeStateKey, StateKey, TransferState} from './transfer_state'; diff --git a/packages/core/src/render3/after_render_hooks.ts b/packages/core/src/render3/after_render_hooks.ts index 4ed40d158bda..0b0e862023e5 100644 --- a/packages/core/src/render3/after_render_hooks.ts +++ b/packages/core/src/render3/after_render_hooks.ts @@ -10,7 +10,7 @@ import { ChangeDetectionScheduler, NotificationSource, } from '../change_detection/scheduling/zoneless_scheduling'; -import {assertInInjectionContext, Injector, runInInjectionContext, ɵɵdefineInjectable} from '../di'; +import {Injector, assertInInjectionContext, runInInjectionContext, ɵɵdefineInjectable} from '../di'; import {inject} from '../di/injector_compatibility'; import {ErrorHandler} from '../error_handler'; import {DestroyRef} from '../linker/destroy_ref'; @@ -20,6 +20,16 @@ import {NgZone} from '../zone/ng_zone'; import {isPlatformBrowser} from './util/misc_utils'; +/** + * An argument list containing the first non-never type in the given type array, or an empty + * argument list if there are no non-never types in the type array. + */ +export type ɵFirstAvailable = T extends [infer H, ...infer R] + ? [H] extends [never] + ? ɵFirstAvailable + : [H] + : []; + /** * The phase to run an `afterRender` or `afterNextRender` callback in. * @@ -38,14 +48,16 @@ import {isPlatformBrowser} from './util/misc_utils'; * manual DOM access, ensuring the best experience for the end users of your application * or library. * - * @developerPreview + * @deprecated Specify the phase for your callback to run in by passing a spec-object as the first + * parameter to `afterRender` or `afterNextRender` insetad of a function. */ export enum AfterRenderPhase { /** * Use `AfterRenderPhase.EarlyRead` for callbacks that only need to **read** from the * DOM before a subsequent `AfterRenderPhase.Write` callback, for example to perform - * custom layout that the browser doesn't natively support. **Never** use this phase - * for callbacks that can write to the DOM or when `AfterRenderPhase.Read` is adequate. + * custom layout that the browser doesn't natively support. Prefer the + * `AfterRenderPhase.EarlyRead` phase if reading can wait until after the write phase. + * **Never** write to the DOM in this phase. * *
* @@ -58,19 +70,19 @@ export enum AfterRenderPhase { /** * Use `AfterRenderPhase.Write` for callbacks that only **write** to the DOM. **Never** - * use this phase for callbacks that can read from the DOM. + * read from the DOM in this phase. */ Write, /** * Use `AfterRenderPhase.MixedReadWrite` for callbacks that read from or write to the - * DOM, that haven't been refactored to use a different phase. **Never** use this phase - * for callbacks that can use a different phase instead. + * DOM, that haven't been refactored to use a different phase. **Never** use this phase if + * it is possible to divide the work among the other phases instead. * *
* * Using this value can **significantly** degrade performance. - * Instead, prefer refactoring into multiple callbacks using a more specific phase. + * Instead, prefer dividing work into the appropriate phase callbacks. * *
*/ @@ -78,7 +90,7 @@ export enum AfterRenderPhase { /** * Use `AfterRenderPhase.Read` for callbacks that only **read** from the DOM. **Never** - * use this phase for callbacks that can write to the DOM. + * write to the DOM in this phase. */ Read, } @@ -105,6 +117,9 @@ export interface AfterRenderOptions { * phase instead. See `AfterRenderPhase` for more information. * *
+ * + * @deprecated Specify the phase for your callback to run in by passing a spec-object as the first + * parameter to `afterRender` or `afterNextRender` insetad of a function. */ phase?: AfterRenderPhase; } @@ -174,13 +189,99 @@ export function internalAfterNextRender( } /** - * Register a callback to be invoked each time the application - * finishes rendering. + * Register callbacks to be invoked each time the application finishes rendering, during the + * specified phases. The available phases are: + * - `earlyRead` + * Use this phase to **read** from the DOM before a subsequent `write` callback, for example to + * perform custom layout that the browser doesn't natively support. Prefer the `read` phase if + * reading can wait until after the write phase. **Never** write to the DOM in this phase. + * - `write` + * Use this phase to **write** to the DOM. **Never** read from the DOM in this phase. + * - `mixedReadWrite` + * Use this phase to read from and write to the DOM simultaneously. **Never** use this phase if + * it is possible to divide the work among the other phases instead. + * - `read` + * Use this phase to **read** from the DOM. **Never** write to the DOM in this phase. * *
* - * You should always explicitly specify a non-default [phase](api/core/AfterRenderPhase), or you - * risk significant performance degradation. + * You should prefer using the `read` and `write` phases over the `earlyRead` and `mixedReadWrite` + * phases when possible, to avoid performance degradation. + * + *
+ * + * Note that: + * - Callbacks run in the following phase order *after each render*: + * 1. `earlyRead` + * 2. `write` + * 3. `mixedReadWrite` + * 4. `read` + * - Callbacks in the same phase run in the order they are registered. + * - Callbacks run on browser platforms only, they will not run on the server. + * + * The first phase callback to run as part of this spec will receive no parameters. Each + * subsequent phase callback in this spec will receive the return value of the previously run + * phase callback as a parameter. This can be used to coordinate work across multiple phases. + * + * Angular is unable to verify or enforce that phases are used correctly, and instead + * relies on each developer to follow the guidelines documented for each value and + * carefully choose the appropriate one, refactoring their code if necessary. By doing + * so, Angular is better able to minimize the performance degradation associated with + * manual DOM access, ensuring the best experience for the end users of your application + * or library. + * + *
+ * + * 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 spec The callback functions 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({ + * read: () => { + * console.log('content height: ' + this.contentRef.nativeElement.scrollHeight); + * } + * }); + * } + * } + * ``` + * + * @developerPreview + */ +export function afterRender( + spec: { + earlyRead?: () => E; + write?: (...args: ɵFirstAvailable<[E]>) => W; + mixedReadWrite?: (...args: ɵFirstAvailable<[W, E]>) => M; + read?: (...args: ɵFirstAvailable<[M, W, E]>) => void; + }, + opts?: Omit, +): AfterRenderRef; + +/** + * Register a callback to be invoked each time the application finishes rendering, during the + * `mixedReadWrite` phase. + * + *
+ * + * You should prefer specifying an explicit phase for the callback instead, or you risk significant + * performance degradation. * *
* @@ -188,6 +289,7 @@ export function internalAfterNextRender( * - in the order it was registered * - once per render * - on browser platforms only + * - during the `mixedReadWrite` phase * *
* @@ -212,16 +314,30 @@ export function internalAfterNextRender( * @ViewChild('content') contentRef: ElementRef; * * constructor() { - * afterRender(() => { - * console.log('content height: ' + this.contentRef.nativeElement.scrollHeight); - * }, {phase: AfterRenderPhase.Read}); + * afterRender({ + * read: () => { + * console.log('content height: ' + this.contentRef.nativeElement.scrollHeight); + * } + * }); * } * } * ``` * * @developerPreview */ -export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef { +export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef; + +export function afterRender( + callbackOrSpec: + | VoidFunction + | { + earlyRead?: () => unknown; + write?: (r?: unknown) => unknown; + mixedReadWrite?: (r?: unknown) => unknown; + read?: (r?: unknown) => void; + }, + options?: AfterRenderOptions, +): AfterRenderRef { ngDevMode && assertNotInReactiveContext( afterRender, @@ -238,37 +354,117 @@ export function afterRender(callback: VoidFunction, options?: AfterRenderOptions performanceMarkFeature('NgAfterRender'); - const afterRenderEventManager = injector.get(AfterRenderEventManager); - // Lazily initialize the handler implementation, if necessary. This is so that it can be - // tree-shaken if `afterRender` and `afterNextRender` aren't used. - const callbackHandler = (afterRenderEventManager.handler ??= - new AfterRenderCallbackHandlerImpl()); - const phase = options?.phase ?? AfterRenderPhase.MixedReadWrite; - const destroy = () => { - callbackHandler.unregister(instance); - unregisterFn(); - }; - const unregisterFn = injector.get(DestroyRef).onDestroy(destroy); - const instance = runInInjectionContext(injector, () => new AfterRenderCallback(phase, callback)); - - callbackHandler.register(instance); - return {destroy}; + return afterRenderImpl( + callbackOrSpec, + injector, + /* once */ false, + options?.phase ?? AfterRenderPhase.MixedReadWrite, + ); } /** - * Register a callback to be invoked the next time the application - * finishes rendering. + * Register callbacks to be invoked the next time the application finishes rendering, during the + * specified phases. The available phases are: + * - `earlyRead` + * Use this phase to **read** from the DOM before a subsequent `write` callback, for example to + * perform custom layout that the browser doesn't natively support. Prefer the `read` phase if + * reading can wait until after the write phase. **Never** write to the DOM in this phase. + * - `write` + * Use this phase to **write** to the DOM. **Never** read from the DOM in this phase. + * - `mixedReadWrite` + * Use this phase to read from and write to the DOM simultaneously. **Never** use this phase if + * it is possible to divide the work among the other phases instead. + * - `read` + * Use this phase to **read** from the DOM. **Never** write to the DOM in this phase. * *
* - * You should always explicitly specify a non-default [phase](api/core/AfterRenderPhase), or you - * risk significant performance degradation. + * You should prefer using the `read` and `write` phases over the `earlyRead` and `mixedReadWrite` + * phases when possible, to avoid performance degradation. + * + *
+ * + * Note that: + * - Callbacks run in the following phase order *once, after the next render*: + * 1. `earlyRead` + * 2. `write` + * 3. `mixedReadWrite` + * 4. `read` + * - Callbacks in the same phase run in the order they are registered. + * - Callbacks run on browser platforms only, they will not run on the server. + * + * The first phase callback to run as part of this spec will receive no parameters. Each + * subsequent phase callback in this spec will receive the return value of the previously run + * phase callback as a parameter. This can be used to coordinate work across multiple phases. + * + * Angular is unable to verify or enforce that phases are used correctly, and instead + * relies on each developer to follow the guidelines documented for each value and + * carefully choose the appropriate one, refactoring their code if necessary. By doing + * so, Angular is better able to minimize the performance degradation associated with + * manual DOM access, ensuring the best experience for the end users of your application + * or library. + * + *
+ * + * 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 spec The callback functions 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({ + * write: () => { + * this.chart = new MyChart(this.chartRef.nativeElement); + * } + * }); + * } + * } + * ``` + * + * @developerPreview + */ +export function afterNextRender( + spec: { + earlyRead?: () => E; + write?: (...args: ɵFirstAvailable<[E]>) => W; + mixedReadWrite?: (...args: ɵFirstAvailable<[W, E]>) => M; + read?: (...args: ɵFirstAvailable<[M, W, E]>) => void; + }, + opts?: Omit, +): AfterRenderRef; + +/** + * Register a callback to be invoked the next time the application finishes rendering, during the + * `mixedReadWrite` phase. + * + *
+ * + * You should prefer specifying an explicit phase for the callback instead, or you risk significant + * performance degradation. * *
* * Note that the callback will run * - in the order it was registered * - on browser platforms only + * - during the `mixedReadWrite` phase * *
* @@ -295,9 +491,11 @@ export function afterRender(callback: VoidFunction, options?: AfterRenderOptions * chart: MyChart|null; * * constructor() { - * afterNextRender(() => { - * this.chart = new MyChart(this.chartRef.nativeElement); - * }, {phase: AfterRenderPhase.Write}); + * afterNextRender({ + * write: () => { + * this.chart = new MyChart(this.chartRef.nativeElement); + * } + * }); * } * } * ``` @@ -307,6 +505,18 @@ export function afterRender(callback: VoidFunction, options?: AfterRenderOptions export function afterNextRender( callback: VoidFunction, options?: AfterRenderOptions, +): AfterRenderRef; + +export function afterNextRender( + callbackOrSpec: + | VoidFunction + | { + earlyRead?: () => unknown; + write?: (r?: unknown) => unknown; + mixedReadWrite?: (r?: unknown) => unknown; + read?: (r?: unknown) => void; + }, + options?: AfterRenderOptions, ): AfterRenderRef { !options && assertInInjectionContext(afterNextRender); const injector = options?.injector ?? inject(Injector); @@ -317,27 +527,101 @@ export function afterNextRender( performanceMarkFeature('NgAfterNextRender'); + return afterRenderImpl( + callbackOrSpec, + injector, + /* once */ true, + options?.phase ?? AfterRenderPhase.MixedReadWrite, + ); +} + +function getSpec( + callbackOrSpec: + | VoidFunction + | { + earlyRead?: () => unknown; + write?: (r?: unknown) => unknown; + mixedReadWrite?: (r?: unknown) => unknown; + read?: (r?: unknown) => void; + }, + phase: AfterRenderPhase, +) { + if (callbackOrSpec instanceof Function) { + switch (phase) { + case AfterRenderPhase.EarlyRead: + return {earlyRead: callbackOrSpec}; + case AfterRenderPhase.Write: + return {write: callbackOrSpec}; + case AfterRenderPhase.MixedReadWrite: + return {mixedReadWrite: callbackOrSpec}; + case AfterRenderPhase.Read: + return {read: callbackOrSpec}; + } + } + return callbackOrSpec; +} + +/** + * Shared implementation for `afterRender` and `afterNextRender`. + */ +function afterRenderImpl( + callbackOrSpec: + | VoidFunction + | { + earlyRead?: () => unknown; + write?: (r?: unknown) => unknown; + mixedReadWrite?: (r?: unknown) => unknown; + read?: (r?: unknown) => void; + }, + injector: Injector, + once: boolean, + phase: AfterRenderPhase, +): AfterRenderRef { + const spec = getSpec(callbackOrSpec, phase); const afterRenderEventManager = injector.get(AfterRenderEventManager); // Lazily initialize the handler implementation, if necessary. This is so that it can be // tree-shaken if `afterRender` and `afterNextRender` aren't used. const callbackHandler = (afterRenderEventManager.handler ??= new AfterRenderCallbackHandlerImpl()); - const phase = options?.phase ?? AfterRenderPhase.MixedReadWrite; + + const pipelinedArgs: [] | [unknown] = []; + const instances: AfterRenderCallback[] = []; + const destroy = () => { - callbackHandler.unregister(instance); + for (const instance of instances) { + callbackHandler.unregister(instance); + } unregisterFn(); }; const unregisterFn = injector.get(DestroyRef).onDestroy(destroy); - const instance = runInInjectionContext( - injector, - () => - new AfterRenderCallback(phase, () => { - destroy(); - callback(); - }), - ); - callbackHandler.register(instance); + const registerCallback = ( + phase: AfterRenderPhase, + phaseCallback: undefined | ((...args: unknown[]) => unknown), + ) => { + if (!phaseCallback) { + return; + } + const callback = once + ? (...args: [unknown]) => { + destroy(); + phaseCallback(...args); + } + : phaseCallback; + + const instance = runInInjectionContext( + injector, + () => new AfterRenderCallback(phase, pipelinedArgs, callback), + ); + callbackHandler.register(instance); + instances.push(instance); + }; + + registerCallback(AfterRenderPhase.EarlyRead, spec.earlyRead); + registerCallback(AfterRenderPhase.Write, spec.write); + registerCallback(AfterRenderPhase.MixedReadWrite, spec.mixedReadWrite); + registerCallback(AfterRenderPhase.Read, spec.read); + return {destroy}; } @@ -350,7 +634,8 @@ class AfterRenderCallback { constructor( readonly phase: AfterRenderPhase, - private callbackFn: VoidFunction, + private pipelinedArgs: [] | [unknown], + private callbackFn: (...args: unknown[]) => unknown, ) { // Registering a callback will notify the scheduler. inject(ChangeDetectionScheduler, {optional: true})?.notify(NotificationSource.NewRenderHook); @@ -358,7 +643,11 @@ class AfterRenderCallback { invoke() { try { - this.zone.runOutsideAngular(this.callbackFn); + const result = this.zone.runOutsideAngular(() => + this.callbackFn.apply(null, this.pipelinedArgs as [unknown]), + ); + // Clear out the args and add the result which will be passed to the next phase. + this.pipelinedArgs.splice(0, this.pipelinedArgs.length, result); } catch (err) { this.errorHandler?.handleError(err); } diff --git a/packages/core/test/acceptance/after_render_hook_spec.ts b/packages/core/test/acceptance/after_render_hook_spec.ts index 6306c341afb3..706e126b1595 100644 --- a/packages/core/test/acceptance/after_render_hook_spec.ts +++ b/packages/core/test/acceptance/after_render_hook_spec.ts @@ -8,37 +8,37 @@ import {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '@angular/common/src/platform_id'; import { - afterNextRender, - afterRender, - AfterRenderPhase, AfterRenderRef, ApplicationRef, ChangeDetectorRef, Component, - computed, - createComponent, - effect, ErrorHandler, - inject, Injector, NgZone, PLATFORM_ID, - signal, Type, - untracked, ViewContainerRef, + afterNextRender, + afterRender, + computed, + createComponent, + effect, + inject, ɵinternalAfterNextRender as internalAfterNextRender, ɵqueueStateUpdate as queueStateUpdate, + signal, + untracked, } from '@angular/core'; import {NoopNgZone} from '@angular/core/src/zone/ng_zone'; import {TestBed} from '@angular/core/testing'; import {bootstrapApplication} from '@angular/platform-browser'; import {withBody} from '@angular/private/testing'; -import {destroyPlatform} from '../../src/core'; -import {EnvironmentInjector} from '../../src/di'; +import {AfterRenderPhase} from '@angular/core/src/render3/after_render_hooks'; import {firstValueFrom} from 'rxjs'; import {filter} from 'rxjs/operators'; +import {destroyPlatform} from '../../src/core'; +import {EnvironmentInjector} from '../../src/di'; function createAndAttachComponent(component: Type) { const componentRef = createComponent(component, { @@ -62,12 +62,11 @@ describe('after render hooks', () => { class Comp { constructor() { // Helper to register into each phase - function forEachPhase(fn: (phase: AfterRenderPhase) => void) { - for (const phase in AfterRenderPhase) { - const val = AfterRenderPhase[phase]; - if (typeof val === 'number') { - fn(val); - } + function forEachPhase( + fn: (phase: 'earlyRead' | 'write' | 'mixedReadWrite' | 'read') => void, + ) { + for (const phase of ['earlyRead', 'write', 'mixedReadWrite', 'read'] as const) { + fn(phase); } } @@ -76,12 +75,11 @@ describe('after render hooks', () => { }); forEachPhase((phase) => - afterRender( - () => { - log.push(`afterRender (${AfterRenderPhase[phase]})`); + afterRender({ + [phase]: () => { + log.push(`afterRender (${phase})`); }, - {phase}, - ), + }), ); internalAfterNextRender(() => { @@ -89,12 +87,11 @@ describe('after render hooks', () => { }); forEachPhase((phase) => - afterNextRender( - () => { - log.push(`afterNextRender (${AfterRenderPhase[phase]})`); + afterNextRender({ + [phase]: () => { + log.push(`afterNextRender (${phase})`); }, - {phase}, - ), + }), ); internalAfterNextRender(() => { @@ -118,24 +115,24 @@ describe('after render hooks', () => { 'internalAfterNextRender #1', 'internalAfterNextRender #2', 'internalAfterNextRender #3', - 'afterRender (EarlyRead)', - 'afterNextRender (EarlyRead)', - 'afterRender (Write)', - 'afterNextRender (Write)', - 'afterRender (MixedReadWrite)', - 'afterNextRender (MixedReadWrite)', - 'afterRender (Read)', - 'afterNextRender (Read)', + 'afterRender (earlyRead)', + 'afterNextRender (earlyRead)', + 'afterRender (write)', + 'afterNextRender (write)', + 'afterRender (mixedReadWrite)', + 'afterNextRender (mixedReadWrite)', + 'afterRender (read)', + 'afterNextRender (read)', ]); // Running change detection again log.length = 0; TestBed.inject(ApplicationRef).tick(); expect(log).toEqual([ - 'afterRender (EarlyRead)', - 'afterRender (Write)', - 'afterRender (MixedReadWrite)', - 'afterRender (Read)', + 'afterRender (earlyRead)', + 'afterRender (write)', + 'afterRender (mixedReadWrite)', + 'afterRender (read)', ]); }); @@ -493,8 +490,10 @@ describe('after render hooks', () => { createAndAttachComponent(Comp); expect(zoneLog).toEqual([]); - TestBed.inject(ApplicationRef).tick(); - expect(zoneLog).toEqual([false]); + TestBed.inject(NgZone).run(() => { + TestBed.inject(ApplicationRef).tick(); + expect(zoneLog).toEqual([false]); + }); }); it('should propagate errors to the ErrorHandler', () => { @@ -547,6 +546,90 @@ describe('after render hooks', () => { @Component({selector: 'root', template: ``}) class Root {} + @Component({selector: 'comp-a'}) + class CompA { + constructor() { + afterRender({ + earlyRead: () => { + log.push('early-read-1'); + }, + }); + + afterRender({ + write: () => { + log.push('write-1'); + }, + }); + + afterRender({ + mixedReadWrite: () => { + log.push('mixed-read-write-1'); + }, + }); + + afterRender({ + read: () => { + log.push('read-1'); + }, + }); + } + } + + @Component({selector: 'comp-b'}) + class CompB { + constructor() { + afterRender({ + read: () => { + log.push('read-2'); + }, + }); + + afterRender({ + mixedReadWrite: () => { + log.push('mixed-read-write-2'); + }, + }); + + afterRender({ + write: () => { + log.push('write-2'); + }, + }); + + afterRender({ + earlyRead: () => { + log.push('early-read-2'); + }, + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Root, CompA, CompB], + ...COMMON_CONFIGURATION, + }); + createAndAttachComponent(Root); + + expect(log).toEqual([]); + TestBed.inject(ApplicationRef).tick(); + expect(log).toEqual([ + 'early-read-1', + 'early-read-2', + 'write-1', + 'write-2', + 'mixed-read-write-1', + 'mixed-read-write-2', + 'read-1', + 'read-2', + ]); + }); + + it('should run callbacks in the correct phase and order when using deprecated phase flag', () => { + const log: string[] = []; + + @Component({selector: 'root', template: ``}) + class Root {} + @Component({selector: 'comp-a'}) class CompA { constructor() { @@ -633,6 +716,96 @@ describe('after render hooks', () => { ]); }); + it('should schedule callbacks for multiple phases at once', () => { + const log: string[] = []; + + @Component({selector: 'comp'}) + class Comp { + constructor() { + afterRender({ + earlyRead: () => { + log.push('early-read-1'); + }, + write: () => { + log.push('write-1'); + }, + mixedReadWrite: () => { + log.push('mixed-read-write-1'); + }, + read: () => { + log.push('read-1'); + }, + }); + + afterRender(() => { + log.push('mixed-read-write-2'); + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + createAndAttachComponent(Comp); + + expect(log).toEqual([]); + TestBed.inject(ApplicationRef).tick(); + expect(log).toEqual([ + 'early-read-1', + 'write-1', + 'mixed-read-write-1', + 'mixed-read-write-2', + 'read-1', + ]); + }); + + it('should pass data between phases', () => { + const log: string[] = []; + + @Component({selector: 'comp'}) + class Comp { + constructor() { + afterRender({ + earlyRead: () => 'earlyRead result', + write: (results) => { + log.push(`results for write: ${results}`); + return 5; + }, + mixedReadWrite: (results) => { + log.push(`results for mixedReadWrite: ${results}`); + return undefined; + }, + read: (results) => { + log.push(`results for read: ${results}`); + }, + }); + + afterRender({ + earlyRead: () => 'earlyRead 2 result', + read: (results) => { + log.push(`results for read 2: ${results}`); + }, + }); + } + } + + TestBed.configureTestingModule({ + declarations: [Comp], + ...COMMON_CONFIGURATION, + }); + createAndAttachComponent(Comp); + + expect(log).toEqual([]); + TestBed.inject(ApplicationRef).tick(); + expect(log).toEqual([ + 'results for write: earlyRead result', + 'results for mixedReadWrite: 5', + 'results for read: undefined', + 'results for read 2: earlyRead 2 result', + ]); + }); + describe('throw error inside reactive context', () => { it('inside template effect', () => { @Component({template: `{{someFn()}}`}) @@ -927,8 +1100,10 @@ describe('after render hooks', () => { createAndAttachComponent(Comp); expect(zoneLog).toEqual([]); - TestBed.inject(ApplicationRef).tick(); - expect(zoneLog).toEqual([false]); + TestBed.inject(NgZone).run(() => { + TestBed.inject(ApplicationRef).tick(); + expect(zoneLog).toEqual([false]); + }); }); it('should propagate errors to the ErrorHandler', () => { @@ -984,66 +1159,58 @@ describe('after render hooks', () => { @Component({selector: 'comp-a'}) class CompA { constructor() { - afterNextRender( - () => { + afterNextRender({ + earlyRead: () => { log.push('early-read-1'); }, - {phase: AfterRenderPhase.EarlyRead}, - ); + }); - afterNextRender( - () => { + afterNextRender({ + write: () => { log.push('write-1'); }, - {phase: AfterRenderPhase.Write}, - ); + }); - afterNextRender( - () => { + afterNextRender({ + mixedReadWrite: () => { log.push('mixed-read-write-1'); }, - {phase: AfterRenderPhase.MixedReadWrite}, - ); + }); - afterNextRender( - () => { + afterNextRender({ + read: () => { log.push('read-1'); }, - {phase: AfterRenderPhase.Read}, - ); + }); } } @Component({selector: 'comp-b'}) class CompB { constructor() { - afterNextRender( - () => { + afterNextRender({ + read: () => { log.push('read-2'); }, - {phase: AfterRenderPhase.Read}, - ); + }); - afterNextRender( - () => { + afterNextRender({ + mixedReadWrite: () => { log.push('mixed-read-write-2'); }, - {phase: AfterRenderPhase.MixedReadWrite}, - ); + }); - afterNextRender( - () => { + afterNextRender({ + write: () => { log.push('write-2'); }, - {phase: AfterRenderPhase.Write}, - ); + }); - afterNextRender( - () => { + afterNextRender({ + earlyRead: () => { log.push('early-read-2'); }, - {phase: AfterRenderPhase.EarlyRead}, - ); + }); } }