Skip to content

Commit b80af11

Browse files
committed
refactor(core): restructure AfterRenderManager to connect related phases (angular#57453)
The `afterRender` infrastructure was first implemented around the idea of independent, singular hooks. It was later updated to support a spec of multiple hooks that pass values from one to another as they execute, but the implementation still worked in terms of singular hooks under the hood. This creates a number of maintenance issues, and a few bugs. For example, when one hook fails, further hooks in the pipeline should no longer execute, but this was hard to ensure under the old design. This refactoring restructures `afterRender` infrastructure significantly to introduce the concept of a "sequence", a collection of hooks of different phases that execute together. Overall, the implementation is simplified while making it more resilient to issues and future use cases, such as the upcoming `afterRenderEffect`. As part of this refactoring, the `internalAfterNextRender` concept is removed, as well as the unused `queueStateUpdate` concept which used it. PR Close angular#57453
1 parent dabfb6d commit b80af11

29 files changed

+656
-667
lines changed

packages/core/src/application/application_ref.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {NgModuleRef} from '../linker/ng_module_factory';
3131
import {ViewRef} from '../linker/view_ref';
3232
import {PendingTasks} from '../pending_tasks';
3333
import {RendererFactory2} from '../render/api';
34-
import {AfterRenderEventManager} from '../render3/after_render_hooks';
34+
import {AfterRenderManager} from '../render3/after_render/manager';
3535
import {ComponentFactory as R3ComponentFactory} from '../render3/component_ref';
3636
import {isStandalone} from '../render3/definition';
3737
import {ChangeDetectionMode, detectChangesInternal} from '../render3/instructions/change_detection';
@@ -308,7 +308,7 @@ export class ApplicationRef {
308308
/** @internal */
309309
_views: InternalViewRef<unknown>[] = [];
310310
private readonly internalErrorHandler = inject(INTERNAL_APPLICATION_ERROR_HANDLER);
311-
private readonly afterRenderEffectManager = inject(AfterRenderEventManager);
311+
private readonly afterRenderManager = inject(AfterRenderManager);
312312
private readonly zonelessEnabled = inject(ZONELESS_ENABLED);
313313

314314
/**
@@ -676,6 +676,13 @@ export class ApplicationRef {
676676
// flag. We clear the flag here because, for backwards compatibility, `markForCheck()`
677677
// during view checking doesn't cause the view to be re-checked.
678678
this.dirtyFlags &= ~ApplicationRefDirtyFlags.ViewTreeCheck;
679+
680+
// Check if any views are still dirty after checking and we need to loop back.
681+
this.syncDirtyFlagsWithViews();
682+
if (this.dirtyFlags & ApplicationRefDirtyFlags.ViewTreeAny) {
683+
// If any views are still dirty after checking, loop back before running render hooks.
684+
return;
685+
}
679686
} else {
680687
// If we skipped refreshing views above, there might still be unflushed animations
681688
// because we never called `detectChangesInternal` on the views.
@@ -686,32 +693,32 @@ export class ApplicationRef {
686693
// Even if there were no dirty views, afterRender hooks might still be dirty.
687694
if (this.dirtyFlags & ApplicationRefDirtyFlags.AfterRender) {
688695
this.dirtyFlags &= ~ApplicationRefDirtyFlags.AfterRender;
696+
this.afterRenderManager.execute();
689697

690-
this.afterRenderEffectManager.executeInternalCallbacks();
691-
692-
// If we have a newly dirty view after running internal callbacks, recheck the views again
693-
// before running user-provided callbacks
694-
if (this.allViews.some(({_lView}) => requiresRefreshOrTraversal(_lView))) {
695-
// Should be a no-op, but just in case:
696-
this.dirtyFlags |= ApplicationRefDirtyFlags.ViewTreeTraversal;
697-
return;
698-
}
699-
700-
this.afterRenderEffectManager.execute();
701-
702-
if (this.dirtyFlags & ApplicationRefDirtyFlags.AfterRender) {
703-
// If an afterRender hook schedules new afterRender hooks, we don't immediately loop, but
704-
// instead queue them to run in the next synchronization pass, whenever that is. Therefore
705-
// we clear the `AfterRender` bit here, but add it to `deferredDirtyFlags` to be applied
706-
// on the next pass.
707-
this.dirtyFlags &= ~ApplicationRefDirtyFlags.AfterRender;
708-
this.deferredDirtyFlags |= ApplicationRefDirtyFlags.AfterRender;
709-
}
698+
// afterRender hooks might influence dirty flags.
710699
}
700+
this.syncDirtyFlagsWithViews();
701+
}
711702

703+
/**
704+
* Checks `allViews` for views which require refresh/traversal, and updates `dirtyFlags`
705+
* accordingly, with two potential behaviors:
706+
*
707+
* 1. If any of our views require updating, then this adds the `ViewTreeTraversal` dirty flag.
708+
* This _should_ be a no-op, since the scheduler should've added the flag at the same time the
709+
* view was marked as needing updating.
710+
*
711+
* TODO(alxhub): figure out if this behavior is still needed for edge cases.
712+
*
713+
* 2. If none of our views require updating, then clear the view-related `dirtyFlag`s. This
714+
* happens when the scheduler is notified of a view becoming dirty, but the view itself isn't
715+
* reachable through traversal from our roots (e.g. it's detached from the CD tree).
716+
*/
717+
private syncDirtyFlagsWithViews(): void {
712718
if (this.allViews.some(({_lView}) => requiresRefreshOrTraversal(_lView))) {
713719
// If after running all afterRender callbacks new views are dirty, ensure we loop back.
714720
this.dirtyFlags |= ApplicationRefDirtyFlags.ViewTreeTraversal;
721+
return;
715722
} else {
716723
// Even though this flag may be set, none of _our_ views require traversal, and so the
717724
// `ApplicationRef` doesn't require any repeated checking.

packages/core/src/change_detection/scheduling/zoneless_scheduling.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export const enum NotificationSource {
2929
// The following notifications do not require views to be refreshed
3030
// but we should execute render hooks:
3131
// Render hooks are guaranteed to execute with the schedulers timing.
32-
NewRenderHook,
32+
RenderHook,
33+
DeferredRenderHook,
3334
// Views might be created outside and manipulated in ways that
3435
// we cannot be aware of. When a view is attached, Angular now "knows"
3536
// about it and we now know that DOM might have changed (and we should

packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,16 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
135135
this.appRef.dirtyFlags |= ApplicationRefDirtyFlags.ViewTreeCheck;
136136
break;
137137
}
138+
case NotificationSource.DeferredRenderHook: {
139+
// Render hooks are "deferred" when they're triggered from other render hooks. Using the
140+
// deferred dirty flags ensures that adding new hooks doesn't automatically trigger a loop
141+
// inside tick().
142+
this.appRef.deferredDirtyFlags |= ApplicationRefDirtyFlags.AfterRender;
143+
break;
144+
}
138145
case NotificationSource.ViewDetachedFromDOM:
139146
case NotificationSource.ViewAttached:
140-
case NotificationSource.NewRenderHook:
147+
case NotificationSource.RenderHook:
141148
case NotificationSource.AsyncAnimationsLoaded:
142149
default: {
143150
// These notifications only schedule a tick but do not change whether we should refresh

packages/core/src/core.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,13 @@ export {
9898
} from './render3/ng_module_ref';
9999
export {createComponent, reflectComponentType, ComponentMirror} from './render3/component';
100100
export {isStandalone} from './render3/definition';
101+
export {AfterRenderPhase, AfterRenderRef} from './render3/after_render/api';
101102
export {
102-
AfterRenderRef,
103103
AfterRenderOptions,
104-
AfterRenderPhase,
105104
afterRender,
106105
afterNextRender,
107106
ɵFirstAvailable,
108-
} from './render3/after_render_hooks';
107+
} from './render3/after_render/hooks';
109108
export {ApplicationConfig, mergeApplicationConfig} from './application/application_config';
110109
export {makeStateKey, StateKey, TransferState} from './transfer_state';
111110
export {booleanAttribute, numberAttribute} from './util/coercion';

packages/core/src/core_private_export.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ export {
112112
ProviderRecord as ɵProviderRecord,
113113
setInjectorProfilerContext as ɵsetInjectorProfilerContext,
114114
} from './render3/debug/injector_profiler';
115-
export {queueStateUpdate as ɵqueueStateUpdate} from './render3/queue_state_update';
116115
export {
117116
allowSanitizationBypassAndThrow as ɵallowSanitizationBypassAndThrow,
118117
BypassType as ɵBypassType,

packages/core/src/core_render3_private_export.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,7 @@ export {
293293
} from './sanitization/sanitization';
294294
export {ɵɵvalidateIframeAttribute} from './sanitization/iframe_attrs_validation';
295295
export {noSideEffects as ɵnoSideEffects} from './util/closure';
296-
export {
297-
AfterRenderEventManager as ɵAfterRenderEventManager,
298-
internalAfterNextRender as ɵinternalAfterNextRender,
299-
} from './render3/after_render_hooks';
296+
export {AfterRenderManager as ɵAfterRenderManager} from './render3/after_render/manager';
300297
export {
301298
depsTracker as ɵdepsTracker,
302299
USE_RUNTIME_DEPS_TRACKER_FOR_JIT as ɵUSE_RUNTIME_DEPS_TRACKER_FOR_JIT,

packages/core/src/defer/dom_triggers.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {afterNextRender} from '../render3/after_render/hooks';
910
import type {Injector} from '../di';
10-
import {internalAfterNextRender} from '../render3/after_render_hooks';
1111
import {assertLContainer, assertLView} from '../render3/assert';
1212
import {CONTAINER_HEADER_OFFSET} from '../render3/interfaces/container';
1313
import {TNode} from '../render3/interfaces/node';
@@ -279,6 +279,7 @@ export function registerDomTrigger(
279279
type: TriggerType,
280280
) {
281281
const injector = initialLView[INJECTOR]!;
282+
const zone = injector.get(NgZone);
282283
function pollDomTrigger() {
283284
// If the initial view was destroyed, we don't need to do anything.
284285
if (isDestroyed(initialLView)) {
@@ -300,7 +301,7 @@ export function registerDomTrigger(
300301

301302
// Keep polling until we resolve the trigger's LView.
302303
if (!triggerLView) {
303-
internalAfterNextRender(pollDomTrigger, {injector});
304+
afterNextRender({read: pollDomTrigger}, {injector});
304305
return;
305306
}
306307

@@ -313,10 +314,14 @@ export function registerDomTrigger(
313314
const cleanup = registerFn(
314315
element,
315316
() => {
316-
if (initialLView !== triggerLView) {
317-
removeLViewOnDestroy(triggerLView, cleanup);
318-
}
319-
callback();
317+
// `pollDomTrigger` runs outside the zone (because of `afterNextRender`) and registers its
318+
// listeners outside the zone, so we jump back into the zone prior to running the callback.
319+
zone.run(() => {
320+
if (initialLView !== triggerLView) {
321+
removeLViewOnDestroy(triggerLView, cleanup);
322+
}
323+
callback();
324+
});
320325
},
321326
injector,
322327
);
@@ -334,5 +339,5 @@ export function registerDomTrigger(
334339
}
335340

336341
// Begin polling for the trigger.
337-
internalAfterNextRender(pollDomTrigger, {injector});
342+
afterNextRender({read: pollDomTrigger}, {injector});
338343
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/**
10+
* The phase to run an `afterRender` or `afterNextRender` callback in.
11+
*
12+
* Callbacks in the same phase run in the order they are registered. Phases run in the
13+
* following order after each render:
14+
*
15+
* 1. `AfterRenderPhase.EarlyRead`
16+
* 2. `AfterRenderPhase.Write`
17+
* 3. `AfterRenderPhase.MixedReadWrite`
18+
* 4. `AfterRenderPhase.Read`
19+
*
20+
* Angular is unable to verify or enforce that phases are used correctly, and instead
21+
* relies on each developer to follow the guidelines documented for each value and
22+
* carefully choose the appropriate one, refactoring their code if necessary. By doing
23+
* so, Angular is better able to minimize the performance degradation associated with
24+
* manual DOM access, ensuring the best experience for the end users of your application
25+
* or library.
26+
*
27+
* @deprecated Specify the phase for your callback to run in by passing a spec-object as the first
28+
* parameter to `afterRender` or `afterNextRender` instead of a function.
29+
*/
30+
export enum AfterRenderPhase {
31+
/**
32+
* Use `AfterRenderPhase.EarlyRead` for callbacks that only need to **read** from the
33+
* DOM before a subsequent `AfterRenderPhase.Write` callback, for example to perform
34+
* custom layout that the browser doesn't natively support. Prefer the
35+
* `AfterRenderPhase.EarlyRead` phase if reading can wait until after the write phase.
36+
* **Never** write to the DOM in this phase.
37+
*
38+
* <div class="alert is-important">
39+
*
40+
* Using this value can degrade performance.
41+
* Instead, prefer using built-in browser functionality when possible.
42+
*
43+
* </div>
44+
*/
45+
EarlyRead,
46+
47+
/**
48+
* Use `AfterRenderPhase.Write` for callbacks that only **write** to the DOM. **Never**
49+
* read from the DOM in this phase.
50+
*/
51+
Write,
52+
53+
/**
54+
* Use `AfterRenderPhase.MixedReadWrite` for callbacks that read from or write to the
55+
* DOM, that haven't been refactored to use a different phase. **Never** use this phase if
56+
* it is possible to divide the work among the other phases instead.
57+
*
58+
* <div class="alert is-critical">
59+
*
60+
* Using this value can **significantly** degrade performance.
61+
* Instead, prefer dividing work into the appropriate phase callbacks.
62+
*
63+
* </div>
64+
*/
65+
MixedReadWrite,
66+
67+
/**
68+
* Use `AfterRenderPhase.Read` for callbacks that only **read** from the DOM. **Never**
69+
* write to the DOM in this phase.
70+
*/
71+
Read,
72+
}
73+
74+
/**
75+
* A callback that runs after render.
76+
*
77+
* @developerPreview
78+
*/
79+
export interface AfterRenderRef {
80+
/**
81+
* Shut down the callback, preventing it from being called again.
82+
*/
83+
destroy(): void;
84+
}

0 commit comments

Comments
 (0)