Skip to content

Commit 2f347ef

Browse files
crisbetoalxhub
authored andcommitted
fix(core): provide flag to opt into manual cleanup for after render hooks (#57917)
Adds a `manualCleanup` flag to `afterRender` and `afterNextRender`, similarly to `effect`. The reason is that currently if the hook is created outside of an injection context, it requires an injector to be passed in. In some cases that injector might be an injector that is never destroyed (e.g. `EnvironmentInjector`) which can give a false sense of security users thinking that the hook will be cleaned up automatically. We fell into this in the CDK which caused a memory leak (see angular/components#29709). With the `manualCleanup` option users explicitly opt into cleaning the hook up themselves. PR Close #57917
1 parent 9d5b1ec commit 2f347ef

File tree

4 files changed

+62
-5
lines changed

4 files changed

+62
-5
lines changed

goldens/public-api/core/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function afterRender(callback: VoidFunction, options?: AfterRenderOptions
5252
// @public
5353
export interface AfterRenderOptions {
5454
injector?: Injector;
55+
manualCleanup?: boolean;
5556
// @deprecated
5657
phase?: AfterRenderPhase;
5758
}

packages/core/src/render3/after_render/hooks.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ export interface AfterRenderOptions {
4444
*/
4545
injector?: Injector;
4646

47+
/**
48+
* Whether the hook should require manual cleanup.
49+
*
50+
* If this is `false` (the default) the hook will automatically register itself to be cleaned up
51+
* with the current `DestroyRef`.
52+
*/
53+
manualCleanup?: boolean;
54+
4755
/**
4856
* The phase the callback should be invoked in.
4957
*
@@ -448,11 +456,12 @@ function afterRenderImpl(
448456
manager.impl ??= injector.get(AfterRenderImpl);
449457

450458
const hooks = options?.phase ?? AfterRenderPhase.MixedReadWrite;
459+
const destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef) : null;
451460
const sequence = new AfterRenderSequence(
452461
manager.impl,
453462
getHooks(callbackOrSpec, hooks),
454463
once,
455-
injector.get(DestroyRef),
464+
destroyRef,
456465
);
457466
manager.impl.register(sequence);
458467
return sequence;

packages/core/src/render3/after_render/manager.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,15 @@ export class AfterRenderSequence implements AfterRenderRef {
149149
*/
150150
pipelinedValue: unknown = undefined;
151151

152-
private unregisterOnDestroy: () => void;
152+
private unregisterOnDestroy: (() => void) | undefined;
153153

154154
constructor(
155155
readonly impl: AfterRenderImpl,
156156
readonly hooks: AfterRenderHooks,
157157
public once: boolean,
158-
destroyRef: DestroyRef,
158+
destroyRef: DestroyRef | null,
159159
) {
160-
this.unregisterOnDestroy = destroyRef.onDestroy(() => this.destroy());
160+
this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy());
161161
}
162162

163163
afterRun(): void {
@@ -167,6 +167,6 @@ export class AfterRenderSequence implements AfterRenderRef {
167167

168168
destroy(): void {
169169
this.impl.unregister(this);
170-
this.unregisterOnDestroy();
170+
this.unregisterOnDestroy?.();
171171
}
172172
}

packages/core/test/acceptance/after_render_hook_spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,53 @@ describe('after render hooks', () => {
708708
);
709709
});
710710
});
711+
712+
it('should not destroy automatically if manualCleanup is set', () => {
713+
let afterRenderRef: AfterRenderRef | null = null;
714+
let count = 0;
715+
716+
@Component({selector: 'comp', template: ''})
717+
class Comp {
718+
constructor() {
719+
afterRenderRef = afterRender(() => count++, {manualCleanup: true});
720+
}
721+
}
722+
723+
@Component({
724+
imports: [Comp],
725+
template: `
726+
@if (shouldShow) {
727+
<comp/>
728+
}
729+
`,
730+
})
731+
class App {
732+
shouldShow = true;
733+
}
734+
735+
TestBed.configureTestingModule({
736+
declarations: [App, Comp],
737+
...COMMON_CONFIGURATION,
738+
});
739+
const component = createAndAttachComponent(App);
740+
const appRef = TestBed.inject(ApplicationRef);
741+
expect(count).toBe(0);
742+
743+
appRef.tick();
744+
expect(count).toBe(1);
745+
746+
component.instance.shouldShow = false;
747+
component.changeDetectorRef.detectChanges();
748+
appRef.tick();
749+
expect(count).toBe(2);
750+
appRef.tick();
751+
expect(count).toBe(3);
752+
753+
// Ensure that manual destruction still works.
754+
afterRenderRef!.destroy();
755+
appRef.tick();
756+
expect(count).toBe(3);
757+
});
711758
});
712759

713760
describe('afterNextRender', () => {

0 commit comments

Comments
 (0)