Skip to content

Commit f8e2586

Browse files
fix(core): allow async functions in effects (#49783)
This change makes is possible to use async functions (ones returning a promise) as effect run functions. To make it possible, the signature of the effect function changed: effect cleanup function is registered now (using a dedicated callback passed to the effect creation) instead of being returned from the effect function. PR Close #49783
1 parent a845a16 commit f8e2586

File tree

6 files changed

+70
-19
lines changed

6 files changed

+70
-19
lines changed

goldens/public-api/core/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ export interface DoCheck {
512512
}
513513

514514
// @public
515-
export function effect(effectFn: () => EffectCleanupFn | void, options?: CreateEffectOptions): EffectRef;
515+
export function effect(effectFn: (onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions): EffectRef;
516516

517517
// @public
518518
export type EffectCleanupFn = () => void;

packages/core/src/render3/reactivity/effect.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,29 @@ import {DestroyRef} from '../../linker/destroy_ref';
1414
import {Watch} from '../../signals';
1515

1616
/**
17-
* An effect can, optionally, return a cleanup function. If returned, the cleanup is executed before
18-
* the next effect run. The cleanup function makes it possible to "cancel" any work that the
17+
* An effect can, optionally, register a cleanup function. If registered, the cleanup is executed
18+
* before the next effect run. The cleanup function makes it possible to "cancel" any work that the
1919
* previous effect run might have started.
2020
*
2121
* @developerPreview
2222
*/
2323
export type EffectCleanupFn = () => void;
2424

25+
/**
26+
* A callback passed to the effect function that makes it possible to register cleanup logic.
27+
*/
28+
export type EffectCleanupRegisterFn = (cleanupFn: EffectCleanupFn) => void;
29+
2530
/**
2631
* Tracks all effects registered within a given application and runs them via `flush`.
2732
*/
2833
export class EffectManager {
2934
private all = new Set<Watch>();
3035
private queue = new Map<Watch, Zone>();
3136

32-
create(effectFn: () => void, destroyRef: DestroyRef|null, allowSignalWrites: boolean): EffectRef {
37+
create(
38+
effectFn: (onCleanup: (cleanupFn: EffectCleanupFn) => void) => void,
39+
destroyRef: DestroyRef|null, allowSignalWrites: boolean): EffectRef {
3340
const zone = Zone.current;
3441
const watch = new Watch(effectFn, (watch) => {
3542
if (!this.all.has(watch)) {
@@ -131,7 +138,8 @@ export interface CreateEffectOptions {
131138
* @developerPreview
132139
*/
133140
export function effect(
134-
effectFn: () => EffectCleanupFn | void, options?: CreateEffectOptions): EffectRef {
141+
effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
142+
options?: CreateEffectOptions): EffectRef {
135143
!options?.injector && assertInInjectionContext(effect);
136144
const injector = options?.injector ?? inject(Injector);
137145
const effectManager = injector.get(EffectManager);

packages/core/src/signals/src/watch.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@
99
import {ReactiveNode, setActiveConsumer} from './graph';
1010

1111
/**
12-
* A cleanup function that can be optionally returned from the watch logic. When returned, the
12+
* A cleanup function that can be optionally registered from the watch logic. If registered, the
1313
* cleanup logic runs before the next watch execution.
1414
*/
1515
export type WatchCleanupFn = () => void;
1616

17+
/**
18+
* A callback passed to the watch function that makes it possible to register cleanup logic.
19+
*/
20+
export type WatchCleanupRegisterFn = (cleanupFn: WatchCleanupFn) => void;
21+
1722
const NOOP_CLEANUP_FN: WatchCleanupFn = () => {};
1823

1924
/**
@@ -27,10 +32,14 @@ export class Watch extends ReactiveNode {
2732
protected override readonly consumerAllowSignalWrites: boolean;
2833
private dirty = false;
2934
private cleanupFn = NOOP_CLEANUP_FN;
35+
private registerOnCleanup =
36+
(cleanupFn: WatchCleanupFn) => {
37+
this.cleanupFn = cleanupFn;
38+
}
3039

3140
constructor(
32-
private watch: () => void|WatchCleanupFn, private schedule: (watch: Watch) => void,
33-
allowSignalWrites: boolean) {
41+
private watch: (onCleanup: WatchCleanupRegisterFn) => void,
42+
private schedule: (watch: Watch) => void, allowSignalWrites: boolean) {
3443
super();
3544
this.consumerAllowSignalWrites = allowSignalWrites;
3645
}
@@ -66,7 +75,8 @@ export class Watch extends ReactiveNode {
6675
this.trackingVersion++;
6776
try {
6877
this.cleanupFn();
69-
this.cleanupFn = this.watch() ?? NOOP_CLEANUP_FN;
78+
this.cleanupFn = NOOP_CLEANUP_FN;
79+
this.watch(this.registerOnCleanup);
7080
} finally {
7181
setActiveConsumer(prevConsumer);
7282
}

packages/core/test/render3/reactivity_spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,11 @@ describe('effects', () => {
130130
})
131131
class Cmp {
132132
counter = signal(0);
133-
effectRef = effect(() => {
133+
effectRef = effect((onCleanup) => {
134134
counterLog.push(this.counter());
135-
return () => {
135+
onCleanup(() => {
136136
cleanupCount++;
137-
};
137+
});
138138
});
139139
}
140140

@@ -179,6 +179,7 @@ describe('effects', () => {
179179

180180
expect(didRun).toBeTrue();
181181
});
182+
182183
it('should disallow writing to signals within effects by default',
183184
withBody('<test-cmp></test-cmp>', async () => {
184185
@Component({

packages/core/test/signals/effect_util.ts

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

9-
import {Watch} from '@angular/core/src/signals';
9+
import {Watch, WatchCleanupFn} from '@angular/core/src/signals';
1010

1111
let queue = new Set<Watch>();
1212

1313
/**
1414
* A wrapper around `Watch` that emulates the `effect` API and allows for more streamlined testing.
1515
*/
16-
export function testingEffect(effectFn: () => void): void {
16+
export function testingEffect(effectFn: (onCleanup: (cleanupFn: WatchCleanupFn) => void) => void):
17+
void {
1718
const watch = new Watch(effectFn, queue.add.bind(queue), true);
1819

1920
// Effects start dirty.

packages/core/test/signals/watch_spec.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,19 @@ describe('watchers', () => {
100100
expect(updateCounter).toEqual(3);
101101
});
102102

103-
it('should allow returning cleanup function from the watch logic', () => {
103+
it('should allow registering cleanup function from the watch logic', () => {
104104
const source = signal(0);
105105

106106
const seenCounterValues: number[] = [];
107-
testingEffect(() => {
107+
testingEffect((onCleanup) => {
108108
seenCounterValues.push(source());
109109

110-
// return a cleanup function that is executed every time an effect re-runs
111-
return () => {
110+
// register a cleanup function that is executed every time an effect re-runs
111+
onCleanup(() => {
112112
if (seenCounterValues.length === 2) {
113113
seenCounterValues.length = 0;
114114
}
115-
};
115+
});
116116
});
117117

118118
flushEffects();
@@ -128,6 +128,37 @@ describe('watchers', () => {
128128
expect(seenCounterValues).toEqual([2]);
129129
});
130130

131+
it('should forget previously registered cleanup function when effect re-runs', () => {
132+
const source = signal(0);
133+
134+
const seenCounterValues: number[] = [];
135+
testingEffect((onCleanup) => {
136+
const value = source();
137+
138+
seenCounterValues.push(value);
139+
140+
// register a cleanup function that is executed next time an effect re-runs
141+
if (value === 0) {
142+
onCleanup(() => {
143+
seenCounterValues.length = 0;
144+
});
145+
}
146+
});
147+
148+
flushEffects();
149+
expect(seenCounterValues).toEqual([0]);
150+
151+
source.set(2);
152+
flushEffects();
153+
// cleanup (array trim) should have run before executing effect
154+
expect(seenCounterValues).toEqual([2]);
155+
156+
source.set(3);
157+
flushEffects();
158+
// cleanup (array trim) should _not_ be registered again
159+
expect(seenCounterValues).toEqual([2, 3]);
160+
});
161+
131162
it('should throw an error when reading a signal during the notification phase', () => {
132163
const source = signal(0);
133164
let ranScheduler = false;

0 commit comments

Comments
 (0)