Skip to content

Commit f3efe63

Browse files
committed
fix(core): provide flag to opt into manual cleanup for after render hooks
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.
1 parent 9020a50 commit f3efe63

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
@@ -63,6 +63,7 @@ export function afterRenderEffect<E = never, W = never, M = never>(spec: {
6363
// @public
6464
export interface AfterRenderOptions {
6565
injector?: Injector;
66+
manualCleanup?: boolean;
6667
// @deprecated
6768
phase?: AfterRenderPhase;
6869
}

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
@@ -715,6 +715,53 @@ describe('after render hooks', () => {
715715
);
716716
});
717717
});
718+
719+
it('should not destroy automatically if manualCleanup is set', () => {
720+
let afterRenderRef: AfterRenderRef | null = null;
721+
let count = 0;
722+
723+
@Component({selector: 'comp', template: ''})
724+
class Comp {
725+
constructor() {
726+
afterRenderRef = afterRender(() => count++, {manualCleanup: true});
727+
}
728+
}
729+
730+
@Component({
731+
imports: [Comp],
732+
template: `
733+
@if (shouldShow) {
734+
<comp/>
735+
}
736+
`,
737+
})
738+
class App {
739+
shouldShow = true;
740+
}
741+
742+
TestBed.configureTestingModule({
743+
declarations: [App, Comp],
744+
...COMMON_CONFIGURATION,
745+
});
746+
const component = createAndAttachComponent(App);
747+
const appRef = TestBed.inject(ApplicationRef);
748+
expect(count).toBe(0);
749+
750+
appRef.tick();
751+
expect(count).toBe(1);
752+
753+
component.instance.shouldShow = false;
754+
component.changeDetectorRef.detectChanges();
755+
appRef.tick();
756+
expect(count).toBe(2);
757+
appRef.tick();
758+
expect(count).toBe(3);
759+
760+
// Ensure that manual destruction still works.
761+
afterRenderRef!.destroy();
762+
appRef.tick();
763+
expect(count).toBe(3);
764+
});
718765
});
719766

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

0 commit comments

Comments
 (0)