Skip to content

Commit a655e46

Browse files
mmalerbaalxhub
authored andcommittedJun 10, 2024
feat(core): Redesign the afterRender & afterNextRender phases API (#55648)
Previously `afterRender` and `afterNextRender` allowed the user to pass a phase to run the callback in as part of the `AfterRenderOptions`. This worked, but made it cumbersome to coordinate work between phases. ```ts let size: DOMRect|null = null; afterRender(() => { size = nativeEl.getBoundingClientRect(); }, {phase: AfterRenderPhase.EarlyRead}); afterRender(() => { otherNativeEl.style.width = size!.width + 'px'; }, {phase: AfterRenderPhase.Write}); ``` This PR replaces the old phases API with a new one that allows passing a callback per phase in a single `afterRender` / `afterNextRender` call. The return value of each phase's callback is passed to the subsequent callbacks that were part of that `afterRender` call. ```ts afterRender({ earlyRead: () => nativeEl.getBoundingClientRect(), write: (rect) => { otherNativeEl.style.width = rect.width + 'px'; } }); ``` This API also retains the ability to pass a single callback, which will be run in the `mixedReadWrite` phase. ```ts afterRender(() => { // read some stuff ... // write some stuff ... }); ``` PR Close #55648
1 parent 6db94d5 commit a655e46

File tree

5 files changed

+535
-202
lines changed

5 files changed

+535
-202
lines changed
 

Diff for: ‎adev/src/content/guide/components/lifecycle.md

+30-18
Original file line numberDiff line numberDiff line change
@@ -239,30 +239,42 @@ Render callbacks do not run during server-side rendering or during build-time pr
239239

240240
#### afterRender phases
241241

242-
When using `afterRender` or `afterNextRender`, you can optionally specify a `phase`. The phase
243-
gives you control over the sequencing of DOM operations, letting you sequence _write_ operations
244-
before _read_ operations in order to minimize
245-
[layout thrashing](https://web.dev/avoid-large-complex-layouts-and-layout-thrashing).
242+
When using `afterRender` or `afterNextRender`, you can optionally split the work into phases. The
243+
phase gives you control over the sequencing of DOM operations, letting you sequence _write_
244+
operations before _read_ operations in order to minimize
245+
[layout thrashing](https://web.dev/avoid-large-complex-layouts-and-layout-thrashing). In order to
246+
communicate across phases, a phase function may return a result value that can be accessed in the
247+
next phase.
246248

247249
```ts
248-
import {Component, ElementRef, afterNextRender, AfterRenderPhase} from '@angular/core';
250+
import {Component, ElementRef, afterNextRender} from '@angular/core';
249251

250252
@Component({...})
251253
export class UserProfile {
254+
private prevPadding = 0;
252255
private elementHeight = 0;
253256

254257
constructor(elementRef: ElementRef) {
255258
const nativeElement = elementRef.nativeElement;
256259

257-
// Use the `Write` phase to write to a geometric property.
258-
afterNextRender(() => {
259-
nativeElement.style.padding = computePadding();
260-
}, {phase: AfterRenderPhase.Write});
261-
262-
// Use the `Read` phase to read geometric properties after all writes have occurred.
263-
afterNextRender(() => {
264-
this.elementHeight = nativeElement.getBoundingClientRect().height;
265-
}, {phase: AfterRenderPhase.Read});
260+
afterNextRender({
261+
// Use the `Write` phase to write to a geometric property.
262+
write: () => {
263+
const padding = computePadding();
264+
const changed = padding !== prevPadding;
265+
if (changed) {
266+
nativeElement.style.padding = padding;
267+
}
268+
return changed; // Communicate whether anything changed to the read phase.
269+
},
270+
271+
// Use the `Read` phase to read geometric properties after all writes have occurred.
272+
read: (didWrite) => {
273+
if (didWrite) {
274+
this.elementHeight = nativeElement.getBoundingClientRect().height;
275+
}
276+
}
277+
});
266278
}
267279
}
268280
```
@@ -271,10 +283,10 @@ There are four phases, run in the following order:
271283

272284
| Phase | Description |
273285
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
274-
| `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. |
275-
| `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. |
276-
| `Write` | Use this phase to write layout-affecting DOM properties and styles. |
277-
| `Read` | Use this phase to read any layout-affecting DOM properties. |
286+
| `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. |
287+
| `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. |
288+
| `write` | Use this phase to write layout-affecting DOM properties and styles. |
289+
| `read` | Use this phase to read any layout-affecting DOM properties. |
278290

279291
## Lifecycle interfaces
280292

Diff for: ‎goldens/public-api/core/index.api.md

+16-9
Original file line numberDiff line numberDiff line change
@@ -27,24 +27,31 @@ export interface AfterContentInit {
2727
ngAfterContentInit(): void;
2828
}
2929

30+
// @public
31+
export function afterNextRender<E = never, W = never, M = never>(spec: {
32+
earlyRead?: () => E;
33+
write?: (...args: ɵFirstAvailable<[E]>) => W;
34+
mixedReadWrite?: (...args: ɵFirstAvailable<[W, E]>) => M;
35+
read?: (...args: ɵFirstAvailable<[M, W, E]>) => void;
36+
}, opts?: AfterRenderOptions): AfterRenderRef;
37+
3038
// @public
3139
export function afterNextRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef;
3240

41+
// @public
42+
export function afterRender<E = never, W = never, M = never>(spec: {
43+
earlyRead?: () => E;
44+
write?: (...args: ɵFirstAvailable<[E]>) => W;
45+
mixedReadWrite?: (...args: ɵFirstAvailable<[W, E]>) => M;
46+
read?: (...args: ɵFirstAvailable<[M, W, E]>) => void;
47+
}, opts?: AfterRenderOptions): AfterRenderRef;
48+
3349
// @public
3450
export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef;
3551

3652
// @public
3753
export interface AfterRenderOptions {
3854
injector?: Injector;
39-
phase?: AfterRenderPhase;
40-
}
41-
42-
// @public
43-
export enum AfterRenderPhase {
44-
EarlyRead = 0,
45-
MixedReadWrite = 2,
46-
Read = 3,
47-
Write = 1
4855
}
4956

5057
// @public

Diff for: ‎packages/core/src/core.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,9 @@ export {isStandalone} from './render3/definition';
101101
export {
102102
AfterRenderRef,
103103
AfterRenderOptions,
104-
AfterRenderPhase,
105104
afterRender,
106105
afterNextRender,
106+
ɵFirstAvailable,
107107
} from './render3/after_render_hooks';
108108
export {ApplicationConfig, mergeApplicationConfig} from './application/application_config';
109109
export {makeStateKey, StateKey, TransferState} from './transfer_state';

Diff for: ‎packages/core/src/render3/after_render_hooks.ts

+307-67
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
ChangeDetectionScheduler,
1111
NotificationSource,
1212
} from '../change_detection/scheduling/zoneless_scheduling';
13-
import {assertInInjectionContext, Injector, runInInjectionContext, ɵɵdefineInjectable} from '../di';
13+
import {Injector, assertInInjectionContext, runInInjectionContext, ɵɵdefineInjectable} from '../di';
1414
import {inject} from '../di/injector_compatibility';
1515
import {ErrorHandler} from '../error_handler';
1616
import {DestroyRef} from '../linker/destroy_ref';
@@ -20,6 +20,16 @@ import {NgZone} from '../zone/ng_zone';
2020

2121
import {isPlatformBrowser} from './util/misc_utils';
2222

23+
/**
24+
* An argument list containing the first non-never type in the given type array, or an empty
25+
* argument list if there are no non-never types in the type array.
26+
*/
27+
export type ɵFirstAvailable<T extends unknown[]> = T extends [infer H, ...infer R]
28+
? [H] extends [never]
29+
? ɵFirstAvailable<R>
30+
: [H]
31+
: [];
32+
2333
/**
2434
* The phase to run an `afterRender` or `afterNextRender` callback in.
2535
*
@@ -37,15 +47,14 @@ import {isPlatformBrowser} from './util/misc_utils';
3747
* so, Angular is better able to minimize the performance degradation associated with
3848
* manual DOM access, ensuring the best experience for the end users of your application
3949
* or library.
40-
*
41-
* @developerPreview
4250
*/
43-
export enum AfterRenderPhase {
51+
enum AfterRenderPhase {
4452
/**
4553
* Use `AfterRenderPhase.EarlyRead` for callbacks that only need to **read** from the
4654
* DOM before a subsequent `AfterRenderPhase.Write` callback, for example to perform
47-
* custom layout that the browser doesn't natively support. **Never** use this phase
48-
* for callbacks that can write to the DOM or when `AfterRenderPhase.Read` is adequate.
55+
* custom layout that the browser doesn't natively support. Prefer the
56+
* `AfterRenderPhase.EarlyRead` phase if reading can wait until after the write phase.
57+
* **Never** write to the DOM in this phase.
4958
*
5059
* <div class="alert is-important">
5160
*
@@ -58,27 +67,27 @@ export enum AfterRenderPhase {
5867

5968
/**
6069
* Use `AfterRenderPhase.Write` for callbacks that only **write** to the DOM. **Never**
61-
* use this phase for callbacks that can read from the DOM.
70+
* read from the DOM in this phase.
6271
*/
6372
Write,
6473

6574
/**
6675
* Use `AfterRenderPhase.MixedReadWrite` for callbacks that read from or write to the
67-
* DOM, that haven't been refactored to use a different phase. **Never** use this phase
68-
* for callbacks that can use a different phase instead.
76+
* DOM, that haven't been refactored to use a different phase. **Never** use this phase if
77+
* it is possible to divide the work among the other phases instead.
6978
*
7079
* <div class="alert is-critical">
7180
*
7281
* Using this value can **significantly** degrade performance.
73-
* Instead, prefer refactoring into multiple callbacks using a more specific phase.
82+
* Instead, prefer dividing work into the appropriate phase callbacks.
7483
*
7584
* </div>
7685
*/
7786
MixedReadWrite,
7887

7988
/**
8089
* Use `AfterRenderPhase.Read` for callbacks that only **read** from the DOM. **Never**
81-
* use this phase for callbacks that can write to the DOM.
90+
* write to the DOM in this phase.
8291
*/
8392
Read,
8493
}
@@ -95,18 +104,6 @@ export interface AfterRenderOptions {
95104
* If this is not provided, the current injection context will be used instead (via `inject`).
96105
*/
97106
injector?: Injector;
98-
99-
/**
100-
* The phase the callback should be invoked in.
101-
*
102-
* <div class="alert is-critical">
103-
*
104-
* Defaults to `AfterRenderPhase.MixedReadWrite`. You should choose a more specific
105-
* phase instead. See `AfterRenderPhase` for more information.
106-
*
107-
* </div>
108-
*/
109-
phase?: AfterRenderPhase;
110107
}
111108

112109
/**
@@ -174,20 +171,107 @@ export function internalAfterNextRender(
174171
}
175172

176173
/**
177-
* Register a callback to be invoked each time the application
178-
* finishes rendering.
174+
* Register callbacks to be invoked each time the application finishes rendering, during the
175+
* specified phases. The available phases are:
176+
* - `earlyRead`
177+
* Use this phase to **read** from the DOM before a subsequent `write` callback, for example to
178+
* perform custom layout that the browser doesn't natively support. Prefer the `read` phase if
179+
* reading can wait until after the write phase. **Never** write to the DOM in this phase.
180+
* - `write`
181+
* Use this phase to **write** to the DOM. **Never** read from the DOM in this phase.
182+
* - `mixedReadWrite`
183+
* Use this phase to read from and write to the DOM simultaneously. **Never** use this phase if
184+
* it is possible to divide the work among the other phases instead.
185+
* - `read`
186+
* Use this phase to **read** from the DOM. **Never** write to the DOM in this phase.
187+
*
188+
* <div class="alert is-critical">
189+
*
190+
* You should prefer using the `read` and `write` phases over the `earlyRead` and `mixedReadWrite`
191+
* phases when possible, to avoid performance degradation.
192+
*
193+
* </div>
194+
*
195+
* Note that:
196+
* - Callbacks run in the following phase order *after each render*:
197+
* 1. `earlyRead`
198+
* 2. `write`
199+
* 3. `mixedReadWrite`
200+
* 4. `read`
201+
* - Callbacks in the same phase run in the order they are registered.
202+
* - Callbacks run on browser platforms only, they will not run on the server.
203+
*
204+
* The first phase callback to run as part of this spec will receive no parameters. Each
205+
* subsequent phase callback in this spec will receive the return value of the previously run
206+
* phase callback as a parameter. This can be used to coordinate work across multiple phases.
207+
*
208+
* Angular is unable to verify or enforce that phases are used correctly, and instead
209+
* relies on each developer to follow the guidelines documented for each value and
210+
* carefully choose the appropriate one, refactoring their code if necessary. By doing
211+
* so, Angular is better able to minimize the performance degradation associated with
212+
* manual DOM access, ensuring the best experience for the end users of your application
213+
* or library.
214+
*
215+
* <div class="alert is-important">
216+
*
217+
* Components are not guaranteed to be [hydrated](guide/hydration) before the callback runs.
218+
* You must use caution when directly reading or writing the DOM and layout.
219+
*
220+
* </div>
221+
*
222+
* @param spec The callback functions to register
223+
*
224+
* @usageNotes
225+
*
226+
* Use `afterRender` to read or write the DOM after each render.
227+
*
228+
* ### Example
229+
* ```ts
230+
* @Component({
231+
* selector: 'my-cmp',
232+
* template: `<span #content>{{ ... }}</span>`,
233+
* })
234+
* export class MyComponent {
235+
* @ViewChild('content') contentRef: ElementRef;
236+
*
237+
* constructor() {
238+
* afterRender({
239+
* read: () => {
240+
* console.log('content height: ' + this.contentRef.nativeElement.scrollHeight);
241+
* }
242+
* });
243+
* }
244+
* }
245+
* ```
246+
*
247+
* @developerPreview
248+
*/
249+
export function afterRender<E = never, W = never, M = never>(
250+
spec: {
251+
earlyRead?: () => E;
252+
write?: (...args: ɵFirstAvailable<[E]>) => W;
253+
mixedReadWrite?: (...args: ɵFirstAvailable<[W, E]>) => M;
254+
read?: (...args: ɵFirstAvailable<[M, W, E]>) => void;
255+
},
256+
opts?: AfterRenderOptions,
257+
): AfterRenderRef;
258+
259+
/**
260+
* Register a callback to be invoked each time the application finishes rendering, during the
261+
* `mixedReadWrite` phase.
179262
*
180263
* <div class="alert is-critical">
181264
*
182-
* You should always explicitly specify a non-default [phase](api/core/AfterRenderPhase), or you
183-
* risk significant performance degradation.
265+
* You should prefer specifying an explicit phase for the callback instead, or you risk significant
266+
* performance degradation.
184267
*
185268
* </div>
186269
*
187270
* Note that the callback will run
188271
* - in the order it was registered
189272
* - once per render
190273
* - on browser platforms only
274+
* - during the `mixedReadWrite` phase
191275
*
192276
* <div class="alert is-important">
193277
*
@@ -212,16 +296,30 @@ export function internalAfterNextRender(
212296
* @ViewChild('content') contentRef: ElementRef;
213297
*
214298
* constructor() {
215-
* afterRender(() => {
216-
* console.log('content height: ' + this.contentRef.nativeElement.scrollHeight);
217-
* }, {phase: AfterRenderPhase.Read});
299+
* afterRender({
300+
* read: () => {
301+
* console.log('content height: ' + this.contentRef.nativeElement.scrollHeight);
302+
* }
303+
* });
218304
* }
219305
* }
220306
* ```
221307
*
222308
* @developerPreview
223309
*/
224-
export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef {
310+
export function afterRender(callback: VoidFunction, options?: AfterRenderOptions): AfterRenderRef;
311+
312+
export function afterRender(
313+
callbackOrSpec:
314+
| VoidFunction
315+
| {
316+
earlyRead?: () => unknown;
317+
write?: (r?: unknown) => unknown;
318+
mixedReadWrite?: (r?: unknown) => unknown;
319+
read?: (r?: unknown) => void;
320+
},
321+
options?: AfterRenderOptions,
322+
): AfterRenderRef {
225323
ngDevMode &&
226324
assertNotInReactiveContext(
227325
afterRender,
@@ -238,37 +336,112 @@ export function afterRender(callback: VoidFunction, options?: AfterRenderOptions
238336

239337
performanceMarkFeature('NgAfterRender');
240338

241-
const afterRenderEventManager = injector.get(AfterRenderEventManager);
242-
// Lazily initialize the handler implementation, if necessary. This is so that it can be
243-
// tree-shaken if `afterRender` and `afterNextRender` aren't used.
244-
const callbackHandler = (afterRenderEventManager.handler ??=
245-
new AfterRenderCallbackHandlerImpl());
246-
const phase = options?.phase ?? AfterRenderPhase.MixedReadWrite;
247-
const destroy = () => {
248-
callbackHandler.unregister(instance);
249-
unregisterFn();
250-
};
251-
const unregisterFn = injector.get(DestroyRef).onDestroy(destroy);
252-
const instance = runInInjectionContext(injector, () => new AfterRenderCallback(phase, callback));
253-
254-
callbackHandler.register(instance);
255-
return {destroy};
339+
return afterRenderImpl(callbackOrSpec, injector, /* once */ false);
256340
}
257341

258342
/**
259-
* Register a callback to be invoked the next time the application
260-
* finishes rendering.
343+
* Register callbacks to be invoked the next time the application finishes rendering, during the
344+
* specified phases. The available phases are:
345+
* - `earlyRead`
346+
* Use this phase to **read** from the DOM before a subsequent `write` callback, for example to
347+
* perform custom layout that the browser doesn't natively support. Prefer the `read` phase if
348+
* reading can wait until after the write phase. **Never** write to the DOM in this phase.
349+
* - `write`
350+
* Use this phase to **write** to the DOM. **Never** read from the DOM in this phase.
351+
* - `mixedReadWrite`
352+
* Use this phase to read from and write to the DOM simultaneously. **Never** use this phase if
353+
* it is possible to divide the work among the other phases instead.
354+
* - `read`
355+
* Use this phase to **read** from the DOM. **Never** write to the DOM in this phase.
356+
*
357+
* <div class="alert is-critical">
358+
*
359+
* You should prefer using the `read` and `write` phases over the `earlyRead` and `mixedReadWrite`
360+
* phases when possible, to avoid performance degradation.
361+
*
362+
* </div>
363+
*
364+
* Note that:
365+
* - Callbacks run in the following phase order *once, after the next render*:
366+
* 1. `earlyRead`
367+
* 2. `write`
368+
* 3. `mixedReadWrite`
369+
* 4. `read`
370+
* - Callbacks in the same phase run in the order they are registered.
371+
* - Callbacks run on browser platforms only, they will not run on the server.
372+
*
373+
* The first phase callback to run as part of this spec will receive no parameters. Each
374+
* subsequent phase callback in this spec will receive the return value of the previously run
375+
* phase callback as a parameter. This can be used to coordinate work across multiple phases.
376+
*
377+
* Angular is unable to verify or enforce that phases are used correctly, and instead
378+
* relies on each developer to follow the guidelines documented for each value and
379+
* carefully choose the appropriate one, refactoring their code if necessary. By doing
380+
* so, Angular is better able to minimize the performance degradation associated with
381+
* manual DOM access, ensuring the best experience for the end users of your application
382+
* or library.
383+
*
384+
* <div class="alert is-important">
385+
*
386+
* Components are not guaranteed to be [hydrated](guide/hydration) before the callback runs.
387+
* You must use caution when directly reading or writing the DOM and layout.
388+
*
389+
* </div>
390+
*
391+
* @param spec The callback functions to register
392+
*
393+
* @usageNotes
394+
*
395+
* Use `afterNextRender` to read or write the DOM once,
396+
* for example to initialize a non-Angular library.
397+
*
398+
* ### Example
399+
* ```ts
400+
* @Component({
401+
* selector: 'my-chart-cmp',
402+
* template: `<div #chart>{{ ... }}</div>`,
403+
* })
404+
* export class MyChartCmp {
405+
* @ViewChild('chart') chartRef: ElementRef;
406+
* chart: MyChart|null;
407+
*
408+
* constructor() {
409+
* afterNextRender({
410+
* write: () => {
411+
* this.chart = new MyChart(this.chartRef.nativeElement);
412+
* }
413+
* });
414+
* }
415+
* }
416+
* ```
417+
*
418+
* @developerPreview
419+
*/
420+
export function afterNextRender<E = never, W = never, M = never>(
421+
spec: {
422+
earlyRead?: () => E;
423+
write?: (...args: ɵFirstAvailable<[E]>) => W;
424+
mixedReadWrite?: (...args: ɵFirstAvailable<[W, E]>) => M;
425+
read?: (...args: ɵFirstAvailable<[M, W, E]>) => void;
426+
},
427+
opts?: AfterRenderOptions,
428+
): AfterRenderRef;
429+
430+
/**
431+
* Register a callback to be invoked the next time the application finishes rendering, during the
432+
* `mixedReadWrite` phase.
261433
*
262434
* <div class="alert is-critical">
263435
*
264-
* You should always explicitly specify a non-default [phase](api/core/AfterRenderPhase), or you
265-
* risk significant performance degradation.
436+
* You should prefer specifying an explicit phase for the callback instead, or you risk significant
437+
* performance degradation.
266438
*
267439
* </div>
268440
*
269441
* Note that the callback will run
270442
* - in the order it was registered
271443
* - on browser platforms only
444+
* - during the `mixedReadWrite` phase
272445
*
273446
* <div class="alert is-important">
274447
*
@@ -295,9 +468,11 @@ export function afterRender(callback: VoidFunction, options?: AfterRenderOptions
295468
* chart: MyChart|null;
296469
*
297470
* constructor() {
298-
* afterNextRender(() => {
299-
* this.chart = new MyChart(this.chartRef.nativeElement);
300-
* }, {phase: AfterRenderPhase.Write});
471+
* afterNextRender({
472+
* write: () => {
473+
* this.chart = new MyChart(this.chartRef.nativeElement);
474+
* }
475+
* });
301476
* }
302477
* }
303478
* ```
@@ -307,6 +482,18 @@ export function afterRender(callback: VoidFunction, options?: AfterRenderOptions
307482
export function afterNextRender(
308483
callback: VoidFunction,
309484
options?: AfterRenderOptions,
485+
): AfterRenderRef;
486+
487+
export function afterNextRender(
488+
callbackOrSpec:
489+
| VoidFunction
490+
| {
491+
earlyRead?: () => unknown;
492+
write?: (r?: unknown) => unknown;
493+
mixedReadWrite?: (r?: unknown) => unknown;
494+
read?: (r?: unknown) => void;
495+
},
496+
options?: AfterRenderOptions,
310497
): AfterRenderRef {
311498
!options && assertInInjectionContext(afterNextRender);
312499
const injector = options?.injector ?? inject(Injector);
@@ -317,27 +504,75 @@ export function afterNextRender(
317504

318505
performanceMarkFeature('NgAfterNextRender');
319506

507+
return afterRenderImpl(callbackOrSpec, injector, /* once */ true);
508+
}
509+
510+
/**
511+
* Shared implementation for `afterRender` and `afterNextRender`.
512+
*/
513+
function afterRenderImpl(
514+
callbackOrSpec:
515+
| VoidFunction
516+
| {
517+
earlyRead?: () => unknown;
518+
write?: (r?: unknown) => unknown;
519+
mixedReadWrite?: (r?: unknown) => unknown;
520+
read?: (r?: unknown) => void;
521+
},
522+
injector: Injector,
523+
once: boolean,
524+
): AfterRenderRef {
525+
const spec: {
526+
earlyRead?: () => unknown;
527+
write?: (r?: unknown) => unknown;
528+
mixedReadWrite?: (r?: unknown) => unknown;
529+
read?: (r?: unknown) => void;
530+
} = callbackOrSpec instanceof Function ? {mixedReadWrite: callbackOrSpec} : callbackOrSpec;
531+
320532
const afterRenderEventManager = injector.get(AfterRenderEventManager);
321533
// Lazily initialize the handler implementation, if necessary. This is so that it can be
322534
// tree-shaken if `afterRender` and `afterNextRender` aren't used.
323535
const callbackHandler = (afterRenderEventManager.handler ??=
324536
new AfterRenderCallbackHandlerImpl());
325-
const phase = options?.phase ?? AfterRenderPhase.MixedReadWrite;
537+
538+
const pipelinedArgs: [] | [unknown] = [];
539+
const instances: AfterRenderCallback[] = [];
540+
326541
const destroy = () => {
327-
callbackHandler.unregister(instance);
542+
for (const instance of instances) {
543+
callbackHandler.unregister(instance);
544+
}
328545
unregisterFn();
329546
};
330547
const unregisterFn = injector.get(DestroyRef).onDestroy(destroy);
331-
const instance = runInInjectionContext(
332-
injector,
333-
() =>
334-
new AfterRenderCallback(phase, () => {
335-
destroy();
336-
callback();
337-
}),
338-
);
339-
340-
callbackHandler.register(instance);
548+
549+
const registerCallback = (
550+
phase: AfterRenderPhase,
551+
phaseCallback: undefined | ((...args: unknown[]) => unknown),
552+
) => {
553+
if (!phaseCallback) {
554+
return;
555+
}
556+
const callback = once
557+
? (...args: [unknown]) => {
558+
destroy();
559+
phaseCallback(...args);
560+
}
561+
: phaseCallback;
562+
563+
const instance = runInInjectionContext(
564+
injector,
565+
() => new AfterRenderCallback(phase, pipelinedArgs, callback),
566+
);
567+
callbackHandler.register(instance);
568+
instances.push(instance);
569+
};
570+
571+
registerCallback(AfterRenderPhase.EarlyRead, spec.earlyRead);
572+
registerCallback(AfterRenderPhase.Write, spec.write);
573+
registerCallback(AfterRenderPhase.MixedReadWrite, spec.mixedReadWrite);
574+
registerCallback(AfterRenderPhase.Read, spec.read);
575+
341576
return {destroy};
342577
}
343578

@@ -350,15 +585,20 @@ class AfterRenderCallback {
350585

351586
constructor(
352587
readonly phase: AfterRenderPhase,
353-
private callbackFn: VoidFunction,
588+
private pipelinedArgs: [] | [unknown],
589+
private callbackFn: (...args: unknown[]) => unknown,
354590
) {
355591
// Registering a callback will notify the scheduler.
356592
inject(ChangeDetectionScheduler, {optional: true})?.notify(NotificationSource.NewRenderHook);
357593
}
358594

359595
invoke() {
360596
try {
361-
this.zone.runOutsideAngular(this.callbackFn);
597+
const result = this.zone.runOutsideAngular(() =>
598+
this.callbackFn.apply(null, this.pipelinedArgs as [unknown]),
599+
);
600+
// Clear out the args and add the result which will be passed to the next phase.
601+
this.pipelinedArgs.splice(0, this.pipelinedArgs.length, result);
362602
} catch (err) {
363603
this.errorHandler?.handleError(err);
364604
}

Diff for: ‎packages/core/test/acceptance/after_render_hook_spec.ts

+181-107
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,36 @@
88

99
import {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '@angular/common/src/platform_id';
1010
import {
11-
afterNextRender,
12-
afterRender,
13-
AfterRenderPhase,
1411
AfterRenderRef,
1512
ApplicationRef,
1613
ChangeDetectorRef,
1714
Component,
18-
computed,
19-
createComponent,
20-
effect,
2115
ErrorHandler,
22-
inject,
2316
Injector,
2417
NgZone,
2518
PLATFORM_ID,
26-
signal,
2719
Type,
28-
untracked,
2920
ViewContainerRef,
21+
afterNextRender,
22+
afterRender,
23+
computed,
24+
createComponent,
25+
effect,
26+
inject,
3027
ɵinternalAfterNextRender as internalAfterNextRender,
3128
ɵqueueStateUpdate as queueStateUpdate,
29+
signal,
30+
untracked,
3231
} from '@angular/core';
3332
import {NoopNgZone} from '@angular/core/src/zone/ng_zone';
3433
import {TestBed} from '@angular/core/testing';
3534
import {bootstrapApplication} from '@angular/platform-browser';
3635
import {withBody} from '@angular/private/testing';
3736

38-
import {destroyPlatform} from '../../src/core';
39-
import {EnvironmentInjector} from '../../src/di';
4037
import {firstValueFrom} from 'rxjs';
4138
import {filter} from 'rxjs/operators';
39+
import {destroyPlatform} from '../../src/core';
40+
import {EnvironmentInjector} from '../../src/di';
4241

4342
function createAndAttachComponent<T>(component: Type<T>) {
4443
const componentRef = createComponent(component, {
@@ -62,12 +61,11 @@ describe('after render hooks', () => {
6261
class Comp {
6362
constructor() {
6463
// Helper to register into each phase
65-
function forEachPhase(fn: (phase: AfterRenderPhase) => void) {
66-
for (const phase in AfterRenderPhase) {
67-
const val = AfterRenderPhase[phase];
68-
if (typeof val === 'number') {
69-
fn(val);
70-
}
64+
function forEachPhase(
65+
fn: (phase: 'earlyRead' | 'write' | 'mixedReadWrite' | 'read') => void,
66+
) {
67+
for (const phase of ['earlyRead', 'write', 'mixedReadWrite', 'read'] as const) {
68+
fn(phase);
7169
}
7270
}
7371

@@ -76,25 +74,23 @@ describe('after render hooks', () => {
7674
});
7775

7876
forEachPhase((phase) =>
79-
afterRender(
80-
() => {
81-
log.push(`afterRender (${AfterRenderPhase[phase]})`);
77+
afterRender({
78+
[phase]: () => {
79+
log.push(`afterRender (${phase})`);
8280
},
83-
{phase},
84-
),
81+
}),
8582
);
8683

8784
internalAfterNextRender(() => {
8885
log.push('internalAfterNextRender #2');
8986
});
9087

9188
forEachPhase((phase) =>
92-
afterNextRender(
93-
() => {
94-
log.push(`afterNextRender (${AfterRenderPhase[phase]})`);
89+
afterNextRender({
90+
[phase]: () => {
91+
log.push(`afterNextRender (${phase})`);
9592
},
96-
{phase},
97-
),
93+
}),
9894
);
9995

10096
internalAfterNextRender(() => {
@@ -118,24 +114,24 @@ describe('after render hooks', () => {
118114
'internalAfterNextRender #1',
119115
'internalAfterNextRender #2',
120116
'internalAfterNextRender #3',
121-
'afterRender (EarlyRead)',
122-
'afterNextRender (EarlyRead)',
123-
'afterRender (Write)',
124-
'afterNextRender (Write)',
125-
'afterRender (MixedReadWrite)',
126-
'afterNextRender (MixedReadWrite)',
127-
'afterRender (Read)',
128-
'afterNextRender (Read)',
117+
'afterRender (earlyRead)',
118+
'afterNextRender (earlyRead)',
119+
'afterRender (write)',
120+
'afterNextRender (write)',
121+
'afterRender (mixedReadWrite)',
122+
'afterNextRender (mixedReadWrite)',
123+
'afterRender (read)',
124+
'afterNextRender (read)',
129125
]);
130126

131127
// Running change detection again
132128
log.length = 0;
133129
TestBed.inject(ApplicationRef).tick();
134130
expect(log).toEqual([
135-
'afterRender (EarlyRead)',
136-
'afterRender (Write)',
137-
'afterRender (MixedReadWrite)',
138-
'afterRender (Read)',
131+
'afterRender (earlyRead)',
132+
'afterRender (write)',
133+
'afterRender (mixedReadWrite)',
134+
'afterRender (read)',
139135
]);
140136
});
141137

@@ -493,8 +489,10 @@ describe('after render hooks', () => {
493489
createAndAttachComponent(Comp);
494490

495491
expect(zoneLog).toEqual([]);
496-
TestBed.inject(ApplicationRef).tick();
497-
expect(zoneLog).toEqual([false]);
492+
TestBed.inject(NgZone).run(() => {
493+
TestBed.inject(ApplicationRef).tick();
494+
expect(zoneLog).toEqual([false]);
495+
});
498496
});
499497

500498
it('should propagate errors to the ErrorHandler', () => {
@@ -550,66 +548,58 @@ describe('after render hooks', () => {
550548
@Component({selector: 'comp-a'})
551549
class CompA {
552550
constructor() {
553-
afterRender(
554-
() => {
551+
afterRender({
552+
earlyRead: () => {
555553
log.push('early-read-1');
556554
},
557-
{phase: AfterRenderPhase.EarlyRead},
558-
);
555+
});
559556

560-
afterRender(
561-
() => {
557+
afterRender({
558+
write: () => {
562559
log.push('write-1');
563560
},
564-
{phase: AfterRenderPhase.Write},
565-
);
561+
});
566562

567-
afterRender(
568-
() => {
563+
afterRender({
564+
mixedReadWrite: () => {
569565
log.push('mixed-read-write-1');
570566
},
571-
{phase: AfterRenderPhase.MixedReadWrite},
572-
);
567+
});
573568

574-
afterRender(
575-
() => {
569+
afterRender({
570+
read: () => {
576571
log.push('read-1');
577572
},
578-
{phase: AfterRenderPhase.Read},
579-
);
573+
});
580574
}
581575
}
582576

583577
@Component({selector: 'comp-b'})
584578
class CompB {
585579
constructor() {
586-
afterRender(
587-
() => {
580+
afterRender({
581+
read: () => {
588582
log.push('read-2');
589583
},
590-
{phase: AfterRenderPhase.Read},
591-
);
584+
});
592585

593-
afterRender(
594-
() => {
586+
afterRender({
587+
mixedReadWrite: () => {
595588
log.push('mixed-read-write-2');
596589
},
597-
{phase: AfterRenderPhase.MixedReadWrite},
598-
);
590+
});
599591

600-
afterRender(
601-
() => {
592+
afterRender({
593+
write: () => {
602594
log.push('write-2');
603595
},
604-
{phase: AfterRenderPhase.Write},
605-
);
596+
});
606597

607-
afterRender(
608-
() => {
598+
afterRender({
599+
earlyRead: () => {
609600
log.push('early-read-2');
610601
},
611-
{phase: AfterRenderPhase.EarlyRead},
612-
);
602+
});
613603
}
614604
}
615605

@@ -633,6 +623,96 @@ describe('after render hooks', () => {
633623
]);
634624
});
635625

626+
it('should schedule callbacks for multiple phases at once', () => {
627+
const log: string[] = [];
628+
629+
@Component({selector: 'comp'})
630+
class Comp {
631+
constructor() {
632+
afterRender({
633+
earlyRead: () => {
634+
log.push('early-read-1');
635+
},
636+
write: () => {
637+
log.push('write-1');
638+
},
639+
mixedReadWrite: () => {
640+
log.push('mixed-read-write-1');
641+
},
642+
read: () => {
643+
log.push('read-1');
644+
},
645+
});
646+
647+
afterRender(() => {
648+
log.push('mixed-read-write-2');
649+
});
650+
}
651+
}
652+
653+
TestBed.configureTestingModule({
654+
declarations: [Comp],
655+
...COMMON_CONFIGURATION,
656+
});
657+
createAndAttachComponent(Comp);
658+
659+
expect(log).toEqual([]);
660+
TestBed.inject(ApplicationRef).tick();
661+
expect(log).toEqual([
662+
'early-read-1',
663+
'write-1',
664+
'mixed-read-write-1',
665+
'mixed-read-write-2',
666+
'read-1',
667+
]);
668+
});
669+
670+
it('should pass data between phases', () => {
671+
const log: string[] = [];
672+
673+
@Component({selector: 'comp'})
674+
class Comp {
675+
constructor() {
676+
afterRender({
677+
earlyRead: () => 'earlyRead result',
678+
write: (results) => {
679+
log.push(`results for write: ${results}`);
680+
return 5;
681+
},
682+
mixedReadWrite: (results) => {
683+
log.push(`results for mixedReadWrite: ${results}`);
684+
return undefined;
685+
},
686+
read: (results) => {
687+
log.push(`results for read: ${results}`);
688+
},
689+
});
690+
691+
afterRender({
692+
earlyRead: () => 'earlyRead 2 result',
693+
read: (results) => {
694+
log.push(`results for read 2: ${results}`);
695+
},
696+
});
697+
}
698+
}
699+
700+
TestBed.configureTestingModule({
701+
declarations: [Comp],
702+
...COMMON_CONFIGURATION,
703+
});
704+
createAndAttachComponent(Comp);
705+
706+
expect(log).toEqual([]);
707+
TestBed.inject(ApplicationRef).tick();
708+
expect(log).toEqual([
709+
'results for write: earlyRead result',
710+
'results for mixedReadWrite: 5',
711+
'results for read: undefined',
712+
'results for read 2: earlyRead 2 result',
713+
]);
714+
});
715+
636716
describe('throw error inside reactive context', () => {
637717
it('inside template effect', () => {
638718
@Component({template: `{{someFn()}}`})
@@ -927,8 +1007,10 @@ describe('after render hooks', () => {
9271007
createAndAttachComponent(Comp);
9281008

9291009
expect(zoneLog).toEqual([]);
930-
TestBed.inject(ApplicationRef).tick();
931-
expect(zoneLog).toEqual([false]);
1010+
TestBed.inject(NgZone).run(() => {
1011+
TestBed.inject(ApplicationRef).tick();
1012+
expect(zoneLog).toEqual([false]);
1013+
});
9321014
});
9331015

9341016
it('should propagate errors to the ErrorHandler', () => {
@@ -984,66 +1066,58 @@ describe('after render hooks', () => {
9841066
@Component({selector: 'comp-a'})
9851067
class CompA {
9861068
constructor() {
987-
afterNextRender(
988-
() => {
1069+
afterNextRender({
1070+
earlyRead: () => {
9891071
log.push('early-read-1');
9901072
},
991-
{phase: AfterRenderPhase.EarlyRead},
992-
);
1073+
});
9931074

994-
afterNextRender(
995-
() => {
1075+
afterNextRender({
1076+
write: () => {
9961077
log.push('write-1');
9971078
},
998-
{phase: AfterRenderPhase.Write},
999-
);
1079+
});
10001080

1001-
afterNextRender(
1002-
() => {
1081+
afterNextRender({
1082+
mixedReadWrite: () => {
10031083
log.push('mixed-read-write-1');
10041084
},
1005-
{phase: AfterRenderPhase.MixedReadWrite},
1006-
);
1085+
});
10071086

1008-
afterNextRender(
1009-
() => {
1087+
afterNextRender({
1088+
read: () => {
10101089
log.push('read-1');
10111090
},
1012-
{phase: AfterRenderPhase.Read},
1013-
);
1091+
});
10141092
}
10151093
}
10161094

10171095
@Component({selector: 'comp-b'})
10181096
class CompB {
10191097
constructor() {
1020-
afterNextRender(
1021-
() => {
1098+
afterNextRender({
1099+
read: () => {
10221100
log.push('read-2');
10231101
},
1024-
{phase: AfterRenderPhase.Read},
1025-
);
1102+
});
10261103

1027-
afterNextRender(
1028-
() => {
1104+
afterNextRender({
1105+
mixedReadWrite: () => {
10291106
log.push('mixed-read-write-2');
10301107
},
1031-
{phase: AfterRenderPhase.MixedReadWrite},
1032-
);
1108+
});
10331109

1034-
afterNextRender(
1035-
() => {
1110+
afterNextRender({
1111+
write: () => {
10361112
log.push('write-2');
10371113
},
1038-
{phase: AfterRenderPhase.Write},
1039-
);
1114+
});
10401115

1041-
afterNextRender(
1042-
() => {
1116+
afterNextRender({
1117+
earlyRead: () => {
10431118
log.push('early-read-2');
10441119
},
1045-
{phase: AfterRenderPhase.EarlyRead},
1046-
);
1120+
});
10471121
}
10481122
}
10491123

0 commit comments

Comments
 (0)
Please sign in to comment.