Skip to content

Commit 61a4c48

Browse files
committed
feat(core): explicit effect
This is a mere exploration. fixes #56155
1 parent 770e505 commit 61a4c48

5 files changed

Lines changed: 155 additions & 19 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export function afterNextRender(callback: VoidFunction, options?: AfterRenderOpt
5050
// @public
5151
export function afterRenderEffect(callback: (onCleanup: EffectCleanupRegisterFn) => void, options?: AfterRenderOptions): AfterRenderRef;
5252

53+
// @public
54+
export function afterRenderEffect<T>(reactiveFn: () => T, effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void, options?: AfterRenderOptions): AfterRenderRef;
55+
5356
// @public
5457
export function afterRenderEffect<E = never, W = never, M = never>(spec: {
5558
earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E;
@@ -656,6 +659,9 @@ export const DOCUMENT: InjectionToken<Document>;
656659
// @public
657660
export function effect(effectFn: (onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions): EffectRef;
658661

662+
// @public
663+
export function effect<T>(reactiveFn: () => T, effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions): EffectRef;
664+
659665
// @public
660666
export type EffectCleanupFn = () => void;
661667

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

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import {
1616
SIGNAL_NODE,
1717
type SignalNode,
1818
} from '../../../primitives/signals';
19-
import {type EffectCleanupFn, type EffectCleanupRegisterFn} from './effect';
20-
import {type Signal} from '../reactivity/api';
2119
import {TracingService, TracingSnapshot} from '../../application/tracing';
2220
import {
2321
ChangeDetectionScheduler,
@@ -35,13 +33,16 @@ import {
3533
AfterRenderManager,
3634
AfterRenderSequence,
3735
} from '../after_render/manager';
38-
import {LView} from '../interfaces/view';
39-
import {ViewContext} from '../view_context';
40-
import {assertNotInReactiveContext} from './asserts';
4136
import {
4237
emitAfterRenderEffectPhaseCreatedEvent,
4338
setInjectorProfilerContext,
4439
} from '../debug/injector_profiler';
40+
import {LView} from '../interfaces/view';
41+
import {type Signal} from '../reactivity/api';
42+
import {ViewContext} from '../view_context';
43+
import {assertNotInReactiveContext} from './asserts';
44+
import {type EffectCleanupFn, type EffectCleanupRegisterFn} from './effect';
45+
import {untracked} from './untracked';
4546

4647
const NOT_SET = /* @__PURE__ */ Symbol('NOT_SET');
4748
const EMPTY_CLEANUP_SET = /* @__PURE__ */ new Set<() => void>();
@@ -306,6 +307,11 @@ export function afterRenderEffect(
306307
callback: (onCleanup: EffectCleanupRegisterFn) => void,
307308
options?: AfterRenderOptions,
308309
): AfterRenderRef;
310+
export function afterRenderEffect<T>(
311+
reactiveFn: () => T,
312+
effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void,
313+
options?: AfterRenderOptions,
314+
): AfterRenderRef;
309315
/**
310316
* Register effects that, when triggered, are invoked when the application finishes rendering,
311317
* during the specified phases. The available phases are:
@@ -380,7 +386,7 @@ export function afterRenderEffect<E = never, W = never, M = never>(
380386
* @publicApi
381387
*/
382388
export function afterRenderEffect<E = never, W = never, M = never>(
383-
callbackOrSpec:
389+
callbackSpecOrReactiveFn:
384390
| ((onCleanup: EffectCleanupRegisterFn) => void)
385391
| {
386392
earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E;
@@ -390,8 +396,47 @@ export function afterRenderEffect<E = never, W = never, M = never>(
390396
) => M;
391397
read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void;
392398
},
393-
options?: AfterRenderOptions,
399+
optionsOrEffectFn?:
400+
| AfterRenderOptions
401+
| ((params: unknown, onCleanup: EffectCleanupRegisterFn) => void),
402+
explicitOptions?: AfterRenderOptions,
394403
): AfterRenderRef {
404+
let callbackOrSpec:
405+
| ((onCleanup: EffectCleanupRegisterFn) => void)
406+
| {
407+
earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E;
408+
write?: (...args: [...ɵFirstAvailableSignal<[E]>, EffectCleanupRegisterFn]) => W;
409+
mixedReadWrite?: (
410+
...args: [...ɵFirstAvailableSignal<[W, E]>, EffectCleanupRegisterFn]
411+
) => M;
412+
read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void;
413+
};
414+
let options: AfterRenderOptions | undefined;
415+
416+
if (typeof callbackSpecOrReactiveFn === 'function' && typeof optionsOrEffectFn === 'function') {
417+
const reactiveFn = callbackSpecOrReactiveFn as () => unknown;
418+
const effectFn = optionsOrEffectFn;
419+
callbackOrSpec = {
420+
mixedReadWrite: ((onCleanup: EffectCleanupRegisterFn) => {
421+
const params = reactiveFn();
422+
untracked(() => effectFn(params, onCleanup));
423+
}) as any,
424+
} as any;
425+
options = explicitOptions;
426+
} else {
427+
callbackOrSpec = callbackSpecOrReactiveFn as
428+
| ((onCleanup: EffectCleanupRegisterFn) => void)
429+
| {
430+
earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E;
431+
write?: (...args: [...ɵFirstAvailableSignal<[E]>, EffectCleanupRegisterFn]) => W;
432+
mixedReadWrite?: (
433+
...args: [...ɵFirstAvailableSignal<[W, E]>, EffectCleanupRegisterFn]
434+
) => M;
435+
read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void;
436+
};
437+
options = optionsOrEffectFn as AfterRenderOptions | undefined;
438+
}
439+
395440
ngDevMode &&
396441
assertNotInReactiveContext(
397442
afterRenderEffect,

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

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,29 @@
77
*/
88

99
import {
10+
BASE_EFFECT_NODE,
11+
BaseEffectNode,
1012
SIGNAL,
1113
consumerDestroy,
1214
isInNotificationPhase,
13-
setActiveConsumer,
14-
BaseEffectNode,
15-
BASE_EFFECT_NODE,
1615
runEffect,
16+
setActiveConsumer,
1717
} from '../../../primitives/signals';
18-
import {FLAGS, LViewFlags, LView, EFFECTS} from '../interfaces/view';
19-
import {markAncestorsForTraversal} from '../util/view_utils';
20-
import {inject} from '../../di/injector_compatibility';
21-
import {Injector} from '../../di/injector';
22-
import {assertNotInReactiveContext} from './asserts';
23-
import {assertInInjectionContext} from '../../di/contextual';
24-
import {DestroyRef, NodeInjectorDestroyRef} from '../../linker/destroy_ref';
25-
import {ViewContext} from '../view_context';
2618
import {
2719
ChangeDetectionScheduler,
2820
NotificationSource,
2921
} from '../../change_detection/scheduling/zoneless_scheduling';
22+
import {assertInInjectionContext} from '../../di/contextual';
23+
import {Injector} from '../../di/injector';
24+
import {inject} from '../../di/injector_compatibility';
25+
import {DestroyRef, NodeInjectorDestroyRef} from '../../linker/destroy_ref';
26+
import {EFFECTS, FLAGS, LView, LViewFlags} from '../interfaces/view';
3027
import {setIsRefreshingViews} from '../state';
28+
import {markAncestorsForTraversal} from '../util/view_utils';
29+
import {ViewContext} from '../view_context';
30+
import {assertNotInReactiveContext} from './asserts';
3131
import {EffectScheduler, SchedulableEffect} from './root_effect_scheduler';
32+
import {untracked} from './untracked';
3233

3334
import {emitEffectCreatedEvent, setInjectorProfilerContext} from '../debug/injector_profiler';
3435

@@ -137,7 +138,35 @@ export type EffectCleanupRegisterFn = (cleanupFn: EffectCleanupFn) => void;
137138
export function effect(
138139
effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
139140
options?: CreateEffectOptions,
141+
): EffectRef;
142+
export function effect<T>(
143+
reactiveFn: () => T,
144+
effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void,
145+
options?: CreateEffectOptions,
146+
): EffectRef;
147+
export function effect<T>(
148+
effectFnOrReactiveFn: ((onCleanup: EffectCleanupRegisterFn) => void) | (() => T),
149+
optionsOrEffectFn?:
150+
| CreateEffectOptions
151+
| ((params: T, onCleanup: EffectCleanupRegisterFn) => void),
152+
explicitOptions?: CreateEffectOptions,
140153
): EffectRef {
154+
let effectFn: (onCleanup: EffectCleanupRegisterFn) => void;
155+
let options: CreateEffectOptions | undefined;
156+
157+
if (typeof optionsOrEffectFn === 'function') {
158+
const reactiveFn = effectFnOrReactiveFn as () => T;
159+
const untrackedEffectFn = optionsOrEffectFn;
160+
effectFn = (onCleanup) => {
161+
const params = reactiveFn();
162+
untracked(() => untrackedEffectFn(params, onCleanup));
163+
};
164+
options = explicitOptions;
165+
} else {
166+
effectFn = effectFnOrReactiveFn as (onCleanup: EffectCleanupRegisterFn) => void;
167+
options = optionsOrEffectFn;
168+
}
169+
141170
ngDevMode &&
142171
assertNotInReactiveContext(
143172
effect,

packages/core/test/acceptance/after_render_effect_spec.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import {
1414
provideZonelessChangeDetection,
1515
signal,
1616
} from '../../src/core';
17+
import {AfterRenderPhase} from '../../src/render3/after_render/api';
1718
import {
1819
afterRenderEffect,
1920
AfterRenderEffectSequence,
2021
} from '../../src/render3/reactivity/after_render_effect';
21-
import {AfterRenderPhase} from '../../src/render3/after_render/api';
2222
import {TestBed} from '../../testing';
2323

2424
describe('afterRenderEffect', () => {
@@ -36,6 +36,34 @@ describe('afterRenderEffect', () => {
3636
expect(log).toEqual(['before', 'mixedReadWrite', 'after']);
3737
});
3838

39+
it('should support explicit tracking with an untracked effect callback', () => {
40+
const log: number[] = [];
41+
const appRef = TestBed.inject(ApplicationRef);
42+
const tracked = signal(0);
43+
const untrackedSignal = signal(0);
44+
45+
afterRenderEffect(
46+
() => tracked(),
47+
(value) => {
48+
log.push(value);
49+
// This read should not be tracked as a dependency.
50+
untrackedSignal();
51+
},
52+
{injector: appRef.injector},
53+
);
54+
55+
appRef.tick();
56+
expect(log).toEqual([0]);
57+
58+
untrackedSignal.set(1);
59+
appRef.tick();
60+
expect(log).toEqual([0]);
61+
62+
tracked.set(1);
63+
appRef.tick();
64+
expect(log).toEqual([0, 1]);
65+
});
66+
3967
it('should run once', () => {
4068
const log: string[] = [];
4169
const appRef = TestBed.inject(ApplicationRef);

packages/core/test/render3/reactivity_spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,34 @@ describe('reactivity', () => {
240240
expect(fixture.componentInstance.counter()).toBe(2);
241241
});
242242

243+
it('should support explicit tracking with untracked effect callback', () => {
244+
const tracked = signal(0);
245+
const untrackedSignal = signal(0);
246+
const log: number[] = [];
247+
248+
TestBed.runInInjectionContext(() => {
249+
effect(
250+
() => tracked(),
251+
(value) => {
252+
log.push(value);
253+
// This read should not be tracked as a dependency.
254+
untrackedSignal();
255+
},
256+
);
257+
});
258+
259+
TestBed.tick();
260+
expect(log).toEqual([0]);
261+
262+
untrackedSignal.set(1);
263+
TestBed.tick();
264+
expect(log).toEqual([0]);
265+
266+
tracked.set(1);
267+
TestBed.tick();
268+
expect(log).toEqual([0, 1]);
269+
});
270+
243271
it('should run effects created in ngAfterViewInit', () => {
244272
let didRun = false;
245273

0 commit comments

Comments
 (0)