diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index b7a22731540..df68223fd54 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -447,7 +447,9 @@ export interface CreateComputedOptions { // @public export interface CreateEffectOptions { + // @deprecated (undocumented) allowSignalWrites?: boolean; + forceRoot?: true; injector?: Injector; manualCleanup?: boolean; } diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 9f06cf3ff8d..5bfd7ca74ac 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -41,7 +41,7 @@ }, "standalone-bootstrap": { "uncompressed": { - "main": 89354, + "main": 94769, "polyfills": 33802 } }, diff --git a/packages/core/rxjs-interop/src/index.ts b/packages/core/rxjs-interop/src/index.ts index 68aa041f04b..d46a08ab657 100644 --- a/packages/core/rxjs-interop/src/index.ts +++ b/packages/core/rxjs-interop/src/index.ts @@ -9,5 +9,9 @@ export {outputFromObservable} from './output_from_observable'; export {outputToObservable} from './output_to_observable'; export {takeUntilDestroyed} from './take_until_destroyed'; -export {toObservable, ToObservableOptions} from './to_observable'; +export { + toObservable, + ToObservableOptions, + toObservableMicrotask as ɵtoObservableMicrotask, +} from './to_observable'; export {toSignal, ToSignalOptions} from './to_signal'; diff --git a/packages/core/rxjs-interop/src/to_observable.ts b/packages/core/rxjs-interop/src/to_observable.ts index 6851dd16265..804313c575e 100644 --- a/packages/core/rxjs-interop/src/to_observable.ts +++ b/packages/core/rxjs-interop/src/to_observable.ts @@ -14,6 +14,7 @@ import { Injector, Signal, untracked, + ɵmicrotaskEffect as microtaskEffect, } from '@angular/core'; import {Observable, ReplaySubject} from 'rxjs'; @@ -67,3 +68,33 @@ export function toObservable(source: Signal, options?: ToObservableOptions return subject.asObservable(); } + +export function toObservableMicrotask( + source: Signal, + options?: ToObservableOptions, +): Observable { + !options?.injector && assertInInjectionContext(toObservable); + const injector = options?.injector ?? inject(Injector); + const subject = new ReplaySubject(1); + + const watcher = microtaskEffect( + () => { + let value: T; + try { + value = source(); + } catch (err) { + untracked(() => subject.error(err)); + return; + } + untracked(() => subject.next(value)); + }, + {injector, manualCleanup: true}, + ); + + injector.get(DestroyRef).onDestroy(() => { + watcher.destroy(); + subject.complete(); + }); + + return subject.asObservable(); +} diff --git a/packages/core/src/application/application_ref.ts b/packages/core/src/application/application_ref.ts index 76edf618275..96847502eba 100644 --- a/packages/core/src/application/application_ref.ts +++ b/packages/core/src/application/application_ref.ts @@ -44,6 +44,7 @@ import {isPromise} from '../util/lang'; import {NgZone} from '../zone/ng_zone'; import {ApplicationInitStatus} from './application_init'; +import {EffectScheduler} from '../render3/reactivity/root_effect_scheduler'; /** * A DI token that provides a set of callbacks to @@ -310,6 +311,7 @@ export class ApplicationRef { private readonly internalErrorHandler = inject(INTERNAL_APPLICATION_ERROR_HANDLER); private readonly afterRenderManager = inject(AfterRenderManager); private readonly zonelessEnabled = inject(ZONELESS_ENABLED); + private readonly rootEffectScheduler = inject(EffectScheduler); /** * Current dirty state of the application across a number of dimensions (views, afterRender hooks, @@ -647,6 +649,12 @@ export class ApplicationRef { this.dirtyFlags |= this.deferredDirtyFlags; this.deferredDirtyFlags = ApplicationRefDirtyFlags.None; + // First, process any dirty root effects. + if (this.dirtyFlags & ApplicationRefDirtyFlags.RootEffects) { + this.dirtyFlags &= ~ApplicationRefDirtyFlags.RootEffects; + this.rootEffectScheduler.flush(); + } + // First check dirty views, if there are any. if (this.dirtyFlags & ApplicationRefDirtyFlags.ViewTreeAny) { // Change detection on views starts in targeted mode (only check components if they're @@ -677,8 +685,12 @@ export class ApplicationRef { // Check if any views are still dirty after checking and we need to loop back. this.syncDirtyFlagsWithViews(); - if (this.dirtyFlags & ApplicationRefDirtyFlags.ViewTreeAny) { - // If any views are still dirty after checking, loop back before running render hooks. + if ( + this.dirtyFlags & + (ApplicationRefDirtyFlags.ViewTreeAny | ApplicationRefDirtyFlags.RootEffects) + ) { + // If any views or effects are still dirty after checking, loop back before running render + // hooks. return; } } else { @@ -873,6 +885,11 @@ export const enum ApplicationRefDirtyFlags { * After render hooks need to run. */ AfterRender = 0b00001000, + + /** + * Effects at the `ApplicationRef` level. + */ + RootEffects = 0b00010000, } let whenStableStore: WeakMap> | undefined; diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts index c4330a29334..297d9bb22ea 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts @@ -50,6 +50,8 @@ export const enum NotificationSource { // The scheduler is notified when a pending task is removed via the public API. // This allows us to make stability async, delayed until the next application tick. PendingTaskRemoved, + // An `effect()` outside of the view tree became dirty and might need to run. + RootEffect, } /** diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts index 8ed9ef6a158..3be5fd3ec88 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts @@ -153,6 +153,10 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { force = true; break; } + case NotificationSource.RootEffect: { + this.appRef.dirtyFlags |= ApplicationRefDirtyFlags.RootEffects; + break; + } case NotificationSource.PendingTaskRemoved: { // Removing a pending task via the public API forces a scheduled tick, ensuring that // stability is async and delayed until there was at least an opportunity to run diff --git a/packages/core/src/core_reactivity_export_internal.ts b/packages/core/src/core_reactivity_export_internal.ts index be39859102f..392a26f8ace 100644 --- a/packages/core/src/core_reactivity_export_internal.ts +++ b/packages/core/src/core_reactivity_export_internal.ts @@ -21,7 +21,11 @@ export { EffectRef, EffectCleanupFn, EffectCleanupRegisterFn, - EffectScheduler as ɵEffectScheduler, } from './render3/reactivity/effect'; +export { + MicrotaskEffectScheduler as ɵMicrotaskEffectScheduler, + microtaskEffect as ɵmicrotaskEffect, +} from './render3/reactivity/microtask_effect'; +export {EffectScheduler as ɵEffectScheduler} from './render3/reactivity/root_effect_scheduler'; export {afterRenderEffect, ɵFirstAvailableSignal} from './render3/reactivity/after_render_effect'; export {assertNotInReactiveContext} from './render3/reactivity/asserts'; diff --git a/packages/core/src/linker/destroy_ref.ts b/packages/core/src/linker/destroy_ref.ts index 148a250d8ef..80c73296b5c 100644 --- a/packages/core/src/linker/destroy_ref.ts +++ b/packages/core/src/linker/destroy_ref.ts @@ -56,8 +56,8 @@ export abstract class DestroyRef { static __NG_ENV_ID__: (injector: EnvironmentInjector) => DestroyRef = (injector) => injector; } -class NodeInjectorDestroyRef extends DestroyRef { - constructor(private _lView: LView) { +export class NodeInjectorDestroyRef extends DestroyRef { + constructor(readonly _lView: LView) { super(); } diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 6b3810e7459..2cd45cfae5f 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -266,8 +266,6 @@ export class ComponentFactory extends AbstractComponentFactory { const environment: LViewEnvironment = { rendererFactory, sanitizer, - // We don't use inline effects (yet). - inlineEffectRunner: null, changeDetectionScheduler, }; diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index daec6e90950..99d435e0432 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -68,6 +68,7 @@ import { processHostBindingOpCodes, refreshContentQueries, } from './shared'; +import {runEffectsInView} from '../reactivity/view_effect_runner'; /** * The maximum number of times the change detection traversal will rerun before throwing an error. @@ -101,10 +102,6 @@ export function detectChangesInternal( } finally { if (!checkNoChangesMode) { rendererFactory.end?.(); - - // One final flush of the effects queue to catch any effects created in `ngAfterViewInit` or - // other post-order hooks. - environment.inlineEffectRunner?.flush(); } } } @@ -204,8 +201,6 @@ export function refreshView( const isInCheckNoChangesPass = ngDevMode && isInCheckNoChangesMode(); const isInExhaustiveCheckNoChangesPass = ngDevMode && isExhaustiveCheckNoChanges(); - !isInCheckNoChangesPass && lView[ENVIRONMENT].inlineEffectRunner?.flush(); - // Start component reactive context // - We might already be in a reactive context if this is an embedded view of the host. // - We might be descending into a view that needs a consumer. @@ -269,6 +264,7 @@ export function refreshView( // `LView` but its declaration appears after the insertion component. markTransplantedViewsForRefresh(lView); } + runEffectsInView(lView); detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Global); // Content query results must be refreshed before content hooks are called. @@ -496,6 +492,7 @@ function detectChangesInView(lView: LView, mode: ChangeDetectionMode) { if (shouldRefreshView) { refreshView(tView, lView, tView.template, lView[CONTEXT]); } else if (flags & LViewFlags.HasChildViewsToRefresh) { + runEffectsInView(lView); detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted); const components = tView.components; if (components !== null) { diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 945df2f5711..5d91c6a3a66 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -15,7 +15,7 @@ import {SchemaMetadata} from '../../metadata/schema'; import {Sanitizer} from '../../sanitization/sanitizer'; import type {AfterRenderManager} from '../after_render/manager'; import type {ReactiveLViewConsumer} from '../reactive_lview_consumer'; -import type {EffectScheduler} from '../reactivity/effect'; +import type {ViewEffectNode} from '../reactivity/effect'; import {LContainer} from './container'; import { @@ -66,7 +66,8 @@ export const ID = 19; export const EMBEDDED_VIEW_INJECTOR = 20; export const ON_DESTROY_HOOKS = 21; export const EFFECTS_TO_SCHEDULE = 22; -export const REACTIVE_TEMPLATE_CONSUMER = 23; +export const EFFECTS = 23; +export const REACTIVE_TEMPLATE_CONSUMER = 24; /** * Size of LView's header. Necessary to adjust for it when setting slots. @@ -346,6 +347,8 @@ export interface LView extends Array { */ [EFFECTS_TO_SCHEDULE]: Array<() => void> | null; + [EFFECTS]: Set | null; + /** * A collection of callbacks functions that are executed when a given LView is destroyed. Those * are user defined, LView-specific destroy callbacks that don't have any corresponding TView @@ -372,9 +375,6 @@ export interface LViewEnvironment { /** An optional custom sanitizer. */ sanitizer: Sanitizer | null; - /** Container for reactivity system `effect`s. */ - inlineEffectRunner: EffectScheduler | null; - /** Scheduler for change detection to notify when application state changes. */ changeDetectionScheduler: ChangeDetectionScheduler | null; } diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index 952144b58f2..22612431849 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -63,6 +63,7 @@ import { DECLARATION_COMPONENT_VIEW, DECLARATION_LCONTAINER, DestroyHookData, + EFFECTS, ENVIRONMENT, FLAGS, HookData, @@ -91,6 +92,7 @@ import { unwrapRNode, updateAncestorTraversalFlagsOnAttach, } from './util/view_utils'; +import {EMPTY_ARRAY} from '../util/empty'; const enum WalkTNodeTreeAction { /** node create in the native environment. Run on initial creation. */ @@ -553,6 +555,15 @@ function processCleanups(tView: TView, lView: LView): void { destroyHooksFn(); } } + + // Destroy effects registered to the view. Many of these will have been processed above. + const effects = lView[EFFECTS]; + if (effects !== null) { + lView[EFFECTS] = null; + for (const effect of effects) { + effect.destroy(); + } + } } /** Calls onDestroy hooks for this view */ diff --git a/packages/core/src/render3/reactivity/effect.ts b/packages/core/src/render3/reactivity/effect.ts index be05f978d2b..7ee53117041 100644 --- a/packages/core/src/render3/reactivity/effect.ts +++ b/packages/core/src/render3/reactivity/effect.ts @@ -6,197 +6,46 @@ * found in the LICENSE file at https://angular.io/license */ -import {createWatch, Watch, WatchCleanupRegisterFn} from '@angular/core/primitives/signals'; - -import {ChangeDetectorRef} from '../../change_detection'; -import {assertInInjectionContext} from '../../di/contextual'; +import { + REACTIVE_NODE, + ReactiveNode, + SIGNAL, + consumerAfterComputation, + consumerBeforeComputation, + consumerDestroy, + consumerPollProducersForChange, + isInNotificationPhase, +} from '@angular/core/primitives/signals'; +import {FLAGS, LViewFlags, LView, EFFECTS} from '../interfaces/view'; +import {markAncestorsForTraversal} from '../util/view_utils'; import {InjectionToken} from '../../di/injection_token'; -import {Injector} from '../../di/injector'; import {inject} from '../../di/injector_compatibility'; -import {ɵɵdefineInjectable} from '../../di/interface/defs'; -import {ErrorHandler} from '../../error_handler'; -import type {ViewRef} from '../view_ref'; -import {DestroyRef} from '../../linker/destroy_ref'; -import {FLAGS, LViewFlags, EFFECTS_TO_SCHEDULE} from '../interfaces/view'; - -import {assertNotInReactiveContext} from './asserts'; import {performanceMarkFeature} from '../../util/performance'; -import {PendingTasks} from '../../pending_tasks'; +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 {noop} from '../../util/noop'; +import {ErrorHandler} from '../../error_handler'; +import { + ChangeDetectionScheduler, + NotificationSource, +} from '../../change_detection/scheduling/zoneless_scheduling'; +import {setIsRefreshingViews} from '../state'; +import {EffectScheduler, SchedulableEffect} from './root_effect_scheduler'; +import {USE_MICROTASK_EFFECT_BY_DEFAULT} from './patch'; +import {microtaskEffect} from './microtask_effect'; + +let useMicrotaskEffectsByDefault = USE_MICROTASK_EFFECT_BY_DEFAULT; /** - * An effect can, optionally, register a cleanup function. If registered, the cleanup is executed - * before the next effect run. The cleanup function makes it possible to "cancel" any work that the - * previous effect run might have started. - * - * @developerPreview + * Toggle the flag on whether to use microtask effects (for testing). */ -export type EffectCleanupFn = () => void; - -/** - * A callback passed to the effect function that makes it possible to register cleanup logic. - * - * @developerPreview - */ -export type EffectCleanupRegisterFn = (cleanupFn: EffectCleanupFn) => void; - -export interface SchedulableEffect { - run(): void; - creationZone: unknown; -} - -/** - * Not public API, which guarantees `EffectScheduler` only ever comes from the application root - * injector. - */ -export const APP_EFFECT_SCHEDULER = new InjectionToken('', { - providedIn: 'root', - factory: () => inject(EffectScheduler), -}); - -/** - * A scheduler which manages the execution of effects. - */ -export abstract class EffectScheduler { - /** - * Schedule the given effect to be executed at a later time. - * - * It is an error to attempt to execute any effects synchronously during a scheduling operation. - */ - abstract scheduleEffect(e: SchedulableEffect): void; - - /** - * Run any scheduled effects. - */ - abstract flush(): void; - - /** @nocollapse */ - static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({ - token: EffectScheduler, - providedIn: 'root', - factory: () => new ZoneAwareEffectScheduler(), - }); -} - -/** - * A wrapper around `ZoneAwareQueueingScheduler` that schedules flushing via the microtask queue - * when. - */ -export class ZoneAwareEffectScheduler implements EffectScheduler { - private queuedEffectCount = 0; - private queues = new Map>(); - private readonly pendingTasks = inject(PendingTasks); - private taskId: number | null = null; - - scheduleEffect(handle: SchedulableEffect): void { - this.enqueue(handle); - - if (this.taskId === null) { - const taskId = (this.taskId = this.pendingTasks.add()); - queueMicrotask(() => { - this.flush(); - this.pendingTasks.remove(taskId); - this.taskId = null; - }); - } - } - - private enqueue(handle: SchedulableEffect): void { - const zone = handle.creationZone as Zone | null; - if (!this.queues.has(zone)) { - this.queues.set(zone, new Set()); - } - - const queue = this.queues.get(zone)!; - if (queue.has(handle)) { - return; - } - this.queuedEffectCount++; - queue.add(handle); - } - - /** - * Run all scheduled effects. - * - * Execution order of effects within the same zone is guaranteed to be FIFO, but there is no - * ordering guarantee between effects scheduled in different zones. - */ - flush(): void { - while (this.queuedEffectCount > 0) { - for (const [zone, queue] of this.queues) { - // `zone` here must be defined. - if (zone === null) { - this.flushQueue(queue); - } else { - zone.run(() => this.flushQueue(queue)); - } - } - } - } - - private flushQueue(queue: Set): void { - for (const handle of queue) { - queue.delete(handle); - this.queuedEffectCount--; - - // TODO: what happens if this throws an error? - handle.run(); - } - } -} - -/** - * Core reactive node for an Angular effect. - * - * `EffectHandle` combines the reactive graph's `Watch` base node for effects with the framework's - * scheduling abstraction (`EffectScheduler`) as well as automatic cleanup via `DestroyRef` if - * available/requested. - */ -class EffectHandle implements EffectRef, SchedulableEffect { - unregisterOnDestroy: (() => void) | undefined; - readonly watcher: Watch; - - constructor( - private scheduler: EffectScheduler, - private effectFn: (onCleanup: EffectCleanupRegisterFn) => void, - public creationZone: Zone | null, - destroyRef: DestroyRef | null, - private injector: Injector, - allowSignalWrites: boolean, - ) { - this.watcher = createWatch( - (onCleanup) => this.runEffect(onCleanup), - () => this.schedule(), - allowSignalWrites, - ); - this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy()); - } - - private runEffect(onCleanup: WatchCleanupRegisterFn): void { - try { - this.effectFn(onCleanup); - } catch (err) { - // Inject the `ErrorHandler` here in order to avoid circular DI error - // if the effect is used inside of a custom `ErrorHandler`. - const errorHandler = this.injector.get(ErrorHandler, null, {optional: true}); - errorHandler?.handleError(err); - } - } - - run(): void { - this.watcher.run(); - } - - private schedule(): void { - this.scheduler.scheduleEffect(this); - } - - destroy(): void { - this.watcher.destroy(); - this.unregisterOnDestroy?.(); - - // Note: if the effect is currently scheduled, it's not un-scheduled, and so the scheduler will - // retain a reference to it. Attempting to execute it will be a no-op. - } +export function setUseMicrotaskEffectsByDefault(value: boolean): boolean { + const prev = useMicrotaskEffectsByDefault; + useMicrotaskEffectsByDefault = value; + return prev; } /** @@ -211,6 +60,18 @@ export interface EffectRef { destroy(): void; } +class EffectRefImpl implements EffectRef { + [SIGNAL]: EffectNode; + + constructor(node: EffectNode) { + this[SIGNAL] = node; + } + + destroy(): void { + this[SIGNAL].destroy(); + } +} + /** * Options passed to the `effect` function. * @@ -234,16 +95,48 @@ export interface CreateEffectOptions { manualCleanup?: boolean; /** - * Whether the `effect` should allow writing to signals. - * - * Using effects to synchronize data by writing to signals can lead to confusing and potentially - * incorrect behavior, and should be enabled only when necessary. + * Always create a root effect (which is scheduled as a microtask) regardless of whether `effect` + * is called within a component. + */ + forceRoot?: true; + + /** + * @deprecated no longer required, signal writes are allowed by default. */ allowSignalWrites?: boolean; } /** - * Create a global `Effect` for the given reactive function. + * An effect can, optionally, register a cleanup function. If registered, the cleanup is executed + * before the next effect run. The cleanup function makes it possible to "cancel" any work that the + * previous effect run might have started. + * + * @developerPreview + */ +export type EffectCleanupFn = () => void; + +/** + * A callback passed to the effect function that makes it possible to register cleanup logic. + * + * @developerPreview + */ +export type EffectCleanupRegisterFn = (cleanupFn: EffectCleanupFn) => void; + +/** + * Registers an "effect" that will be scheduled & executed whenever the signals that it reads + * changes. + * + * Angular has two different kinds of effect: component effects and root effects. Component effects + * are created when `effect()` is called from a component, directive, or within a service of a + * component/directive. Root effects are created when `effect()` is called from outside the + * component tree, such as in a root service, or when the `forceRoot` option is provided. + * + * The two effect types differ in their timing. Component effects run as a component lifecycle + * event during Angular's synchronization (change detection) process, and can safely read input + * signals or create/destroy views that depend on component state. Root effects run as microtasks + * and have no connection to the component tree or change detection. + * + * `effect()` must be run in injection context, unless the `injector` option is manually specified. * * @developerPreview */ @@ -251,6 +144,14 @@ export function effect( effectFn: (onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions, ): EffectRef { + if (useMicrotaskEffectsByDefault) { + if (ngDevMode && options?.forceRoot) { + throw new Error(`Cannot use 'forceRoot' option with microtask effects on`); + } + + return microtaskEffect(effectFn, options); + } + performanceMarkFeature('NgSignals'); ngDevMode && assertNotInReactiveContext( @@ -260,36 +161,181 @@ export function effect( ); !options?.injector && assertInInjectionContext(effect); - const injector = options?.injector ?? inject(Injector); - const destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef) : null; - const handle = new EffectHandle( - injector.get(APP_EFFECT_SCHEDULER), - effectFn, - typeof Zone === 'undefined' ? null : Zone.current, - destroyRef, - injector, - options?.allowSignalWrites ?? false, - ); - - // Effects need to be marked dirty manually to trigger their initial run. The timing of this - // marking matters, because the effects may read signals that track component inputs, which are - // only available after those components have had their first update pass. - // - // We inject `ChangeDetectorRef` optionally, to determine whether this effect is being created in - // the context of a component or not. If it is, then we check whether the component has already - // run its update pass, and defer the effect's initial scheduling until the update pass if it - // hasn't already run. - const cdr = injector.get(ChangeDetectorRef, null, {optional: true}) as ViewRef | null; - if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) { - // This effect is either not running in a view injector, or the view has already - // undergone its first change detection pass, which is necessary for any required inputs to be - // set. - handle.watcher.notify(); - } else { - // Delay the initialization of the effect until the view is fully initialized. - (cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify); + if (ngDevMode && options?.allowSignalWrites !== undefined) { + console.warn( + `The 'allowSignalWrites' flag is deprecated & longer required for effect() (writes are allowed by default)`, + ); } - return handle; + const injector = options?.injector ?? inject(Injector); + let destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef) : null; + + let node: EffectNode; + + const viewContext = injector.get(ViewContext, null, {optional: true}); + if (viewContext !== null && !options?.forceRoot) { + // This effect was created in the context of a view, and will be associated with the view. + node = createViewEffect(viewContext.view, effectFn); + if (destroyRef instanceof NodeInjectorDestroyRef && destroyRef._lView === viewContext.view) { + // The effect is being created in the same view as the `DestroyRef` references, so it will be + // automatically destroyed without the need for an explicit `DestroyRef` registration. + destroyRef = null; + } + } else { + // This effect was created outside the context of a view, and will be scheduled independently. + node = createRootEffect( + effectFn, + injector.get(EffectScheduler), + injector.get(ChangeDetectionScheduler), + ); + } + node.injector = injector; + + if (destroyRef !== null) { + // If we need to register for cleanup, do that here. + node.onDestroyFn = destroyRef.onDestroy(() => node.destroy()); + } + + return new EffectRefImpl(node); +} + +export interface EffectNode extends ReactiveNode, SchedulableEffect { + hasRun: boolean; + cleanupFns: EffectCleanupFn[] | undefined; + injector: Injector; + + onDestroyFn: () => void; + fn: (cleanupFn: EffectCleanupRegisterFn) => void; + run(): void; + destroy(): void; + maybeCleanup(): void; +} + +export interface ViewEffectNode extends EffectNode { + view: LView; +} + +export interface RootEffectNode extends EffectNode { + scheduler: EffectScheduler; + notifier: ChangeDetectionScheduler; +} + +/** + * Not public API, which guarantees `EffectScheduler` only ever comes from the application root + * injector. + */ +export const APP_EFFECT_SCHEDULER = new InjectionToken('', { + providedIn: 'root', + factory: () => inject(EffectScheduler), +}); + +export const BASE_EFFECT_NODE: Omit = + /* @__PURE__ */ (() => ({ + ...REACTIVE_NODE, + consumerIsAlwaysLive: true, + consumerAllowSignalWrites: true, + dirty: true, + hasRun: false, + cleanupFns: undefined, + zone: null, + onDestroyFn: noop, + run(this: EffectNode): void { + this.dirty = false; + + if (ngDevMode && isInNotificationPhase()) { + throw new Error(`Schedulers cannot synchronously execute watches while scheduling.`); + } + + if (this.hasRun && !consumerPollProducersForChange(this)) { + return; + } + this.hasRun = true; + + const registerCleanupFn: EffectCleanupRegisterFn = (cleanupFn) => + (this.cleanupFns ??= []).push(cleanupFn); + + const prevNode = consumerBeforeComputation(this); + + // We clear `setIsRefreshingViews` so that `markForCheck()` within the body of an effect will + // cause CD to reach the component in question. + const prevRefreshingViews = setIsRefreshingViews(false); + try { + this.maybeCleanup(); + this.fn(registerCleanupFn); + } catch (err: unknown) { + // We inject the error handler lazily, to prevent circular dependencies when an effect is + // created inside of an ErrorHandler. + this.injector.get(ErrorHandler, null, {optional: true})?.handleError(err); + } finally { + setIsRefreshingViews(prevRefreshingViews); + consumerAfterComputation(this, prevNode); + } + }, + + maybeCleanup(this: EffectNode): void { + while (this.cleanupFns?.length) { + this.cleanupFns.pop()!(); + } + }, + }))(); + +export const ROOT_EFFECT_NODE: Omit = + /* @__PURE__ */ (() => ({ + ...BASE_EFFECT_NODE, + consumerMarkedDirty(this: RootEffectNode) { + this.scheduler.schedule(this); + this.notifier.notify(NotificationSource.RootEffect); + }, + destroy(this: RootEffectNode) { + consumerDestroy(this); + this.onDestroyFn(); + this.maybeCleanup(); + }, + }))(); + +export const VIEW_EFFECT_NODE: Omit = + /* @__PURE__ */ (() => ({ + ...BASE_EFFECT_NODE, + consumerMarkedDirty(this: ViewEffectNode): void { + this.view[FLAGS] |= LViewFlags.HasChildViewsToRefresh; + markAncestorsForTraversal(this.view); + }, + destroy(this: ViewEffectNode): void { + consumerDestroy(this); + this.onDestroyFn(); + this.maybeCleanup(); + this.view[EFFECTS]?.delete(this); + }, + }))(); + +export function createViewEffect( + view: LView, + fn: (onCleanup: EffectCleanupRegisterFn) => void, +): ViewEffectNode { + const node = Object.create(VIEW_EFFECT_NODE) as ViewEffectNode; + node.view = view; + node.zone = typeof Zone !== 'undefined' ? Zone.current : null; + node.fn = fn; + + view[EFFECTS] ??= new Set(); + view[EFFECTS].add(node); + + node.consumerMarkedDirty(node); + return node; +} + +export function createRootEffect( + fn: (onCleanup: EffectCleanupRegisterFn) => void, + scheduler: EffectScheduler, + notifier: ChangeDetectionScheduler, +): RootEffectNode { + const node = Object.create(ROOT_EFFECT_NODE) as RootEffectNode; + node.fn = fn; + node.scheduler = scheduler; + node.notifier = notifier; + node.zone = typeof Zone !== 'undefined' ? Zone.current : null; + node.scheduler.schedule(node); + node.notifier.notify(NotificationSource.RootEffect); + return node; } diff --git a/packages/core/src/render3/reactivity/microtask_effect.ts b/packages/core/src/render3/reactivity/microtask_effect.ts new file mode 100644 index 00000000000..4fb249dde4e --- /dev/null +++ b/packages/core/src/render3/reactivity/microtask_effect.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {createWatch, Watch, WatchCleanupRegisterFn} from '@angular/core/primitives/signals'; + +import {ChangeDetectorRef} from '../../change_detection/change_detector_ref'; +import {Injector} from '../../di/injector'; +import {inject} from '../../di/injector_compatibility'; +import {ɵɵdefineInjectable} from '../../di/interface/defs'; +import {ErrorHandler} from '../../error_handler'; +import type {ViewRef} from '../view_ref'; +import {DestroyRef} from '../../linker/destroy_ref'; +import {FLAGS, LViewFlags, EFFECTS_TO_SCHEDULE} from '../interfaces/view'; + +import type {CreateEffectOptions, EffectCleanupRegisterFn, EffectRef} from './effect'; +import {type SchedulableEffect, ZoneAwareEffectScheduler} from './root_effect_scheduler'; +import {performanceMarkFeature} from '../../util/performance'; +import {assertNotInReactiveContext} from './asserts'; +import {assertInInjectionContext} from '../../di'; + +export class MicrotaskEffectScheduler extends ZoneAwareEffectScheduler { + override schedule(effect: SchedulableEffect): void { + // Check whether there are any pending effects _before_ queueing in the base class. + const needsScheduling = this.taskId === null; + super.schedule(effect); + if (needsScheduling) { + queueMicrotask(() => this.flush()); + } + } + + /** @nocollapse */ + static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({ + token: MicrotaskEffectScheduler, + providedIn: 'root', + factory: () => new MicrotaskEffectScheduler(), + }); +} + +/** + * Core reactive node for an Angular effect. + * + * `EffectHandle` combines the reactive graph's `Watch` base node for effects with the framework's + * scheduling abstraction (`MicrotaskEffectScheduler`) as well as automatic cleanup via `DestroyRef` + * if available/requested. + */ +class EffectHandle implements EffectRef, SchedulableEffect { + unregisterOnDestroy: (() => void) | undefined; + readonly watcher: Watch; + + constructor( + private scheduler: MicrotaskEffectScheduler, + private effectFn: (onCleanup: EffectCleanupRegisterFn) => void, + public zone: Zone | null, + destroyRef: DestroyRef | null, + private injector: Injector, + allowSignalWrites: boolean, + ) { + this.watcher = createWatch( + (onCleanup) => this.runEffect(onCleanup), + () => this.schedule(), + allowSignalWrites, + ); + this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy()); + } + + private runEffect(onCleanup: WatchCleanupRegisterFn): void { + try { + this.effectFn(onCleanup); + } catch (err) { + // Inject the `ErrorHandler` here in order to avoid circular DI error + // if the effect is used inside of a custom `ErrorHandler`. + const errorHandler = this.injector.get(ErrorHandler, null, {optional: true}); + errorHandler?.handleError(err); + } + } + + run(): void { + this.watcher.run(); + } + + private schedule(): void { + this.scheduler.schedule(this); + } + + destroy(): void { + this.watcher.destroy(); + this.unregisterOnDestroy?.(); + + // Note: if the effect is currently scheduled, it's not un-scheduled, and so the scheduler will + // retain a reference to it. Attempting to execute it will be a no-op. + } +} + +// Just used for the name for the debug error below. +function effect() {} + +/** + * Create a global `Effect` for the given reactive function. + */ +export function microtaskEffect( + effectFn: (onCleanup: EffectCleanupRegisterFn) => void, + options?: CreateEffectOptions, +): EffectRef { + performanceMarkFeature('NgSignals'); + ngDevMode && + assertNotInReactiveContext( + effect, + 'Call `effect` outside of a reactive context. For example, schedule the ' + + 'effect inside the component constructor.', + ); + + !options?.injector && assertInInjectionContext(effect); + + const injector = options?.injector ?? inject(Injector); + const destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef) : null; + + const handle = new EffectHandle( + injector.get(MicrotaskEffectScheduler), + effectFn, + typeof Zone === 'undefined' ? null : Zone.current, + destroyRef, + injector, + options?.allowSignalWrites ?? false, + ); + + // Effects need to be marked dirty manually to trigger their initial run. The timing of this + // marking matters, because the effects may read signals that track component inputs, which are + // only available after those components have had their first update pass. + // + // We inject `ChangeDetectorRef` optionally, to determine whether this effect is being created in + // the context of a component or not. If it is, then we check whether the component has already + // run its update pass, and defer the effect's initial scheduling until the update pass if it + // hasn't already run. + const cdr = injector.get(ChangeDetectorRef, null, {optional: true}) as ViewRef | null; + if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) { + // This effect is either not running in a view injector, or the view has already + // undergone its first change detection pass, which is necessary for any required inputs to be + // set. + handle.watcher.notify(); + } else { + // Delay the initialization of the effect until the view is fully initialized. + (cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify); + } + + return handle; +} diff --git a/packages/core/src/render3/reactivity/patch.ts b/packages/core/src/render3/reactivity/patch.ts new file mode 100644 index 00000000000..4d0c6ef4a88 --- /dev/null +++ b/packages/core/src/render3/reactivity/patch.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Controls whether effects use the legacy `microtaskEffect` by default. + */ +export const USE_MICROTASK_EFFECT_BY_DEFAULT = true; diff --git a/packages/core/src/render3/reactivity/root_effect_scheduler.ts b/packages/core/src/render3/reactivity/root_effect_scheduler.ts new file mode 100644 index 00000000000..54147bb50a1 --- /dev/null +++ b/packages/core/src/render3/reactivity/root_effect_scheduler.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ɵɵdefineInjectable} from '../../di/interface/defs'; +import {PendingTasks} from '../../pending_tasks'; +import {inject} from '../../di/injector_compatibility'; + +/** + * Abstraction that encompasses any kind of effect that can be scheduled. + */ +export interface SchedulableEffect { + run(): void; + zone: { + run(fn: () => T): T; + } | null; +} + +/** + * A scheduler which manages the execution of effects. + */ +export abstract class EffectScheduler { + /** + * Schedule the given effect to be executed at a later time. + * + * It is an error to attempt to execute any effects synchronously during a scheduling operation. + */ + abstract schedule(e: SchedulableEffect): void; + + /** + * Run any scheduled effects. + */ + abstract flush(): void; + + /** @nocollapse */ + static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({ + token: EffectScheduler, + providedIn: 'root', + factory: () => new ZoneAwareEffectScheduler(), + }); +} + +/** + * A wrapper around `ZoneAwareQueueingScheduler` that schedules flushing via the microtask queue + * when. + */ +export class ZoneAwareEffectScheduler implements EffectScheduler { + private queuedEffectCount = 0; + private queues = new Map>(); + private readonly pendingTasks = inject(PendingTasks); + protected taskId: number | null = null; + + schedule(handle: SchedulableEffect): void { + this.enqueue(handle); + + if (this.taskId === null) { + this.taskId = this.pendingTasks.add(); + } + } + + private enqueue(handle: SchedulableEffect): void { + const zone = handle.zone as Zone | null; + if (!this.queues.has(zone)) { + this.queues.set(zone, new Set()); + } + + const queue = this.queues.get(zone)!; + if (queue.has(handle)) { + return; + } + this.queuedEffectCount++; + queue.add(handle); + } + + /** + * Run all scheduled effects. + * + * Execution order of effects within the same zone is guaranteed to be FIFO, but there is no + * ordering guarantee between effects scheduled in different zones. + */ + flush(): void { + while (this.queuedEffectCount > 0) { + for (const [zone, queue] of this.queues) { + // `zone` here must be defined. + if (zone === null) { + this.flushQueue(queue); + } else { + zone.run(() => this.flushQueue(queue)); + } + } + } + + if (this.taskId !== null) { + this.pendingTasks.remove(this.taskId); + this.taskId = null; + } + } + + private flushQueue(queue: Set): void { + for (const handle of queue) { + queue.delete(handle); + this.queuedEffectCount--; + + // TODO: what happens if this throws an error? + handle.run(); + } + } +} diff --git a/packages/core/src/render3/reactivity/view_effect_runner.ts b/packages/core/src/render3/reactivity/view_effect_runner.ts new file mode 100644 index 00000000000..e51a9c926a4 --- /dev/null +++ b/packages/core/src/render3/reactivity/view_effect_runner.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {EFFECTS, FLAGS, type LView, LViewFlags} from '../interfaces/view'; + +export function runEffectsInView(view: LView): void { + if (view[EFFECTS] === null) { + return; + } + + // Since effects can make other effects dirty, we flush them in a loop until there are no more to + // flush. + let tryFlushEffects = true; + + while (tryFlushEffects) { + let foundDirtyEffect = false; + for (const effect of view[EFFECTS]) { + if (!effect.dirty) { + continue; + } + foundDirtyEffect = true; + + // `runEffectsInView` is called during change detection, and therefore runs + // in the Angular zone if it's available. + if (effect.zone === null || Zone.current === effect.zone) { + effect.run(); + } else { + effect.zone.run(() => effect.run()); + } + } + + // Check if we need to continue flushing. If we didn't find any dirty effects, then there's + // no need to loop back. Otherwise, check the view to see if it was marked for traversal + // again. If so, there's a chance that one of the effects we ran caused another effect to + // become dirty. + tryFlushEffects = foundDirtyEffect && !!(view[FLAGS] & LViewFlags.HasChildViewsToRefresh); + } +} diff --git a/packages/core/src/render3/state.ts b/packages/core/src/render3/state.ts index e2d89bb975b..46faa20e884 100644 --- a/packages/core/src/render3/state.ts +++ b/packages/core/src/render3/state.ts @@ -434,8 +434,10 @@ export function isRefreshingViews(): boolean { return _isRefreshingViews; } -export function setIsRefreshingViews(mode: boolean): void { +export function setIsRefreshingViews(mode: boolean): boolean { + const prev = _isRefreshingViews; _isRefreshingViews = mode; + return prev; } // top level variables should not be exported for performance reasons (PERF_NOTES.md) diff --git a/packages/core/src/render3/view_context.ts b/packages/core/src/render3/view_context.ts new file mode 100644 index 00000000000..af9f0da56aa --- /dev/null +++ b/packages/core/src/render3/view_context.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import type {TNode} from './interfaces/node'; +import type {LView} from './interfaces/view'; +import {getCurrentTNode, getLView} from './state'; + +export class ViewContext { + constructor( + readonly view: LView, + readonly node: TNode, + ) {} + + /** + * @internal + */ + static __NG_ELEMENT_ID__ = injectViewContext; +} + +export function injectViewContext(): ViewContext { + return new ViewContext(getLView()!, getCurrentTNode()!); +} diff --git a/packages/core/test/acceptance/after_render_hook_spec.ts b/packages/core/test/acceptance/after_render_hook_spec.ts index 6bbf9c3909f..b484c0388fc 100644 --- a/packages/core/test/acceptance/after_render_hook_spec.ts +++ b/packages/core/test/acceptance/after_render_hook_spec.ts @@ -33,6 +33,7 @@ import {TestBed} from '@angular/core/testing'; import {firstValueFrom} from 'rxjs'; import {filter} from 'rxjs/operators'; import {EnvironmentInjector, Injectable} from '../../src/di'; +import {setUseMicrotaskEffectsByDefault} from '@angular/core/src/render3/reactivity/effect'; function createAndAttachComponent(component: Type) { const componentRef = createComponent(component, { @@ -43,6 +44,12 @@ function createAndAttachComponent(component: Type) { } describe('after render hooks', () => { + let prev: boolean; + beforeEach(() => { + prev = setUseMicrotaskEffectsByDefault(false); + }); + afterEach(() => setUseMicrotaskEffectsByDefault(prev)); + describe('browser', () => { const COMMON_PROVIDERS = [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}]; const COMMON_CONFIGURATION = { diff --git a/packages/core/test/acceptance/authoring/output_function_spec.ts b/packages/core/test/acceptance/authoring/output_function_spec.ts index 41162ba86b6..d660d5568ba 100644 --- a/packages/core/test/acceptance/authoring/output_function_spec.ts +++ b/packages/core/test/acceptance/authoring/output_function_spec.ts @@ -16,10 +16,17 @@ import { signal, } from '@angular/core'; import {outputFromObservable} from '@angular/core/rxjs-interop'; +import {setUseMicrotaskEffectsByDefault} from '@angular/core/src/render3/reactivity/effect'; import {TestBed} from '@angular/core/testing'; import {BehaviorSubject, Observable, share, Subject} from 'rxjs'; describe('output() function', () => { + let prev: boolean; + beforeEach(() => { + prev = setUseMicrotaskEffectsByDefault(false); + }); + afterEach(() => setUseMicrotaskEffectsByDefault(prev)); + it('should support emitting values', () => { @Directive({ selector: '[dir]', diff --git a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts index bc984caa269..c30d7352260 100644 --- a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts +++ b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts @@ -16,9 +16,16 @@ import { Output, ViewChild, } from '@angular/core'; +import {setUseMicrotaskEffectsByDefault} from '@angular/core/src/render3/reactivity/effect'; import {TestBed} from '@angular/core/testing'; describe('signal inputs', () => { + let prev: boolean; + beforeEach(() => { + prev = setUseMicrotaskEffectsByDefault(false); + }); + afterEach(() => setUseMicrotaskEffectsByDefault(prev)); + beforeEach(() => TestBed.configureTestingModule({ errorOnUnknownProperties: true, diff --git a/packages/core/test/authoring/input_signal_spec.ts b/packages/core/test/authoring/input_signal_spec.ts index cf24720f4d7..765b67b8eba 100644 --- a/packages/core/test/authoring/input_signal_spec.ts +++ b/packages/core/test/authoring/input_signal_spec.ts @@ -8,9 +8,16 @@ import {Component, computed, effect, input} from '@angular/core'; import {SIGNAL} from '@angular/core/primitives/signals'; +import {setUseMicrotaskEffectsByDefault} from '@angular/core/src/render3/reactivity/effect'; import {TestBed} from '@angular/core/testing'; describe('input signal', () => { + let prev: boolean; + beforeEach(() => { + prev = setUseMicrotaskEffectsByDefault(false); + }); + afterEach(() => setUseMicrotaskEffectsByDefault(prev)); + it('should properly notify live consumers (effect)', () => { @Component({template: ''}) class TestCmp { diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 62f7175c12d..ad38b5fb37b 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -218,6 +218,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementInstructionMap" }, @@ -566,6 +569,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1388,6 +1394,9 @@ { "name": "roundOffset" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index 5c25cf8943c..169346cb79e 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -242,6 +242,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementInstructionMap" }, @@ -617,6 +620,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1463,6 +1469,9 @@ { "name": "roundOffset" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 651ab98db47..63e6cf5160d 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -152,6 +152,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -464,6 +467,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1172,6 +1178,9 @@ { "name": "retrieveHydrationInfo" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 879cc8d5148..b35a7baa1e5 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -182,6 +182,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -512,6 +515,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1721,6 +1727,9 @@ { "name": "init_metadata_attr" }, + { + "name": "init_microtask_effect" + }, { "name": "init_misc_utils" }, @@ -1826,6 +1835,9 @@ { "name": "init_partial" }, + { + "name": "init_patch" + }, { "name": "init_pending_tasks" }, @@ -1937,6 +1949,9 @@ { "name": "init_restriction" }, + { + "name": "init_root_effect_scheduler" + }, { "name": "init_sanitization" }, @@ -2096,6 +2111,12 @@ { "name": "init_view_container_ref" }, + { + "name": "init_view_context" + }, + { + "name": "init_view_effect_runner" + }, { "name": "init_view_engine_compatibility_prebound" }, @@ -2417,6 +2438,9 @@ { "name": "retrieveHydrationInfo" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 31f9f827ea9..1ca75f5337c 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -212,6 +212,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -653,6 +656,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1754,6 +1760,9 @@ { "name": "retrieveHydrationInfo" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index e5b326c0482..8c16fa3c311 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -215,6 +215,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -638,6 +641,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1733,6 +1739,9 @@ { "name": "retrieveHydrationInfo" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 047f7f29c28..7a777ab62ae 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -101,6 +101,9 @@ { "name": "ENVIRONMENT_INITIALIZER" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -350,6 +353,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -932,6 +938,9 @@ { "name": "retrieveHydrationInfo" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 34ae096e05c..6e7713f18bd 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -152,6 +152,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -518,6 +521,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1295,6 +1301,9 @@ { "name": "retrieveHydrationInfoImpl" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index b6d39ea58a7..66c82964b21 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -218,6 +218,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -794,6 +797,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1994,6 +2000,9 @@ { "name": "routes" }, + { + "name": "runEffectsInView" + }, { "name": "runInInjectionContext" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index e2bd4219bf8..640da0f65df 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -134,6 +134,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -419,6 +422,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1034,6 +1040,9 @@ { "name": "retrieveHydrationInfo" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 43f70e0dcfa..32275469ba7 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -155,6 +155,9 @@ { "name": "EVENT_MANAGER_PLUGINS" }, + { + "name": "EffectScheduler" + }, { "name": "ElementRef" }, @@ -536,6 +539,9 @@ { "name": "ZONELESS_SCHEDULER_DISABLED" }, + { + "name": "ZoneAwareEffectScheduler" + }, { "name": "ZoneStablePendingTask" }, @@ -1394,6 +1400,9 @@ { "name": "retrieveHydrationInfo" }, + { + "name": "runEffectsInView" + }, { "name": "saveNameToExportMap" }, diff --git a/packages/core/test/render3/di_spec.ts b/packages/core/test/render3/di_spec.ts index da2d4abfd45..326ebe50503 100644 --- a/packages/core/test/render3/di_spec.ts +++ b/packages/core/test/render3/di_spec.ts @@ -156,7 +156,6 @@ describe('di', () => { { rendererFactory: {} as any, sanitizer: null, - inlineEffectRunner: null, changeDetectionScheduler: null, }, {} as any, diff --git a/packages/core/test/render3/instructions/shared_spec.ts b/packages/core/test/render3/instructions/shared_spec.ts index 51e0e5592df..29d8f5fea91 100644 --- a/packages/core/test/render3/instructions/shared_spec.ts +++ b/packages/core/test/render3/instructions/shared_spec.ts @@ -72,7 +72,6 @@ export function enterViewWithOneDiv() { { rendererFactory, sanitizer: null, - inlineEffectRunner: null, changeDetectionScheduler: null, }, renderer, diff --git a/packages/core/test/render3/microtask_effect_spec.ts b/packages/core/test/render3/microtask_effect_spec.ts new file mode 100644 index 00000000000..6fdf2dff30e --- /dev/null +++ b/packages/core/test/render3/microtask_effect_spec.ts @@ -0,0 +1,788 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AsyncPipe} from '@angular/common'; +import { + AfterViewInit, + ApplicationRef, + Component, + computed, + ContentChildren, + createComponent, + createEnvironmentInjector, + destroyPlatform, + ɵmicrotaskEffect as microtaskEffect, + EnvironmentInjector, + ErrorHandler, + inject, + Injectable, + Injector, + Input, + NgZone, + OnChanges, + QueryList, + signal, + SimpleChanges, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import {toObservable} from '@angular/core/rxjs-interop'; +import {TestBed} from '@angular/core/testing'; +import {bootstrapApplication} from '@angular/platform-browser'; +import {withBody} from '@angular/private/testing'; +import {filter, firstValueFrom, map} from 'rxjs'; + +describe('microtask effects', () => { + beforeEach(destroyPlatform); + afterEach(destroyPlatform); + + it( + 'should run effects in the zone in which they get created', + withBody('', async () => { + const log: string[] = []; + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp { + constructor(ngZone: NgZone) { + microtaskEffect(() => { + log.push(Zone.current.name); + }); + + ngZone.runOutsideAngular(() => { + microtaskEffect(() => { + log.push(Zone.current.name); + }); + }); + } + } + + await bootstrapApplication(Cmp); + + expect(log).not.toEqual(['angular', 'angular']); + }), + ); + + it('should contribute to application stableness when an effect is pending', async () => { + const someSignal = signal('initial'); + + @Component({ + standalone: true, + template: '', + }) + class App { + unused = microtaskEffect(() => someSignal()); + } + + const appRef = TestBed.inject(ApplicationRef); + const componentRef = createComponent(App, { + environmentInjector: TestBed.inject(EnvironmentInjector), + }); + // Effect is not scheduled until change detection runs for the component + await expectAsync(firstValueFrom(appRef.isStable)).toBeResolvedTo(true); + + componentRef.changeDetectorRef.detectChanges(); + const stableEmits: boolean[] = []; + const p = firstValueFrom( + appRef.isStable.pipe( + map((stable) => { + stableEmits.push(stable); + return stableEmits; + }), + filter((emits) => emits.length === 2), + ), + ); + await expectAsync(p).toBeResolvedTo([false, true]); + componentRef.destroy(); + }); + + it('should propagate errors to the ErrorHandler', () => { + let run = false; + + let lastError: any = null; + class FakeErrorHandler extends ErrorHandler { + override handleError(error: any): void { + lastError = error; + } + } + + const injector = createEnvironmentInjector( + [{provide: ErrorHandler, useFactory: () => new FakeErrorHandler()}], + TestBed.inject(EnvironmentInjector), + ); + microtaskEffect( + () => { + run = true; + throw new Error('fail!'); + }, + {injector}, + ); + expect(() => TestBed.flushEffects()).not.toThrow(); + expect(run).toBeTrue(); + expect(lastError.message).toBe('fail!'); + }); + + it('should be usable inside an ErrorHandler', async () => { + const shouldError = signal(false); + let lastError: any = null; + + class FakeErrorHandler extends ErrorHandler { + constructor() { + super(); + microtaskEffect(() => { + if (shouldError()) { + throw new Error('fail!'); + } + }); + } + + override handleError(error: any): void { + lastError = error; + } + } + + @Component({ + standalone: true, + template: '', + providers: [{provide: ErrorHandler, useClass: FakeErrorHandler}], + }) + class App { + errorHandler = inject(ErrorHandler); + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.errorHandler).toBeInstanceOf(FakeErrorHandler); + expect(lastError).toBe(null); + + shouldError.set(true); + fixture.detectChanges(); + + expect(lastError?.message).toBe('fail!'); + }); + + it('should run effect cleanup function on destroy', async () => { + let counterLog: number[] = []; + let cleanupCount = 0; + + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp { + counter = signal(0); + effectRef = microtaskEffect((onCleanup) => { + counterLog.push(this.counter()); + onCleanup(() => { + cleanupCount++; + }); + }); + } + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + await fixture.whenStable(); + expect(counterLog).toEqual([0]); + // initially an effect runs but the default cleanup function is noop + expect(cleanupCount).toBe(0); + + fixture.componentInstance.counter.set(5); + fixture.detectChanges(); + await fixture.whenStable(); + expect(counterLog).toEqual([0, 5]); + expect(cleanupCount).toBe(1); + + fixture.destroy(); + expect(counterLog).toEqual([0, 5]); + expect(cleanupCount).toBe(2); + }); + + it('should run effects created in ngAfterViewInit', () => { + let didRun = false; + + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp implements AfterViewInit { + injector = inject(Injector); + + ngAfterViewInit(): void { + microtaskEffect( + () => { + didRun = true; + }, + {injector: this.injector}, + ); + } + } + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + + expect(didRun).toBeTrue(); + }); + + it( + 'should disallow writing to signals within effects by default', + withBody('', async () => { + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp { + counter = signal(0); + constructor() { + microtaskEffect(() => { + expect(() => this.counter.set(1)).toThrow(); + }); + } + } + + await (await bootstrapApplication(Cmp)).whenStable(); + }), + ); + + it('should allow writing to signals within effects when option set', () => { + const counter = signal(0); + + microtaskEffect(() => counter.set(1), { + allowSignalWrites: true, + injector: TestBed.inject(Injector), + }); + TestBed.flushEffects(); + expect(counter()).toBe(1); + }); + + it('should allow writing to signals in ngOnChanges', () => { + @Component({ + selector: 'with-input', + standalone: true, + template: '{{inSignal()}}', + }) + class WithInput implements OnChanges { + inSignal = signal(undefined); + @Input() in: string | undefined; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['in']) { + this.inSignal.set(changes['in'].currentValue); + } + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithInput], + template: `|`, + }) + class Cmp {} + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('A|B'); + }); + + it('should allow writing to signals in a constructor', () => { + @Component({ + selector: 'with-constructor', + standalone: true, + template: '{{state()}}', + }) + class WithConstructor { + state = signal('property initializer'); + + constructor() { + this.state.set('constructor'); + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithConstructor], + template: ``, + }) + class Cmp {} + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('constructor'); + }); + + it('should allow writing to signals in input setters', () => { + @Component({ + selector: 'with-input-setter', + standalone: true, + template: '{{state()}}', + }) + class WithInputSetter { + state = signal('property initializer'); + + @Input() + set testInput(newValue: string) { + this.state.set(newValue); + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithInputSetter], + template: ` + | + `, + }) + class Cmp {} + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('binding|static'); + }); + + it('should allow writing to signals in query result setters', () => { + @Component({ + selector: 'with-query', + standalone: true, + template: '{{items().length}}', + }) + class WithQuery { + items = signal([]); + + @ContentChildren('item') + set itemsQuery(result: QueryList) { + this.items.set(result.toArray()); + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithQuery], + template: `
`, + }) + class Cmp {} + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1'); + }); + + it('should not execute query setters in the reactive context', () => { + const state = signal('initial'); + + @Component({ + selector: 'with-query-setter', + standalone: true, + template: '
', + }) + class WithQuerySetter { + el: unknown; + @ViewChild('el', {static: true}) + set elQuery(result: unknown) { + // read a signal in a setter - I want to verify that framework executes this code outside of + // the reactive context + state(); + this.el = result; + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + template: ``, + }) + class Cmp { + noOfCmpCreated = 0; + constructor(environmentInjector: EnvironmentInjector) { + // A slightly artificial setup where a component instance is created using imperative APIs. + // We don't have control over the timing / reactive context of such API calls so need to + // code defensively in the framework. + + // Here we want to specifically verify that an effect is _not_ re-run if a signal read + // happens in a query setter of a dynamically created component. + microtaskEffect(() => { + createComponent(WithQuerySetter, {environmentInjector}); + this.noOfCmpCreated++; + }); + } + } + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + + expect(fixture.componentInstance.noOfCmpCreated).toBe(1); + + state.set('changed'); + fixture.detectChanges(); + + expect(fixture.componentInstance.noOfCmpCreated).toBe(1); + }); + + it('should allow toObservable subscription in template (with async pipe)', () => { + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [AsyncPipe], + template: '{{counter$ | async}}', + }) + class Cmp { + counter$ = toObservable(signal(0)); + } + + const fixture = TestBed.createComponent(Cmp); + expect(() => fixture.detectChanges(true)).not.toThrow(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('0'); + }); + + describe('effects created in components should first run after ngOnInit', () => { + it('when created during bootstrapping', () => { + let log: string[] = []; + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + }) + class TestCmp { + constructor() { + microtaskEffect(() => log.push('effect')); + } + + ngOnInit(): void { + log.push('init'); + } + } + + const fixture = TestBed.createComponent(TestCmp); + TestBed.flushEffects(); + expect(log).toEqual([]); + fixture.detectChanges(); + expect(log).toEqual(['init', 'effect']); + }); + + it('when created during change detection', () => { + let log: string[] = []; + + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + }) + class TestCmp { + ngOnInitRan = false; + constructor() { + microtaskEffect(() => log.push('effect')); + } + + ngOnInit(): void { + log.push('init'); + } + } + + @Component({ + standalone: true, + selector: 'driver-cmp', + imports: [TestCmp], + template: ` + @if (cond) { + + } + `, + }) + class DriverCmp { + cond = false; + } + + const fixture = TestBed.createComponent(DriverCmp); + fixture.detectChanges(); + expect(log).toEqual([]); + + // Toggle the @if, which should create and run the effect. + fixture.componentInstance.cond = true; + fixture.detectChanges(); + expect(log).toEqual(['init', 'effect']); + }); + + it('when created dynamically', () => { + let log: string[] = []; + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + }) + class TestCmp { + ngOnInitRan = false; + constructor() { + microtaskEffect(() => log.push('effect')); + } + + ngOnInit(): void { + log.push('init'); + } + } + + @Component({ + standalone: true, + selector: 'driver-cmp', + template: '', + }) + class DriverCmp { + vcr = inject(ViewContainerRef); + } + + const fixture = TestBed.createComponent(DriverCmp); + fixture.detectChanges(); + + const ref = fixture.componentInstance.vcr.createComponent(TestCmp); + + // Verify that simply creating the component didn't schedule the effect. + TestBed.flushEffects(); + expect(log).toEqual([]); + + // Running change detection should schedule and run the effect. + fixture.detectChanges(); + expect(log).toEqual(['init', 'effect']); + ref.destroy(); + }); + + it('when created in a service provided in a component', () => { + let log: string[] = []; + + @Injectable() + class EffectService { + constructor() { + microtaskEffect(() => log.push('effect')); + } + } + + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + providers: [EffectService], + }) + class TestCmp { + svc = inject(EffectService); + + ngOnInit(): void { + log.push('init'); + } + } + + const fixture = TestBed.createComponent(TestCmp); + TestBed.flushEffects(); + expect(log).toEqual([]); + fixture.detectChanges(); + expect(log).toEqual(['init', 'effect']); + }); + + it('if multiple effects are created', () => { + let log: string[] = []; + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + }) + class TestCmp { + constructor() { + microtaskEffect(() => log.push('effect a')); + microtaskEffect(() => log.push('effect b')); + microtaskEffect(() => log.push('effect c')); + } + + ngOnInit(): void { + log.push('init'); + } + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(log[0]).toBe('init'); + expect(log).toContain('effect a'); + expect(log).toContain('effect b'); + expect(log).toContain('effect c'); + }); + }); + + describe('should disallow creating an effect context', () => { + it('inside template effect', () => { + @Component({ + template: '{{someFn()}}', + }) + class Cmp { + someFn() { + microtaskEffect(() => {}); + } + } + + const fixture = TestBed.createComponent(Cmp); + expect(() => fixture.detectChanges(true)).toThrowError( + /effect\(\) cannot be called from within a reactive context./, + ); + }); + + it('inside computed', () => { + expect(() => { + computed(() => { + microtaskEffect(() => {}); + })(); + }).toThrowError(/effect\(\) cannot be called from within a reactive context./); + }); + + it('inside an effect', () => { + @Component({ + template: '', + }) + class Cmp { + constructor() { + microtaskEffect(() => { + this.someFnThatWillCreateAnEffect(); + }); + } + + someFnThatWillCreateAnEffect() { + microtaskEffect(() => {}); + } + } + + TestBed.configureTestingModule({ + providers: [ + { + provide: ErrorHandler, + useClass: class extends ErrorHandler { + override handleError(e: Error) { + throw e; + } + }, + }, + ], + }); + const fixture = TestBed.createComponent(Cmp); + + expect(() => fixture.detectChanges()).toThrowError( + /effect\(\) cannot be called from within a reactive context./, + ); + }); + }); +}); + +describe('microtask effects in TestBed', () => { + it('created in the constructor should run with detectChanges()', () => { + const log: string[] = []; + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp { + constructor() { + log.push('Ctor'); + + microtaskEffect(() => { + log.push('Effect'); + }); + } + + ngDoCheck() { + log.push('DoCheck'); + } + } + + TestBed.createComponent(Cmp).detectChanges(); + + expect(log).toEqual([ + // The component gets constructed, which creates the effect. Since the effect is created in a + // component, it doesn't get scheduled until the component is first change detected. + 'Ctor', + + // Next, the first change detection (update pass) happens. + 'DoCheck', + + // Then the effect runs. + 'Effect', + ]); + }); + + it('created in ngOnInit should run with detectChanges()', () => { + const log: string[] = []; + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp { + private injector = inject(Injector); + + constructor() { + log.push('Ctor'); + } + + ngOnInit() { + microtaskEffect( + () => { + log.push('Effect'); + }, + {injector: this.injector}, + ); + } + + ngDoCheck() { + log.push('DoCheck'); + } + } + + TestBed.createComponent(Cmp).detectChanges(); + + expect(log).toEqual([ + // The component gets constructed. + 'Ctor', + + // Next, the first change detection (update pass) happens, which creates the effect and + // schedules it for execution. + 'DoCheck', + + // Then the effect runs. + 'Effect', + ]); + }); + + it('will flush effects automatically when using autoDetectChanges', async () => { + const val = signal('initial'); + let observed = ''; + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp { + constructor() { + microtaskEffect(() => { + observed = val(); + }); + } + } + + const fixture = TestBed.createComponent(Cmp); + fixture.autoDetectChanges(); + + expect(observed).toBe('initial'); + val.set('new'); + expect(observed).toBe('initial'); + await fixture.whenStable(); + expect(observed).toBe('new'); + }); +}); diff --git a/packages/core/test/render3/reactive_safety_spec.ts b/packages/core/test/render3/reactive_safety_spec.ts index 80e929733ee..080539e42ba 100644 --- a/packages/core/test/render3/reactive_safety_spec.ts +++ b/packages/core/test/render3/reactive_safety_spec.ts @@ -26,6 +26,7 @@ import { } from '@angular/core'; import {getActiveConsumer} from '@angular/core/primitives/signals'; import {createInjector} from '@angular/core/src/di/create_injector'; +import {setUseMicrotaskEffectsByDefault} from '@angular/core/src/render3/reactivity/effect'; import {TestBed} from '@angular/core/testing'; /* @@ -34,6 +35,12 @@ import {TestBed} from '@angular/core/testing'; */ describe('reactive safety', () => { + let prev: boolean; + beforeEach(() => { + prev = setUseMicrotaskEffectsByDefault(false); + }); + afterEach(() => setUseMicrotaskEffectsByDefault(prev)); + describe('view creation', () => { it('should be safe to call ViewContainerRef.createEmbeddedView', () => { @Component({ diff --git a/packages/core/test/render3/reactivity_spec.ts b/packages/core/test/render3/reactivity_spec.ts index 11cc4027e3f..7b1344b57e2 100644 --- a/packages/core/test/render3/reactivity_spec.ts +++ b/packages/core/test/render3/reactivity_spec.ts @@ -10,12 +10,15 @@ import {AsyncPipe} from '@angular/common'; import { AfterViewInit, ApplicationRef, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, computed, ContentChildren, createComponent, createEnvironmentInjector, destroyPlatform, + Directive, effect, EnvironmentInjector, ErrorHandler, @@ -25,217 +28,148 @@ import { Input, NgZone, OnChanges, + provideExperimentalZonelessChangeDetection, QueryList, signal, SimpleChanges, + TemplateRef, ViewChild, ViewContainerRef, } from '@angular/core'; -import {toObservable} from '@angular/core/rxjs-interop'; +import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop'; +import {createInjector} from '@angular/core/src/di/create_injector'; +import {setUseMicrotaskEffectsByDefault} from '@angular/core/src/render3/reactivity/effect'; import {TestBed} from '@angular/core/testing'; import {bootstrapApplication} from '@angular/platform-browser'; import {withBody} from '@angular/private/testing'; import {filter, firstValueFrom, map} from 'rxjs'; -describe('effects', () => { - beforeEach(destroyPlatform); - afterEach(destroyPlatform); +describe('reactivity', () => { + let prev: boolean; + beforeEach(() => { + prev = setUseMicrotaskEffectsByDefault(false); + }); + afterEach(() => setUseMicrotaskEffectsByDefault(prev)); - it( - 'should run effects in the zone in which they get created', - withBody('', async () => { - const log: string[] = []; - @Component({ - selector: 'test-cmp', - standalone: true, - template: '', - }) - class Cmp { - constructor(ngZone: NgZone) { - effect(() => { - log.push(Zone.current.name); - }); + describe('effects', () => { + beforeEach(destroyPlatform); + afterEach(destroyPlatform); - ngZone.runOutsideAngular(() => { + it( + 'should run effects in the zone in which they get created', + withBody('', async () => { + const log: string[] = []; + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp { + constructor(ngZone: NgZone) { effect(() => { log.push(Zone.current.name); }); - }); + + ngZone.runOutsideAngular(() => { + effect(() => { + log.push(Zone.current.name); + }); + }); + } + } + + await bootstrapApplication(Cmp); + + expect(log).not.toEqual(['angular', 'angular']); + }), + ); + + it('should contribute to application stableness when an effect is pending', async () => { + const someSignal = signal('initial'); + const appRef = TestBed.inject(ApplicationRef); + + const isStable: boolean[] = []; + const sub = appRef.isStable.subscribe((stable) => isStable.push(stable)); + expect(isStable).toEqual([true]); + + TestBed.runInInjectionContext(() => effect(() => someSignal())); + expect(isStable).toEqual([true, false]); + + appRef.tick(); + + expect(isStable).toEqual([true, false, true]); + }); + + it('should propagate errors to the ErrorHandler', () => { + let run = false; + + let lastError: any = null; + class FakeErrorHandler extends ErrorHandler { + override handleError(error: any): void { + lastError = error; } } - await bootstrapApplication(Cmp); - - expect(log).not.toEqual(['angular', 'angular']); - }), - ); - - it('should contribute to application stableness when an effect is pending', async () => { - const someSignal = signal('initial'); - - @Component({ - standalone: true, - template: '', - }) - class App { - unused = effect(() => someSignal()); - } - - const appRef = TestBed.inject(ApplicationRef); - const componentRef = createComponent(App, { - environmentInjector: TestBed.inject(EnvironmentInjector), + const injector = createEnvironmentInjector( + [{provide: ErrorHandler, useFactory: () => new FakeErrorHandler()}], + TestBed.inject(EnvironmentInjector), + ); + effect( + () => { + run = true; + throw new Error('fail!'); + }, + {injector}, + ); + expect(() => TestBed.flushEffects()).not.toThrow(); + expect(run).toBeTrue(); + expect(lastError.message).toBe('fail!'); }); - // Effect is not scheduled until change detection runs for the component - await expectAsync(firstValueFrom(appRef.isStable)).toBeResolvedTo(true); - componentRef.changeDetectorRef.detectChanges(); - const stableEmits: boolean[] = []; - const p = firstValueFrom( - appRef.isStable.pipe( - map((stable) => { - stableEmits.push(stable); - return stableEmits; - }), - filter((emits) => emits.length === 2), - ), - ); - await expectAsync(p).toBeResolvedTo([false, true]); - componentRef.destroy(); - }); + it('should be usable inside an ErrorHandler', async () => { + const shouldError = signal(false); + let lastError: any = null; - it('should propagate errors to the ErrorHandler', () => { - let run = false; + class FakeErrorHandler extends ErrorHandler { + constructor() { + super(); + effect(() => { + if (shouldError()) { + throw new Error('fail!'); + } + }); + } - let lastError: any = null; - class FakeErrorHandler extends ErrorHandler { - override handleError(error: any): void { - lastError = error; - } - } - - const injector = createEnvironmentInjector( - [{provide: ErrorHandler, useFactory: () => new FakeErrorHandler()}], - TestBed.inject(EnvironmentInjector), - ); - effect( - () => { - run = true; - throw new Error('fail!'); - }, - {injector}, - ); - expect(() => TestBed.flushEffects()).not.toThrow(); - expect(run).toBeTrue(); - expect(lastError.message).toBe('fail!'); - }); - - it('should be usable inside an ErrorHandler', async () => { - const shouldError = signal(false); - let lastError: any = null; - - class FakeErrorHandler extends ErrorHandler { - constructor() { - super(); - effect(() => { - if (shouldError()) { - throw new Error('fail!'); - } - }); + override handleError(error: any): void { + lastError = error; + } } - override handleError(error: any): void { - lastError = error; + @Component({ + standalone: true, + template: '', + providers: [{provide: ErrorHandler, useClass: FakeErrorHandler}], + }) + class App { + errorHandler = inject(ErrorHandler); } - } - @Component({ - standalone: true, - template: '', - providers: [{provide: ErrorHandler, useClass: FakeErrorHandler}], - }) - class App { - errorHandler = inject(ErrorHandler); - } + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); - const fixture = TestBed.createComponent(App); - fixture.detectChanges(); + expect(fixture.componentInstance.errorHandler).toBeInstanceOf(FakeErrorHandler); + expect(lastError).toBe(null); - expect(fixture.componentInstance.errorHandler).toBeInstanceOf(FakeErrorHandler); - expect(lastError).toBe(null); + shouldError.set(true); + fixture.detectChanges(); - shouldError.set(true); - fixture.detectChanges(); + expect(lastError?.message).toBe('fail!'); + }); - expect(lastError?.message).toBe('fail!'); - }); + it('should run effect cleanup function on destroy', async () => { + let counterLog: number[] = []; + let cleanupCount = 0; - it('should run effect cleanup function on destroy', async () => { - let counterLog: number[] = []; - let cleanupCount = 0; - - @Component({ - selector: 'test-cmp', - standalone: true, - template: '', - }) - class Cmp { - counter = signal(0); - effectRef = effect((onCleanup) => { - counterLog.push(this.counter()); - onCleanup(() => { - cleanupCount++; - }); - }); - } - - const fixture = TestBed.createComponent(Cmp); - fixture.detectChanges(); - await fixture.whenStable(); - expect(counterLog).toEqual([0]); - // initially an effect runs but the default cleanup function is noop - expect(cleanupCount).toBe(0); - - fixture.componentInstance.counter.set(5); - fixture.detectChanges(); - await fixture.whenStable(); - expect(counterLog).toEqual([0, 5]); - expect(cleanupCount).toBe(1); - - fixture.destroy(); - expect(counterLog).toEqual([0, 5]); - expect(cleanupCount).toBe(2); - }); - - it('should run effects created in ngAfterViewInit', () => { - let didRun = false; - - @Component({ - selector: 'test-cmp', - standalone: true, - template: '', - }) - class Cmp implements AfterViewInit { - injector = inject(Injector); - - ngAfterViewInit(): void { - effect( - () => { - didRun = true; - }, - {injector: this.injector}, - ); - } - } - - const fixture = TestBed.createComponent(Cmp); - fixture.detectChanges(); - - expect(didRun).toBeTrue(); - }); - - it( - 'should disallow writing to signals within effects by default', - withBody('', async () => { @Component({ selector: 'test-cmp', standalone: true, @@ -243,436 +177,736 @@ describe('effects', () => { }) class Cmp { counter = signal(0); + effectRef = effect((onCleanup) => { + counterLog.push(this.counter()); + onCleanup(() => { + cleanupCount++; + }); + }); + } + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + await fixture.whenStable(); + expect(counterLog).toEqual([0]); + // initially an effect runs but the default cleanup function is noop + expect(cleanupCount).toBe(0); + + fixture.componentInstance.counter.set(5); + fixture.detectChanges(); + await fixture.whenStable(); + expect(counterLog).toEqual([0, 5]); + expect(cleanupCount).toBe(1); + + fixture.destroy(); + expect(counterLog).toEqual([0, 5]); + expect(cleanupCount).toBe(2); + }); + + it('should run effects created in ngAfterViewInit', () => { + let didRun = false; + + @Component({ + selector: 'test-cmp', + standalone: true, + template: '', + }) + class Cmp implements AfterViewInit { + injector = inject(Injector); + + ngAfterViewInit(): void { + effect( + () => { + didRun = true; + }, + {injector: this.injector}, + ); + } + } + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + + expect(didRun).toBeTrue(); + }); + + it('should create root effects when outside of a component, using injection context', () => { + TestBed.configureTestingModule({}); + const counter = signal(0); + const log: number[] = []; + TestBed.runInInjectionContext(() => effect(() => log.push(counter()))); + + TestBed.flushEffects(); + expect(log).toEqual([0]); + + counter.set(1); + TestBed.flushEffects(); + expect(log).toEqual([0, 1]); + }); + + it('should create root effects when outside of a component, using an injector', () => { + TestBed.configureTestingModule({}); + const counter = signal(0); + const log: number[] = []; + effect(() => log.push(counter()), {injector: TestBed.inject(Injector)}); + + TestBed.flushEffects(); + expect(log).toEqual([0]); + + counter.set(1); + TestBed.flushEffects(); + expect(log).toEqual([0, 1]); + }); + + it('should create root effects inside a component when specified', () => { + TestBed.configureTestingModule({}); + const counter = signal(0); + const log: number[] = []; + + @Component({ + standalone: true, + template: '', + }) + class TestCmp { constructor() { + effect(() => log.push(counter()), {forceRoot: true}); + } + } + + // Running this creates the effect. Note: we never CD this component. + TestBed.createComponent(TestCmp); + + TestBed.flushEffects(); + expect(log).toEqual([0]); + + counter.set(1); + TestBed.flushEffects(); + expect(log).toEqual([0, 1]); + }); + + it('should check components made dirty from markForCheck() from an effect', async () => { + TestBed.configureTestingModule({ + providers: [provideExperimentalZonelessChangeDetection()], + }); + + const source = signal(''); + @Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: '{{ data }}', + }) + class TestCmp { + cdr = inject(ChangeDetectorRef); + data = ''; + effectRef = effect(() => { + if (this.data !== source()) { + this.data = source(); + this.cdr.markForCheck(); + } + }); + } + + const fix = TestBed.createComponent(TestCmp); + await fix.whenStable(); + + source.set('test'); + await fix.whenStable(); + + expect(fix.nativeElement.innerHTML).toBe('test'); + }); + + it('should check components made dirty from markForCheck() from an effect in a service', async () => { + TestBed.configureTestingModule({ + providers: [provideExperimentalZonelessChangeDetection()], + }); + + const source = signal(''); + + @Injectable() + class Service { + data = ''; + cdr = inject(ChangeDetectorRef); + effectRef = effect(() => { + if (this.data !== source()) { + this.data = source(); + this.cdr.markForCheck(); + } + }); + } + + @Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [Service], + template: '{{ service.data }}', + }) + class TestCmp { + service = inject(Service); + } + + const fix = TestBed.createComponent(TestCmp); + await fix.whenStable(); + + source.set('test'); + await fix.whenStable(); + + expect(fix.nativeElement.innerHTML).toBe('test'); + }); + + it('should check views made dirty from markForCheck() from an effect in a directive', async () => { + TestBed.configureTestingModule({ + providers: [provideExperimentalZonelessChangeDetection()], + }); + + const source = signal(''); + + @Directive({ + standalone: true, + selector: '[dir]', + }) + class Dir { + tpl = inject(TemplateRef); + vcr = inject(ViewContainerRef); + cdr = inject(ChangeDetectorRef); + ctx = { + $implicit: '', + }; + ref = this.vcr.createEmbeddedView(this.tpl, this.ctx); + + effectRef = effect(() => { + if (this.ctx.$implicit !== source()) { + this.ctx.$implicit = source(); + this.cdr.markForCheck(); + } + }); + } + + @Component({ + standalone: true, + imports: [Dir], + template: `{{data}}`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class TestCmp {} + + const fix = TestBed.createComponent(TestCmp); + await fix.whenStable(); + + source.set('test'); + await fix.whenStable(); + + expect(fix.nativeElement.innerHTML).toContain('test'); + }); + + describe('destruction', () => { + it('should still destroy root effects with the DestroyRef of the component', () => { + TestBed.configureTestingModule({}); + const counter = signal(0); + const log: number[] = []; + + @Component({ + standalone: true, + template: '', + }) + class TestCmp { + constructor() { + effect(() => log.push(counter()), {forceRoot: true}); + } + } + + const fix = TestBed.createComponent(TestCmp); + + TestBed.flushEffects(); + expect(log).toEqual([0]); + + // Destroy the effect. + fix.destroy(); + + counter.set(1); + TestBed.flushEffects(); + expect(log).toEqual([0]); + }); + + it('should destroy effects when the parent component is destroyed', () => { + let destroyed = false; + @Component({ + standalone: true, + }) + class TestCmp { + constructor() { + effect((onCleanup) => onCleanup(() => (destroyed = true))); + } + } + + const fix = TestBed.createComponent(TestCmp); + fix.detectChanges(); + + fix.destroy(); + expect(destroyed).toBeTrue(); + }); + + it('should destroy effects when their view is destroyed, separately from DestroyRef', () => { + let destroyed = false; + @Component({ + standalone: true, + }) + class TestCmp { + readonly injector = Injector.create({providers: [], parent: inject(Injector)}); + + constructor() { + effect((onCleanup) => onCleanup(() => (destroyed = true)), {injector: this.injector}); + } + } + + const fix = TestBed.createComponent(TestCmp); + fix.detectChanges(); + + fix.destroy(); + expect(destroyed).toBeTrue(); + }); + + it('should destroy effects when their DestroyRef is separately destroyed', () => { + let destroyed = false; + @Component({ + standalone: true, + }) + class TestCmp { + readonly injector = Injector.create({providers: [], parent: inject(Injector)}); + + constructor() { + effect((onCleanup) => onCleanup(() => (destroyed = true)), {injector: this.injector}); + } + } + + const fix = TestBed.createComponent(TestCmp); + fix.detectChanges(); + + (fix.componentInstance.injector as Injector & {destroy(): void}).destroy(); + expect(destroyed).toBeTrue(); + }); + }); + }); + + describe('safeguards', () => { + it('should allow writing to signals within effects', () => { + const counter = signal(0); + + effect(() => counter.set(1), {injector: TestBed.inject(Injector)}); + TestBed.flushEffects(); + expect(counter()).toBe(1); + }); + + it('should allow writing to signals in ngOnChanges', () => { + @Component({ + selector: 'with-input', + standalone: true, + template: '{{inSignal()}}', + }) + class WithInput implements OnChanges { + inSignal = signal(undefined); + @Input() in: string | undefined; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['in']) { + this.inSignal.set(changes['in'].currentValue); + } + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithInput], + template: `|`, + }) + class Cmp {} + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('A|B'); + }); + + it('should allow writing to signals in a constructor', () => { + @Component({ + selector: 'with-constructor', + standalone: true, + template: '{{state()}}', + }) + class WithConstructor { + state = signal('property initializer'); + + constructor() { + this.state.set('constructor'); + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithConstructor], + template: ``, + }) + class Cmp {} + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('constructor'); + }); + + it('should allow writing to signals in input setters', () => { + @Component({ + selector: 'with-input-setter', + standalone: true, + template: '{{state()}}', + }) + class WithInputSetter { + state = signal('property initializer'); + + @Input() + set testInput(newValue: string) { + this.state.set(newValue); + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithInputSetter], + template: ` + | + `, + }) + class Cmp {} + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('binding|static'); + }); + + it('should allow writing to signals in query result setters', () => { + @Component({ + selector: 'with-query', + standalone: true, + template: '{{items().length}}', + }) + class WithQuery { + items = signal([]); + + @ContentChildren('item') + set itemsQuery(result: QueryList) { + this.items.set(result.toArray()); + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithQuery], + template: `
`, + }) + class Cmp {} + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('1'); + }); + + it('should not execute query setters in the reactive context', () => { + const state = signal('initial'); + + @Component({ + selector: 'with-query-setter', + standalone: true, + template: '
', + }) + class WithQuerySetter { + el: unknown; + @ViewChild('el', {static: true}) + set elQuery(result: unknown) { + // read a signal in a setter - I want to verify that framework executes this code outside of + // the reactive context + state(); + this.el = result; + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + template: ``, + }) + class Cmp { + noOfCmpCreated = 0; + constructor(environmentInjector: EnvironmentInjector) { + // A slightly artificial setup where a component instance is created using imperative APIs. + // We don't have control over the timing / reactive context of such API calls so need to + // code defensively in the framework. + + // Here we want to specifically verify that an effect is _not_ re-run if a signal read + // happens in a query setter of a dynamically created component. effect(() => { - expect(() => this.counter.set(1)).toThrow(); + createComponent(WithQuerySetter, {environmentInjector}); + this.noOfCmpCreated++; }); } } - await bootstrapApplication(Cmp); - }), - ); - - it('should allow writing to signals within effects when option set', () => { - const counter = signal(0); - - effect(() => counter.set(1), {allowSignalWrites: true, injector: TestBed.inject(Injector)}); - TestBed.flushEffects(); - expect(counter()).toBe(1); - }); - - it('should allow writing to signals in ngOnChanges', () => { - @Component({ - selector: 'with-input', - standalone: true, - template: '{{inSignal()}}', - }) - class WithInput implements OnChanges { - inSignal = signal(undefined); - @Input() in: string | undefined; - - ngOnChanges(changes: SimpleChanges): void { - if (changes['in']) { - this.inSignal.set(changes['in'].currentValue); - } - } - } - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [WithInput], - template: `|`, - }) - class Cmp {} - - const fixture = TestBed.createComponent(Cmp); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toBe('A|B'); - }); - - it('should allow writing to signals in a constructor', () => { - @Component({ - selector: 'with-constructor', - standalone: true, - template: '{{state()}}', - }) - class WithConstructor { - state = signal('property initializer'); - - constructor() { - this.state.set('constructor'); - } - } - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [WithConstructor], - template: ``, - }) - class Cmp {} - - const fixture = TestBed.createComponent(Cmp); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toBe('constructor'); - }); - - it('should allow writing to signals in input setters', () => { - @Component({ - selector: 'with-input-setter', - standalone: true, - template: '{{state()}}', - }) - class WithInputSetter { - state = signal('property initializer'); - - @Input() - set testInput(newValue: string) { - this.state.set(newValue); - } - } - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [WithInputSetter], - template: ` - | - `, - }) - class Cmp {} - - const fixture = TestBed.createComponent(Cmp); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toBe('binding|static'); - }); - - it('should allow writing to signals in query result setters', () => { - @Component({ - selector: 'with-query', - standalone: true, - template: '{{items().length}}', - }) - class WithQuery { - items = signal([]); - - @ContentChildren('item') - set itemsQuery(result: QueryList) { - this.items.set(result.toArray()); - } - } - - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [WithQuery], - template: `
`, - }) - class Cmp {} - - const fixture = TestBed.createComponent(Cmp); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toBe('1'); - }); - - it('should not execute query setters in the reactive context', () => { - const state = signal('initial'); - - @Component({ - selector: 'with-query-setter', - standalone: true, - template: '
', - }) - class WithQuerySetter { - el: unknown; - @ViewChild('el', {static: true}) - set elQuery(result: unknown) { - // read a signal in a setter - I want to verify that framework executes this code outside of - // the reactive context - state(); - this.el = result; - } - } - - @Component({ - selector: 'test-cmp', - standalone: true, - template: ``, - }) - class Cmp { - noOfCmpCreated = 0; - constructor(environmentInjector: EnvironmentInjector) { - // A slightly artificial setup where a component instance is created using imperative APIs. - // We don't have control over the timing / reactive context of such API calls so need to - // code defensively in the framework. - - // Here we want to specifically verify that an effect is _not_ re-run if a signal read - // happens in a query setter of a dynamically created component. - effect(() => { - createComponent(WithQuerySetter, {environmentInjector}); - this.noOfCmpCreated++; - }); - } - } - - const fixture = TestBed.createComponent(Cmp); - fixture.detectChanges(); - - expect(fixture.componentInstance.noOfCmpCreated).toBe(1); - - state.set('changed'); - fixture.detectChanges(); - - expect(fixture.componentInstance.noOfCmpCreated).toBe(1); - }); - - it('should allow toObservable subscription in template (with async pipe)', () => { - @Component({ - selector: 'test-cmp', - standalone: true, - imports: [AsyncPipe], - template: '{{counter$ | async}}', - }) - class Cmp { - counter$ = toObservable(signal(0)); - } - - const fixture = TestBed.createComponent(Cmp); - expect(() => fixture.detectChanges(true)).not.toThrow(); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toBe('0'); - }); - - describe('effects created in components should first run after ngOnInit', () => { - it('when created during bootstrapping', () => { - let log: string[] = []; - @Component({ - standalone: true, - selector: 'test-cmp', - template: '', - }) - class TestCmp { - constructor() { - effect(() => log.push('effect')); - } - - ngOnInit(): void { - log.push('init'); - } - } - - const fixture = TestBed.createComponent(TestCmp); - TestBed.flushEffects(); - expect(log).toEqual([]); + const fixture = TestBed.createComponent(Cmp); fixture.detectChanges(); - expect(log).toEqual(['init', 'effect']); + + expect(fixture.componentInstance.noOfCmpCreated).toBe(1); + + state.set('changed'); + fixture.detectChanges(); + + expect(fixture.componentInstance.noOfCmpCreated).toBe(1); }); - it('when created during change detection', () => { - let log: string[] = []; - + it('should allow toObservable subscription in template (with async pipe)', () => { @Component({ - standalone: true, selector: 'test-cmp', - template: '', + standalone: true, + imports: [AsyncPipe], + template: '{{counter$ | async}}', }) - class TestCmp { - ngOnInitRan = false; - constructor() { - effect(() => log.push('effect')); - } - - ngOnInit(): void { - log.push('init'); - } + class Cmp { + counter$ = toObservable(signal(0)); } - @Component({ - standalone: true, - selector: 'driver-cmp', - imports: [TestCmp], - template: ` + const fixture = TestBed.createComponent(Cmp); + expect(() => fixture.detectChanges(true)).not.toThrow(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('0'); + }); + + describe('effects created in components should first run after ngOnInit', () => { + it('when created during bootstrapping', () => { + let log: string[] = []; + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + }) + class TestCmp { + constructor() { + effect(() => log.push('effect')); + } + + ngOnInit(): void { + log.push('init'); + } + } + + const fixture = TestBed.createComponent(TestCmp); + TestBed.flushEffects(); + expect(log).toEqual([]); + fixture.detectChanges(); + expect(log).toEqual(['init', 'effect']); + }); + + it('when created during change detection', () => { + let log: string[] = []; + + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + }) + class TestCmp { + ngOnInitRan = false; + constructor() { + effect(() => log.push('effect')); + } + + ngOnInit(): void { + log.push('init'); + } + } + + @Component({ + standalone: true, + selector: 'driver-cmp', + imports: [TestCmp], + template: ` @if (cond) { } `, - }) - class DriverCmp { - cond = false; - } - - const fixture = TestBed.createComponent(DriverCmp); - fixture.detectChanges(); - expect(log).toEqual([]); - - // Toggle the @if, which should create and run the effect. - fixture.componentInstance.cond = true; - fixture.detectChanges(); - expect(log).toEqual(['init', 'effect']); - }); - - it('when created dynamically', () => { - let log: string[] = []; - @Component({ - standalone: true, - selector: 'test-cmp', - template: '', - }) - class TestCmp { - ngOnInitRan = false; - constructor() { - effect(() => log.push('effect')); + }) + class DriverCmp { + cond = false; } - ngOnInit(): void { - log.push('init'); - } - } + const fixture = TestBed.createComponent(DriverCmp); + fixture.detectChanges(); + expect(log).toEqual([]); - @Component({ - standalone: true, - selector: 'driver-cmp', - template: '', - }) - class DriverCmp { - vcr = inject(ViewContainerRef); - } - - const fixture = TestBed.createComponent(DriverCmp); - fixture.detectChanges(); - - const ref = fixture.componentInstance.vcr.createComponent(TestCmp); - - // Verify that simply creating the component didn't schedule the effect. - TestBed.flushEffects(); - expect(log).toEqual([]); - - // Running change detection should schedule and run the effect. - fixture.detectChanges(); - expect(log).toEqual(['init', 'effect']); - ref.destroy(); - }); - - it('when created in a service provided in a component', () => { - let log: string[] = []; - - @Injectable() - class EffectService { - constructor() { - effect(() => log.push('effect')); - } - } - - @Component({ - standalone: true, - selector: 'test-cmp', - template: '', - providers: [EffectService], - }) - class TestCmp { - svc = inject(EffectService); - - ngOnInit(): void { - log.push('init'); - } - } - - const fixture = TestBed.createComponent(TestCmp); - TestBed.flushEffects(); - expect(log).toEqual([]); - fixture.detectChanges(); - expect(log).toEqual(['init', 'effect']); - }); - - it('if multiple effects are created', () => { - let log: string[] = []; - @Component({ - standalone: true, - selector: 'test-cmp', - template: '', - }) - class TestCmp { - constructor() { - effect(() => log.push('effect a')); - effect(() => log.push('effect b')); - effect(() => log.push('effect c')); - } - - ngOnInit(): void { - log.push('init'); - } - } - - const fixture = TestBed.createComponent(TestCmp); - fixture.detectChanges(); - expect(log[0]).toBe('init'); - expect(log).toContain('effect a'); - expect(log).toContain('effect b'); - expect(log).toContain('effect c'); - }); - }); - - describe('should disallow creating an effect context', () => { - it('inside template effect', () => { - @Component({ - template: '{{someFn()}}', - }) - class Cmp { - someFn() { - effect(() => {}); - } - } - - const fixture = TestBed.createComponent(Cmp); - expect(() => fixture.detectChanges(true)).toThrowError( - /effect\(\) cannot be called from within a reactive context./, - ); - }); - - it('inside computed', () => { - expect(() => { - computed(() => { - effect(() => {}); - })(); - }).toThrowError(/effect\(\) cannot be called from within a reactive context./); - }); - - it('inside an effect', () => { - @Component({ - template: '', - }) - class Cmp { - constructor() { - effect(() => { - this.someFnThatWillCreateAnEffect(); - }); - } - - someFnThatWillCreateAnEffect() { - effect(() => {}); - } - } - - TestBed.configureTestingModule({ - providers: [ - { - provide: ErrorHandler, - useClass: class extends ErrorHandler { - override handleError(e: Error) { - throw e; - } - }, - }, - ], + // Toggle the @if, which should create and run the effect. + fixture.componentInstance.cond = true; + fixture.detectChanges(); + expect(log).toEqual(['init', 'effect']); }); - const fixture = TestBed.createComponent(Cmp); - expect(() => fixture.detectChanges()).toThrowError( - /effect\(\) cannot be called from within a reactive context./, - ); + it('when created dynamically', () => { + let log: string[] = []; + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + }) + class TestCmp { + ngOnInitRan = false; + constructor() { + effect(() => log.push('effect')); + } + + ngOnInit(): void { + log.push('init'); + } + } + + @Component({ + standalone: true, + selector: 'driver-cmp', + template: '', + }) + class DriverCmp { + vcr = inject(ViewContainerRef); + } + + const fixture = TestBed.createComponent(DriverCmp); + fixture.detectChanges(); + + fixture.componentInstance.vcr.createComponent(TestCmp); + + // Verify that simply creating the component didn't schedule the effect. + TestBed.flushEffects(); + expect(log).toEqual([]); + + // Running change detection should schedule and run the effect. + fixture.detectChanges(); + expect(log).toEqual(['init', 'effect']); + }); + + it('when created in a service provided in a component', () => { + let log: string[] = []; + + @Injectable() + class EffectService { + constructor() { + effect(() => log.push('effect')); + } + } + + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + providers: [EffectService], + }) + class TestCmp { + svc = inject(EffectService); + + ngOnInit(): void { + log.push('init'); + } + } + + const fixture = TestBed.createComponent(TestCmp); + TestBed.flushEffects(); + expect(log).toEqual([]); + fixture.detectChanges(); + expect(log).toEqual(['init', 'effect']); + }); + + it('if multiple effects are created', () => { + let log: string[] = []; + @Component({ + standalone: true, + selector: 'test-cmp', + template: '', + }) + class TestCmp { + constructor() { + effect(() => log.push('effect a')); + effect(() => log.push('effect b')); + effect(() => log.push('effect c')); + } + + ngOnInit(): void { + log.push('init'); + } + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(log[0]).toBe('init'); + expect(log).toContain('effect a'); + expect(log).toContain('effect b'); + expect(log).toContain('effect c'); + }); + }); + + describe('should disallow creating an effect context', () => { + it('inside template effect', () => { + @Component({ + template: '{{someFn()}}', + }) + class Cmp { + someFn() { + effect(() => {}); + } + } + + const fixture = TestBed.createComponent(Cmp); + expect(() => fixture.detectChanges(true)).toThrowError( + /effect\(\) cannot be called from within a reactive context./, + ); + }); + + it('inside computed', () => { + expect(() => { + computed(() => { + effect(() => {}); + })(); + }).toThrowError(/effect\(\) cannot be called from within a reactive context./); + }); + + it('inside an effect', () => { + @Component({ + template: '', + }) + class Cmp { + constructor() { + effect(() => { + this.someFnThatWillCreateAnEffect(); + }); + } + + someFnThatWillCreateAnEffect() { + effect(() => {}); + } + } + + TestBed.configureTestingModule({ + providers: [ + { + provide: ErrorHandler, + useClass: class extends ErrorHandler { + override handleError(e: Error) { + throw e; + } + }, + }, + ], + }); + const fixture = TestBed.createComponent(Cmp); + + expect(() => fixture.detectChanges()).toThrowError( + /effect\(\) cannot be called from within a reactive context./, + ); + }); }); }); }); diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts index a175719e87f..8547b822602 100644 --- a/packages/core/test/render3/view_fixture.ts +++ b/packages/core/test/render3/view_fixture.ts @@ -114,7 +114,6 @@ export class ViewFixture { { rendererFactory, sanitizer: sanitizer || null, - inlineEffectRunner: null, changeDetectionScheduler: null, }, hostRenderer, diff --git a/packages/core/test/test_bed_effect_spec.ts b/packages/core/test/test_bed_effect_spec.ts index eda71e7088b..f18f0bba7ac 100644 --- a/packages/core/test/test_bed_effect_spec.ts +++ b/packages/core/test/test_bed_effect_spec.ts @@ -6,10 +6,28 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component, effect, inject, Injector, NgZone, signal} from '@angular/core'; +import { + ApplicationRef, + ChangeDetectionStrategy, + Component, + effect, + inject, + Injector, + Input, + NgZone, + provideZoneChangeDetection, + signal, +} from '@angular/core'; import {TestBed} from '@angular/core/testing'; +import {setUseMicrotaskEffectsByDefault} from '../src/render3/reactivity/effect'; describe('effects in TestBed', () => { + let prev: boolean; + beforeEach(() => { + prev = setUseMicrotaskEffectsByDefault(false); + }); + afterEach(() => setUseMicrotaskEffectsByDefault(prev)); + it('created in the constructor should run with detectChanges()', () => { const log: string[] = []; @Component({ @@ -114,4 +132,64 @@ describe('effects in TestBed', () => { await fixture.whenStable(); expect(observed).toBe('new'); }); + + it('will run an effect with an input signal on the first CD', () => { + let observed: string | null = null; + + @Component({ + standalone: true, + template: '', + }) + class Cmp { + @Input() input!: string; + constructor() { + effect(() => { + observed = this.input; + }); + } + } + + const fix = TestBed.createComponent(Cmp); + fix.componentRef.setInput('input', 'hello'); + fix.detectChanges(); + + expect(observed as string | null).toBe('hello'); + }); + + it('should run root effects before detectChanges() when in zone mode', async () => { + TestBed.configureTestingModule({ + providers: [provideZoneChangeDetection()], + }); + const log: string[] = []; + + @Component({ + standalone: true, + template: `{{ sentinel }}`, + }) + class TestCmp { + get sentinel(): string { + log.push('CD'); + return ''; + } + } + + // Instantiate the component and CD it once. + const fix = TestBed.createComponent(TestCmp); + fix.detectChanges(); + + // Instantiate a root effect and run it once. + const counter = signal(0); + const appRef = TestBed.inject(ApplicationRef); + effect(() => log.push(`effect: ${counter()}`), {injector: appRef.injector}); + await appRef.whenStable(); + + log.length = 0; + + // Trigger the effect and call `detectChanges()` on the fixture. + counter.set(1); + fix.detectChanges(false); + + // The effect should run before the component CD. + expect(log).toEqual(['effect: 1', 'CD']); + }); }); diff --git a/packages/core/testing/src/component_fixture.ts b/packages/core/testing/src/component_fixture.ts index 9e9767ef3dc..c273389403f 100644 --- a/packages/core/testing/src/component_fixture.ts +++ b/packages/core/testing/src/component_fixture.ts @@ -20,11 +20,12 @@ import { RendererFactory2, ViewRef, ɵDeferBlockDetails as DeferBlockDetails, - ɵEffectScheduler as EffectScheduler, ɵgetDeferBlocks as getDeferBlocks, ɵNoopNgZone as NoopNgZone, ɵZONELESS_ENABLED as ZONELESS_ENABLED, ɵPendingTasks as PendingTasks, + ɵEffectScheduler as EffectScheduler, + ɵMicrotaskEffectScheduler as MicrotaskEffectScheduler, } from '@angular/core'; import {Subscription} from 'rxjs'; @@ -74,8 +75,6 @@ export class ComponentFixture { protected readonly _noZoneOptionIsSet = inject(ComponentFixtureNoNgZone, {optional: true}); /** @internal */ protected _ngZone: NgZone = this._noZoneOptionIsSet ? new NoopNgZone() : inject(NgZone); - /** @internal */ - protected _effectRunner = inject(EffectScheduler); // Inject ApplicationRef to ensure NgZone stableness causes after render hooks to run // This will likely happen as a result of fixture.detectChanges because it calls ngZone.run // This is a crazy way of doing things but hey, it's the world we live in. @@ -89,6 +88,8 @@ export class ComponentFixture { private readonly appErrorHandler = inject(TestBedApplicationErrorHandler); private readonly zonelessEnabled = inject(ZONELESS_ENABLED); private readonly scheduler = inject(ɵChangeDetectionScheduler); + private readonly rootEffectScheduler = inject(EffectScheduler); + private readonly microtaskEffectScheduler = inject(MicrotaskEffectScheduler); private readonly autoDetectDefault = this.zonelessEnabled ? true : false; private autoDetect = inject(ComponentFixtureAutoDetect, {optional: true}) ?? this.autoDetectDefault; @@ -132,7 +133,7 @@ export class ComponentFixture { * Trigger a change detection cycle for the component. */ detectChanges(checkNoChanges = true): void { - this._effectRunner.flush(); + this.microtaskEffectScheduler.flush(); const originalCheckNoChanges = this.componentRef.changeDetectorRef.checkNoChanges; try { if (!checkNoChanges) { @@ -151,9 +152,9 @@ export class ComponentFixture { } else { // Run the change detection inside the NgZone so that any async tasks as part of the change // detection are captured by the zone and can be waited for in isStable. - // Run any effects that were created/dirtied during change detection. Such effects might become - // dirty in response to input signals changing. this._ngZone.run(() => { + // Flush root effects before `detectChanges()`, to emulate the sequencing of `tick()`. + this.rootEffectScheduler.flush(); this.changeDetectorRef.detectChanges(); this.checkNoChanges(); }); @@ -161,7 +162,7 @@ export class ComponentFixture { } finally { this.componentRef.changeDetectorRef.checkNoChanges = originalCheckNoChanges; } - this._effectRunner.flush(); + this.microtaskEffectScheduler.flush(); } /** diff --git a/packages/core/testing/src/test_bed.ts b/packages/core/testing/src/test_bed.ts index 3b5a0abd149..8e6ceb34670 100644 --- a/packages/core/testing/src/test_bed.ts +++ b/packages/core/testing/src/test_bed.ts @@ -39,6 +39,7 @@ import { ɵsetUnknownElementStrictMode as setUnknownElementStrictMode, ɵsetUnknownPropertyStrictMode as setUnknownPropertyStrictMode, ɵstringify as stringify, + ɵMicrotaskEffectScheduler as MicrotaskEffectScheduler, } from '@angular/core'; import {ComponentFixture} from './component_fixture'; @@ -855,6 +856,7 @@ export class TestBedImpl implements TestBed { * @developerPreview */ flushEffects(): void { + this.inject(MicrotaskEffectScheduler).flush(); this.inject(EffectScheduler).flush(); } }