diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 1cc47bdb4250..5b55cfab4ccb 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -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(reactiveFn: () => T, effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void, options?: AfterRenderOptions): AfterRenderRef; + // @public export function afterRenderEffect(spec: { earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E; @@ -656,6 +659,9 @@ export const DOCUMENT: InjectionToken; // @public export function effect(effectFn: (onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions): EffectRef; +// @public (undocumented) +export function effect(reactiveFn: () => T, effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions): EffectRef; + // @public export type EffectCleanupFn = () => void; diff --git a/packages/core/src/render3/reactivity/after_render_effect.ts b/packages/core/src/render3/reactivity/after_render_effect.ts index 3230e056a29e..37fe626a7489 100644 --- a/packages/core/src/render3/reactivity/after_render_effect.ts +++ b/packages/core/src/render3/reactivity/after_render_effect.ts @@ -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, @@ -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>(); @@ -306,6 +307,11 @@ export function afterRenderEffect( callback: (onCleanup: EffectCleanupRegisterFn) => void, options?: AfterRenderOptions, ): AfterRenderRef; +export function afterRenderEffect( + 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: @@ -380,7 +386,7 @@ export function afterRenderEffect( * @publicApi */ export function afterRenderEffect( - callbackOrSpec: + callbackSpecOrReactiveFn: | ((onCleanup: EffectCleanupRegisterFn) => void) | { earlyRead?: (onCleanup: EffectCleanupRegisterFn) => E; @@ -390,8 +396,47 @@ export function afterRenderEffect( ) => 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, diff --git a/packages/core/src/render3/reactivity/effect.ts b/packages/core/src/render3/reactivity/effect.ts index e3bac7611469..4d6bc106a495 100644 --- a/packages/core/src/render3/reactivity/effect.ts +++ b/packages/core/src/render3/reactivity/effect.ts @@ -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'; @@ -137,7 +138,35 @@ export type EffectCleanupRegisterFn = (cleanupFn: EffectCleanupFn) => void; export function effect( effectFn: (onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions, +): EffectRef; +export function effect( + reactiveFn: () => T, + effectFn: (params: T, onCleanup: EffectCleanupRegisterFn) => void, + options?: CreateEffectOptions, +): EffectRef; +export function effect( + 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, diff --git a/packages/core/test/acceptance/after_render_effect_spec.ts b/packages/core/test/acceptance/after_render_effect_spec.ts index 202e1211bcb0..fa08007a9276 100644 --- a/packages/core/test/acceptance/after_render_effect_spec.ts +++ b/packages/core/test/acceptance/after_render_effect_spec.ts @@ -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', () => { @@ -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); diff --git a/packages/core/test/render3/reactivity_spec.ts b/packages/core/test/render3/reactivity_spec.ts index 8f68592709e2..1086bf79162a 100644 --- a/packages/core/test/render3/reactivity_spec.ts +++ b/packages/core/test/render3/reactivity_spec.ts @@ -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;