refactor(core): add support for new effect scheduling. (#56501)

The original effect design for Angular had one "bucket" of effects, which
are scheduled on the microtask queue. This approach got us pretty far, but
as developers have built more complex reactive systems, we've hit the
limitations of this design.

This commit changes the nature of effects significantly. In particular,
effects created in components have a completely new scheduling system, which
executes them as a part of the change detection cycle. This results in
behavior similar to that of nested effects in other reactive frameworks. The
scheduling behavior here uses the "mark for traversal" flag
(`HasChildViewsToRefresh`). This has really nice behavior:

 * if the component is dirty already, effects run following preorder hooks
   (ngOnInit, etc).
 * if the component isn't dirty, it doesn't get change detected only because
   of the dirty effect.

This is not a breaking change, since `effect()` is in developer preview (and
it remains so).

As a part of this redesigned `effect()` behavior, the `allowSignalWrites`
flag was removed. Effects no longer prohibit writing to signals at all. This
decision was taken in response to feedback / observations of usage patterns,
which showed the benefit of the restriction did not justify the DX cost.

The new effect timing is not yet enabled - a future PR will flip the flag.

PR Close #56501
This commit is contained in:
Alex Rickabaugh 2024-06-05 08:45:27 -07:00 committed by Andrew Scott
parent c6039b5f89
commit 4e890cc5ac
44 changed files with 2549 additions and 837 deletions

View file

@ -447,7 +447,9 @@ export interface CreateComputedOptions<T> {
// @public
export interface CreateEffectOptions {
// @deprecated (undocumented)
allowSignalWrites?: boolean;
forceRoot?: true;
injector?: Injector;
manualCleanup?: boolean;
}

View file

@ -41,7 +41,7 @@
},
"standalone-bootstrap": {
"uncompressed": {
"main": 89354,
"main": 94769,
"polyfills": 33802
}
},

View file

@ -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';

View file

@ -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<T>(source: Signal<T>, options?: ToObservableOptions
return subject.asObservable();
}
export function toObservableMicrotask<T>(
source: Signal<T>,
options?: ToObservableOptions,
): Observable<T> {
!options?.injector && assertInInjectionContext(toObservable);
const injector = options?.injector ?? inject(Injector);
const subject = new ReplaySubject<T>(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();
}

View file

@ -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<ApplicationRef, Promise<void>> | undefined;

View file

@ -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,
}
/**

View file

@ -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

View file

@ -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';

View file

@ -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();
}

View file

@ -266,8 +266,6 @@ export class ComponentFactory<T> extends AbstractComponentFactory<T> {
const environment: LViewEnvironment = {
rendererFactory,
sanitizer,
// We don't use inline effects (yet).
inlineEffectRunner: null,
changeDetectionScheduler,
};

View file

@ -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<T>(
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<T>(
// `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) {

View file

@ -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<T = unknown> extends Array<any> {
*/
[EFFECTS_TO_SCHEDULE]: Array<() => void> | null;
[EFFECTS]: Set<ViewEffectNode> | 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;
}

View file

@ -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 */

View file

@ -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<Zone | null, Set<SchedulableEffect>>();
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<SchedulableEffect>): 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<unknown> | 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<EffectNode, 'fn' | 'destroy' | 'injector'> =
/* @__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<RootEffectNode, 'fn' | 'scheduler' | 'notifier' | 'injector'> =
/* @__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<ViewEffectNode, 'fn' | 'view' | 'injector'> =
/* @__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;
}

View file

@ -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<unknown> | 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;
}

View file

@ -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;

View file

@ -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<T>(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<Zone | null, Set<SchedulableEffect>>();
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<SchedulableEffect>): void {
for (const handle of queue) {
queue.delete(handle);
this.queuedEffectCount--;
// TODO: what happens if this throws an error?
handle.run();
}
}
}

View file

@ -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);
}
}

View file

@ -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)

View file

@ -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()!);
}

View file

@ -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<T>(component: Type<T>) {
const componentRef = createComponent(component, {
@ -43,6 +44,12 @@ function createAndAttachComponent<T>(component: Type<T>) {
}
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 = {

View file

@ -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]',

View file

@ -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,

View file

@ -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 {

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -156,7 +156,6 @@ describe('di', () => {
{
rendererFactory: {} as any,
sanitizer: null,
inlineEffectRunner: null,
changeDetectionScheduler: null,
},
{} as any,

View file

@ -72,7 +72,6 @@ export function enterViewWithOneDiv() {
{
rendererFactory,
sanitizer: null,
inlineEffectRunner: null,
changeDetectionScheduler: null,
},
renderer,

View file

@ -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('<test-cmp></test-cmp>', 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('<test-cmp></test-cmp>', 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<string | undefined>(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: `<with-input [in]="'A'" />|<with-input [in]="'B'" />`,
})
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: `<with-constructor />`,
})
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: `
<with-input-setter [testInput]="'binding'" />|<with-input-setter testInput="static" />
`,
})
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<unknown[]>([]);
@ContentChildren('item')
set itemsQuery(result: QueryList<unknown>) {
this.items.set(result.toArray());
}
}
@Component({
selector: 'test-cmp',
standalone: true,
imports: [WithQuery],
template: `<with-query><div #item></div></with-query>`,
})
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: '<div #el></div>',
})
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) {
<test-cmp />
}
`,
})
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');
});
});

View file

@ -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({

File diff suppressed because it is too large Load diff

View file

@ -114,7 +114,6 @@ export class ViewFixture {
{
rendererFactory,
sanitizer: sanitizer || null,
inlineEffectRunner: null,
changeDetectionScheduler: null,
},
hostRenderer,

View file

@ -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']);
});
});

View file

@ -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<T> {
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<T> {
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<T> {
* 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<T> {
} 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<T> {
} finally {
this.componentRef.changeDetectorRef.checkNoChanges = originalCheckNoChanges;
}
this._effectRunner.flush();
this.microtaskEffectScheduler.flush();
}
/**

View file

@ -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();
}
}