Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export function afterNextRender(callback: VoidFunction, options?: AfterRenderOpt
// @public
export function afterRenderEffect(callback: (onCleanup: EffectCleanupRegisterFn) => void, options?: AfterRenderOptions): AfterRenderRef;

// @public (undocumented)
export function afterRenderEffect<T>(reactiveFn: () => T, effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void, options?: AfterRenderOptions): AfterRenderRef;

// @public
export function afterRenderEffect<E = never, W = never, M = never>(spec: {
earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E;
Expand Down Expand Up @@ -656,6 +659,9 @@ export const DOCUMENT: InjectionToken<Document>;
// @public
export function effect(effectFn: (onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions): EffectRef;

// @public (undocumented)
export function effect<T>(reactiveFn: () => T, effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions): EffectRef;

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

Expand Down
59 changes: 52 additions & 7 deletions packages/core/src/render3/reactivity/after_render_effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import {
SIGNAL_NODE,
type SignalNode,
} from '../../../primitives/signals';
import {type EffectCleanupFn, type EffectCleanupRegisterFn} from './effect';
import {type Signal} from '../reactivity/api';
import {TracingService, TracingSnapshot} from '../../application/tracing';
import {
ChangeDetectionScheduler,
Expand All @@ -35,13 +33,16 @@ import {
AfterRenderManager,
AfterRenderSequence,
} from '../after_render/manager';
import {LView} from '../interfaces/view';
import {ViewContext} from '../view_context';
import {assertNotInReactiveContext} from './asserts';
import {
emitAfterRenderEffectPhaseCreatedEvent,
setInjectorProfilerContext,
} from '../debug/injector_profiler';
import {LView} from '../interfaces/view';
import {type Signal} from '../reactivity/api';
import {ViewContext} from '../view_context';
import {assertNotInReactiveContext} from './asserts';
import {type EffectCleanupFn, type EffectCleanupRegisterFn} from './effect';
import {untracked} from './untracked';

const NOT_SET = /* @__PURE__ */ Symbol('NOT_SET');
const EMPTY_CLEANUP_SET = /* @__PURE__ */ new Set<() => void>();
Expand Down Expand Up @@ -306,6 +307,11 @@ export function afterRenderEffect(
callback: (onCleanup: EffectCleanupRegisterFn) => void,
options?: AfterRenderOptions,
): AfterRenderRef;
export function afterRenderEffect<T>(
reactiveFn: () => T,
effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void,
options?: AfterRenderOptions,
): AfterRenderRef;
/**
* Register effects that, when triggered, are invoked when the application finishes rendering,
* during the specified phases. The available phases are:
Expand Down Expand Up @@ -380,7 +386,7 @@ export function afterRenderEffect<E = never, W = never, M = never>(
* @publicApi
*/
export function afterRenderEffect<E = never, W = never, M = never>(
callbackOrSpec:
callbackSpecOrReactiveFn:
| ((onCleanup: EffectCleanupRegisterFn) => void)
| {
earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E;
Expand All @@ -390,8 +396,47 @@ export function afterRenderEffect<E = never, W = never, M = never>(
) => M;
read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void;
},
options?: AfterRenderOptions,
optionsOrEffectFn?:
| AfterRenderOptions
| ((params: unknown, onCleanup: EffectCleanupRegisterFn) => void),
explicitOptions?: AfterRenderOptions,
): AfterRenderRef {
let callbackOrSpec:
| ((onCleanup: EffectCleanupRegisterFn) => void)
| {
earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E;
write?: (...args: [...ɵFirstAvailableSignal<[E]>, EffectCleanupRegisterFn]) => W;
mixedReadWrite?: (
...args: [...ɵFirstAvailableSignal<[W, E]>, EffectCleanupRegisterFn]
) => M;
read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void;
};
let options: AfterRenderOptions | undefined;

if (typeof callbackSpecOrReactiveFn === 'function' && typeof optionsOrEffectFn === 'function') {
const reactiveFn = callbackSpecOrReactiveFn as () => unknown;
const effectFn = optionsOrEffectFn;
callbackOrSpec = {
mixedReadWrite: ((onCleanup: EffectCleanupRegisterFn) => {
const params = reactiveFn();
untracked(() => effectFn(params, onCleanup));
}) as any,
} as any;
options = explicitOptions;
} else {
callbackOrSpec = callbackSpecOrReactiveFn as
| ((onCleanup: EffectCleanupRegisterFn) => void)
| {
earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E;
write?: (...args: [...ɵFirstAvailableSignal<[E]>, EffectCleanupRegisterFn]) => W;
mixedReadWrite?: (
...args: [...ɵFirstAvailableSignal<[W, E]>, EffectCleanupRegisterFn]
) => M;
read?: (...args: [...ɵFirstAvailableSignal<[M, W, E]>, EffectCleanupRegisterFn]) => void;
};
options = optionsOrEffectFn as AfterRenderOptions | undefined;
}

ngDevMode &&
assertNotInReactiveContext(
afterRenderEffect,
Expand Down
51 changes: 40 additions & 11 deletions packages/core/src/render3/reactivity/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,29 @@
*/

import {
BASE_EFFECT_NODE,
BaseEffectNode,
SIGNAL,
consumerDestroy,
isInNotificationPhase,
setActiveConsumer,
BaseEffectNode,
BASE_EFFECT_NODE,
runEffect,
setActiveConsumer,
} from '../../../primitives/signals';
import {FLAGS, LViewFlags, LView, EFFECTS} from '../interfaces/view';
import {markAncestorsForTraversal} from '../util/view_utils';
import {inject} from '../../di/injector_compatibility';
import {Injector} from '../../di/injector';
import {assertNotInReactiveContext} from './asserts';
import {assertInInjectionContext} from '../../di/contextual';
import {DestroyRef, NodeInjectorDestroyRef} from '../../linker/destroy_ref';
import {ViewContext} from '../view_context';
import {
ChangeDetectionScheduler,
NotificationSource,
} from '../../change_detection/scheduling/zoneless_scheduling';
import {assertInInjectionContext} from '../../di/contextual';
import {Injector} from '../../di/injector';
import {inject} from '../../di/injector_compatibility';
import {DestroyRef, NodeInjectorDestroyRef} from '../../linker/destroy_ref';
import {EFFECTS, FLAGS, LView, LViewFlags} from '../interfaces/view';
import {setIsRefreshingViews} from '../state';
import {markAncestorsForTraversal} from '../util/view_utils';
import {ViewContext} from '../view_context';
import {assertNotInReactiveContext} from './asserts';
import {EffectScheduler, SchedulableEffect} from './root_effect_scheduler';
import {untracked} from './untracked';

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

Expand Down Expand Up @@ -137,7 +138,35 @@ export type EffectCleanupRegisterFn = (cleanupFn: EffectCleanupFn) => void;
export function effect(
effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
options?: CreateEffectOptions,
): EffectRef;
export function effect<T>(
reactiveFn: () => T,
effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void,
options?: CreateEffectOptions,
): EffectRef;
export function effect<T>(
effectFnOrReactiveFn: ((onCleanup: EffectCleanupRegisterFn) => void) | (() => T),
optionsOrEffectFn?:
| CreateEffectOptions
| ((params: T, onCleanup: EffectCleanupRegisterFn) => void),
explicitOptions?: CreateEffectOptions,
): EffectRef {
let effectFn: (onCleanup: EffectCleanupRegisterFn) => void;
let options: CreateEffectOptions | undefined;

if (typeof optionsOrEffectFn === 'function') {
const reactiveFn = effectFnOrReactiveFn as () => T;
const untrackedEffectFn = optionsOrEffectFn;
effectFn = (onCleanup) => {
const params = reactiveFn();
untracked(() => untrackedEffectFn(params, onCleanup));
};
options = explicitOptions;
} else {
effectFn = effectFnOrReactiveFn as (onCleanup: EffectCleanupRegisterFn) => void;
options = optionsOrEffectFn;
}

ngDevMode &&
assertNotInReactiveContext(
effect,
Expand Down
76 changes: 75 additions & 1 deletion packages/core/test/acceptance/after_render_effect_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import {
provideZonelessChangeDetection,
signal,
} from '../../src/core';
import {AfterRenderPhase} from '../../src/render3/after_render/api';
import {
afterRenderEffect,
AfterRenderEffectSequence,
} from '../../src/render3/reactivity/after_render_effect';
import {AfterRenderPhase} from '../../src/render3/after_render/api';
import {TestBed} from '../../testing';

describe('afterRenderEffect', () => {
Expand All @@ -36,6 +36,80 @@ describe('afterRenderEffect', () => {
expect(log).toEqual(['before', 'mixedReadWrite', 'after']);
});

it('should support explicit tracking with an untracked effect callback', () => {
const log: number[] = [];
const appRef = TestBed.inject(ApplicationRef);
const tracked = signal(0);
const untrackedSignal = signal(0);

afterRenderEffect(
() => tracked(),
(value) => {
log.push(value);
// This read should not be tracked as a dependency.
untrackedSignal();
},
{injector: appRef.injector},
);

appRef.tick();
expect(log).toEqual([0]);

untrackedSignal.set(1);
appRef.tick();
expect(log).toEqual([0]);

tracked.set(1);
appRef.tick();
expect(log).toEqual([0, 1]);
});

it('should run explicit and phased hooks in phase order', () => {
const log: string[] = [];
const appRef = TestBed.inject(ApplicationRef);
const tracked = signal(0);
const untrackedSignal = signal(0);

afterRenderEffect(
() => tracked(),
(value) => {
log.push(`explicit: ${value}`);
// This read should not be tracked as a dependency.
untrackedSignal();
},
{injector: appRef.injector},
);

afterRenderEffect(
{
earlyRead: () => log.push('phase: earlyRead'),
write: () => log.push('phase: write'),
mixedReadWrite: () => log.push('phase: mixedReadWrite'),
read: () => log.push('phase: read'),
},
{injector: appRef.injector},
);

appRef.tick();
expect(log).toEqual([
'phase: earlyRead',
'phase: write',
'explicit: 0',
'phase: mixedReadWrite',
'phase: read',
]);

log.length = 0;

untrackedSignal.set(1);
appRef.tick();
expect(log).toEqual([]);

tracked.set(1);
appRef.tick();
expect(log).toEqual(['explicit: 1']);
});

it('should run once', () => {
const log: string[] = [];
const appRef = TestBed.inject(ApplicationRef);
Expand Down
28 changes: 28 additions & 0 deletions packages/core/test/render3/reactivity_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,34 @@ describe('reactivity', () => {
expect(fixture.componentInstance.counter()).toBe(2);
});

it('should support explicit tracking with untracked effect callback', () => {
const tracked = signal(0);
const untrackedSignal = signal(0);
const log: number[] = [];

TestBed.runInInjectionContext(() => {
effect(
() => tracked(),
(value) => {
log.push(value);
// This read should not be tracked as a dependency.
untrackedSignal();
},
);
});

TestBed.tick();
expect(log).toEqual([0]);

untrackedSignal.set(1);
TestBed.tick();
expect(log).toEqual([0]);

tracked.set(1);
TestBed.tick();
expect(log).toEqual([0, 1]);
});

it('should run effects created in ngAfterViewInit', () => {
let didRun = false;

Expand Down
Loading