refactor(core): decouple effects from change detection (#51049)

Previously effects were queued as they became dirty, and this queue was
flushed at various checkpoints during the change detection cycle. The result
was that change detection _was_ the effect runner, and without executing CD,
effects would not execute. This leads a particular tradeoff:

* effects are subject to unidirectional data flow (bad for dx)
* effects don't cause a new round of CD (good/bad depending on use case)
* effects can be used to implement control flow efficiently (desirable)

This commit changes the scheduling mechanism. Effects are now scheduled via
the microtask queue. This changes the tradeoffs:

* effects are no longer limited by unidirectional data flow (easy dx)
* effects registered in the Angular zone will trigger CD after they run
  (same as `Promise.resolve` really)
* the public `effect()` type of effect probably isn't a good building block
  for our built-in control flow, and we'll need a new internal abstraction.

As `effect()` is in developer preview, changing the execution timing is not
considered breaking even though it may impact current users.

PR Close #51049
This commit is contained in:
Alex Rickabaugh 2023-07-14 11:02:51 -07:00 committed by Andrew Kushnir
parent e86d6dba27
commit 38c9f08c8d
27 changed files with 363 additions and 407 deletions

View file

@ -20,6 +20,7 @@ import { PlatformRef } from '@angular/core';
import { ProviderToken } from '@angular/core';
import { SchemaMetadata } from '@angular/core';
import { Type } from '@angular/core';
import { ɵFlushableEffectRunner } from '@angular/core';
// @public
export const __core_private_testing_placeholder__ = "";
@ -29,7 +30,7 @@ export function async(fn: Function): (done: any) => any;
// @public
export class ComponentFixture<T> {
constructor(componentRef: ComponentRef<T>, ngZone: NgZone | null, _autoDetect: boolean);
constructor(componentRef: ComponentRef<T>, ngZone: NgZone | null, effectRunner: ɵFlushableEffectRunner | null, _autoDetect: boolean);
autoDetectChanges(autoDetect?: boolean): void;
changeDetectorRef: ChangeDetectorRef;
checkNoChanges(): void;
@ -110,6 +111,7 @@ export interface TestBed {
createComponent<T>(component: Type<T>): ComponentFixture<T>;
// (undocumented)
execute(tokens: any[], fn: Function, context?: any): any;
flushEffects(): void;
// @deprecated (undocumented)
get<T>(token: ProviderToken<T>, notFoundValue?: T, flags?: InjectFlags): any;
// @deprecated (undocumented)

View file

@ -23,5 +23,8 @@ export {
effect,
EffectRef,
EffectCleanupFn,
EffectScheduler as ɵEffectScheduler,
ZoneAwareQueueingScheduler as ɵZoneAwareQueueingScheduler,
FlushableEffectRunner as ɵFlushableEffectRunner,
} from './render3/reactivity/effect';
// clang-format on
// clang-format on

View file

@ -44,7 +44,7 @@ import {CONTEXT, HEADER_OFFSET, INJECTOR, LView, LViewEnvironment, LViewFlags, T
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces';
import {createElementNode, setupStaticAttributes, writeDirectClass} from './node_manipulation';
import {extractAttrsAndClassesFromSelector, stringifyCSSSelectorList} from './node_selector_matcher';
import {EffectManager} from './reactivity/effect';
import {EffectScheduler} from './reactivity/effect';
import {enterView, getCurrentTNode, getLView, leaveView} from './state';
import {computeStaticStyling} from './styling/static_styling';
import {mergeHostAttrs, setUpAttributes} from './util/attrs_utils';
@ -188,14 +188,13 @@ export class ComponentFactory<T> extends AbstractComponentFactory<T> {
}
const sanitizer = rootViewInjector.get(Sanitizer, null);
const effectManager = rootViewInjector.get(EffectManager, null);
const afterRenderEventManager = rootViewInjector.get(AfterRenderEventManager, null);
const environment: LViewEnvironment = {
rendererFactory,
sanitizer,
effectManager,
// We don't use inline effects (yet).
inlineEffectRunner: null,
afterRenderEventManager,
};

View file

@ -48,7 +48,7 @@ export function detectChangesInternal<T>(
// One final flush of the effects queue to catch any effects created in `ngAfterViewInit` or
// other post-order hooks.
environment.effectManager?.flush();
environment.inlineEffectRunner?.flush();
// Invoke all callbacks registered via `after*Render`, if needed.
afterRenderEventManager?.end();
@ -117,7 +117,7 @@ export function refreshView<T>(
// since they were assigned. We do not want to execute lifecycle hooks in that mode.
const isInCheckNoChangesPass = ngDevMode && isInCheckNoChangesMode();
!isInCheckNoChangesPass && lView[ENVIRONMENT].effectManager?.flush();
!isInCheckNoChangesPass && lView[ENVIRONMENT].inlineEffectRunner?.flush();
enterView(lView);
try {

View file

@ -12,7 +12,7 @@ import {DehydratedView} from '../../hydration/interfaces';
import {SchemaMetadata} from '../../metadata/schema';
import {Sanitizer} from '../../sanitization/sanitizer';
import type {ReactiveLViewConsumer} from '../reactive_lview_consumer';
import type {EffectManager} from '../reactivity/effect';
import type {FlushableEffectRunner} from '../reactivity/effect';
import type {AfterRenderEventManager} from '../after_render_hooks';
import {LContainer} from './container';
@ -372,7 +372,7 @@ export interface LViewEnvironment {
sanitizer: Sanitizer|null;
/** Container for reactivity system `effect`s. */
effectManager: EffectManager|null;
inlineEffectRunner: FlushableEffectRunner|null;
/** Container for after render hooks */
afterRenderEventManager: AfterRenderEventManager|null;

View file

@ -7,11 +7,14 @@
*/
import {assertInInjectionContext} from '../../di/contextual';
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 {DestroyRef} from '../../linker/destroy_ref';
import {Watch, watch} from '../../signals';
import {isInNotificationPhase, watch, Watch, WatchCleanupFn, WatchCleanupRegisterFn} from '../../signals';
/**
* An effect can, optionally, register a cleanup function. If registered, the cleanup is executed
@ -27,73 +30,201 @@ export type EffectCleanupFn = () => void;
*/
export type EffectCleanupRegisterFn = (cleanupFn: EffectCleanupFn) => void;
export interface SchedulableEffect {
run(): void;
creationZone: unknown;
}
/**
* Tracks all effects registered within a given application and runs them via `flush`.
* Not public API, which guarantees `EffectScheduler` only ever comes from the application root
* injector.
*/
export class EffectManager {
private all = new Set<Watch>();
private queue = new Map<Watch, Zone|null>();
export const APP_EFFECT_SCHEDULER = new InjectionToken('', {
providedIn: 'root',
factory: () => inject(EffectScheduler),
});
create(
effectFn: (onCleanup: (cleanupFn: EffectCleanupFn) => void) => void,
destroyRef: DestroyRef|null, allowSignalWrites: boolean): EffectRef {
const zone = (typeof Zone === 'undefined') ? null : Zone.current;
const w = watch(effectFn, (watch) => {
if (!this.all.has(watch)) {
return;
}
/**
* 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;
this.queue.set(watch, zone);
}, allowSignalWrites);
/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
token: EffectScheduler,
providedIn: 'root',
factory: () => new ZoneAwareMicrotaskScheduler(),
});
}
this.all.add(w);
/**
* Interface to an `EffectScheduler` capable of running scheduled effects synchronously.
*/
export interface FlushableEffectRunner {
/**
* Run any scheduled effects.
*/
flush(): void;
}
// Effects start dirty.
w.notify();
/**
* An `EffectScheduler` which is capable of queueing scheduled effects per-zone, and flushing them
* as an explicit operation.
*/
export class ZoneAwareQueueingScheduler implements EffectScheduler, FlushableEffectRunner {
private queuedEffectCount = 0;
private queues = new Map<Zone|null, Set<SchedulableEffect>>();
let unregisterOnDestroy: (() => void)|undefined;
scheduleEffect(handle: SchedulableEffect): void {
const zone = handle.creationZone as Zone | null;
if (!this.queues.has(zone)) {
this.queues.set(zone, new Set());
}
const destroy = () => {
w.cleanup();
unregisterOnDestroy?.();
this.all.delete(w);
this.queue.delete(w);
};
unregisterOnDestroy = destroyRef?.onDestroy(destroy);
return {
destroy,
};
}
flush(): void {
if (this.queue.size === 0) {
const queue = this.queues.get(zone)!;
if (queue.has(handle)) {
return;
}
this.queuedEffectCount++;
queue.add(handle);
}
for (const [watch, zone] of this.queue) {
this.queue.delete(watch);
if (zone) {
zone.run(() => watch.run());
} else {
watch.run();
/**
* 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));
}
}
}
}
get isQueueEmpty(): boolean {
return this.queue.size === 0;
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();
}
}
/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
token: EffectManager,
token: ZoneAwareQueueingScheduler,
providedIn: 'root',
factory: () => new EffectManager(),
factory: () => new ZoneAwareQueueingScheduler(),
});
}
/**
* A wrapper around `ZoneAwareQueueingScheduler` that schedules flushing via the microtask queue
* when.
*/
export class ZoneAwareMicrotaskScheduler implements EffectScheduler {
private hasQueuedFlush = false;
private delegate = new ZoneAwareQueueingScheduler();
private flushTask = () => {
// Leave `hasQueuedFlush` as `true` so we don't queue another microtask if more effects are
// scheduled during flushing. The flush of the `ZoneAwareQueueingScheduler` delegate is
// guaranteed to empty the queue.
this.delegate.flush();
this.hasQueuedFlush = false;
// This is a variable initialization, not a method.
// tslint:disable-next-line:semicolon
};
scheduleEffect(handle: SchedulableEffect): void {
this.delegate.scheduleEffect(handle);
if (!this.hasQueuedFlush) {
queueMicrotask(this.flushTask);
this.hasQueuedFlush = true;
}
}
}
/**
* 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 {
private alive = true;
unregisterOnDestroy: (() => void)|undefined;
protected watcher: Watch;
constructor(
private scheduler: EffectScheduler,
private effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
public creationZone: Zone|null, destroyRef: DestroyRef|null,
private errorHandler: ErrorHandler|null, allowSignalWrites: boolean) {
this.watcher =
watch((onCleanup) => this.runEffect(onCleanup), () => this.schedule(), allowSignalWrites);
this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy());
}
private runEffect(onCleanup: WatchCleanupRegisterFn): void {
if (!this.alive) {
// Running a destroyed effect is a no-op.
return;
}
if (ngDevMode && isInNotificationPhase()) {
throw new Error(`Schedulers cannot synchronously execute effects while scheduling.`);
}
try {
this.effectFn(onCleanup);
} catch (err) {
this.errorHandler?.handleError(err);
}
}
run(): void {
this.watcher.run();
}
private schedule(): void {
if (!this.alive) {
return;
}
this.scheduler.scheduleEffect(this);
}
notify(): void {
this.watcher.notify();
}
destroy(): void {
this.alive = false;
this.watcher.cleanup();
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.
}
}
/**
* A global reactive effect, which can be manually destroyed.
*
@ -147,7 +278,16 @@ export function effect(
options?: CreateEffectOptions): EffectRef {
!options?.injector && assertInInjectionContext(effect);
const injector = options?.injector ?? inject(Injector);
const effectManager = injector.get(EffectManager);
const errorHandler = injector.get(ErrorHandler, null, {optional: true});
const destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef) : null;
return effectManager.create(effectFn, destroyRef, !!options?.allowSignalWrites);
const handle = new EffectHandle(
injector.get(APP_EFFECT_SCHEDULER), effectFn,
(typeof Zone === 'undefined') ? null : Zone.current, destroyRef, errorHandler,
options?.allowSignalWrites ?? false);
// Effects start dirty.
handle.notify();
return handle;
}

View file

@ -9,8 +9,8 @@
export {defaultEquals, isSignal, Signal, SIGNAL, ValueEqualityFn} from './src/api';
export {computed, CreateComputedOptions} from './src/computed';
export {setThrowInvalidWriteToSignalError} from './src/errors';
export {consumerAfterComputation, consumerBeforeComputation, consumerDestroy, producerAccessed, producerNotifyConsumers, producerUpdatesAllowed, producerUpdateValueVersion, REACTIVE_NODE, ReactiveNode, setActiveConsumer} from './src/graph';
export {consumerAfterComputation, consumerBeforeComputation, consumerDestroy, isInNotificationPhase, producerAccessed, producerNotifyConsumers, producerUpdatesAllowed, producerUpdateValueVersion, REACTIVE_NODE, ReactiveNode, setActiveConsumer} from './src/graph';
export {CreateSignalOptions, setPostSignalSetFn, signal, WritableSignal} from './src/signal';
export {untracked} from './src/untracked';
export {Watch, watch, WatchCleanupFn} from './src/watch';
export {Watch, watch, WatchCleanupFn, WatchCleanupRegisterFn} from './src/watch';
export {setAlternateWeakRefImpl} from './src/weak_ref';

View file

@ -26,6 +26,10 @@ export function setActiveConsumer(consumer: ReactiveNode|null): ReactiveNode|nul
return prev;
}
export function isInNotificationPhase(): boolean {
return inNotificationPhase;
}
export const REACTIVE_NODE = {
version: 0 as Version,
dirty: false,

View file

@ -197,9 +197,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementInstructionMap"
},
@ -338,9 +335,6 @@
{
"name": "NG_TEMPLATE_SELECTOR"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -422,9 +416,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
@ -545,9 +536,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "WebAnimationsPlayer"
},
@ -749,12 +737,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "containsElement"
},
@ -1049,9 +1031,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -1313,9 +1292,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -224,9 +224,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementInstructionMap"
},
@ -365,9 +362,6 @@
{
"name": "NG_TEMPLATE_SELECTOR"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -461,9 +455,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
@ -599,9 +590,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "WebAnimationsPlayer"
},
@ -806,12 +794,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "containsElement"
},
@ -1115,9 +1097,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -1388,9 +1367,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -134,9 +134,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementRef"
},
@ -269,9 +266,6 @@
{
"name": "NG_TEMPLATE_SELECTOR"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -347,9 +341,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
@ -458,9 +449,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "ZONE_IS_STABLE_OBSERVABLE"
},
@ -617,12 +605,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "convertToBitFlags"
},
@ -881,9 +863,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -1109,9 +1088,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -182,9 +182,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementRef"
},
@ -362,9 +359,6 @@
{
"name": "NG_VALUE_ACCESSOR"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -464,9 +458,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
@ -611,9 +602,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "ZONE_IS_STABLE_OBSERVABLE"
},
@ -833,12 +821,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "controlNameBinding"
},
@ -1196,9 +1178,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -1529,9 +1508,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -185,9 +185,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementRef"
},
@ -344,9 +341,6 @@
{
"name": "NG_VALUE_ACCESSOR"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -455,9 +449,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
@ -602,9 +593,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "ZONE_IS_STABLE_OBSERVABLE"
},
@ -806,12 +794,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "controlPath"
},
@ -1157,9 +1139,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -1493,9 +1472,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -83,9 +83,6 @@
{
"name": "ENVIRONMENT_INITIALIZER"
},
{
"name": "EffectManager"
},
{
"name": "ElementRef"
},
@ -191,9 +188,6 @@
{
"name": "NG_TEMPLATE_SELECTOR"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -266,9 +260,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "RefCountOperator"
},
@ -350,9 +341,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "ZONE_IS_STABLE_OBSERVABLE"
},
@ -479,12 +467,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "convertToBitFlags"
},
@ -704,9 +686,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -881,9 +860,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -140,9 +140,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementRef"
},
@ -311,9 +308,6 @@
{
"name": "NODES"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -383,9 +377,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REFERENCE_NODE_BODY"
},
@ -512,9 +503,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "ZONE_IS_STABLE_OBSERVABLE"
},
@ -674,12 +662,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "convertToBitFlags"
},
@ -947,9 +929,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -1181,9 +1160,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -200,9 +200,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementRef"
},
@ -398,9 +395,6 @@
{
"name": "NONE"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -536,9 +530,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
@ -782,9 +773,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "XSS_SECURITY_URL"
},
@ -989,12 +977,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "containsSegmentGroup"
},
@ -1460,9 +1442,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -1802,9 +1781,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -113,9 +113,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementRef"
},
@ -245,9 +242,6 @@
{
"name": "NG_TEMPLATE_SELECTOR"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -311,9 +305,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
@ -410,9 +401,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "ZONE_IS_STABLE_OBSERVABLE"
},
@ -557,12 +545,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "convertToBitFlags"
},
@ -788,9 +770,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -974,9 +953,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -137,9 +137,6 @@
{
"name": "EVENT_MANAGER_PLUGINS"
},
{
"name": "EffectManager"
},
{
"name": "ElementRef"
},
@ -275,9 +272,6 @@
{
"name": "NG_TEMPLATE_SELECTOR"
},
{
"name": "NOOP_CLEANUP_FN"
},
{
"name": "NOT_FOUND"
},
@ -371,9 +365,6 @@
{
"name": "REACTIVE_LVIEW_CONSUMER_NODE"
},
{
"name": "REACTIVE_NODE"
},
{
"name": "REMOVE_STYLES_ON_COMPONENT_DESTROY"
},
@ -527,9 +518,6 @@
{
"name": "ViewRef"
},
{
"name": "WATCH_NODE"
},
{
"name": "ZONE_IS_STABLE_OBSERVABLE"
},
@ -722,12 +710,6 @@
{
"name": "consumerIsLive"
},
{
"name": "consumerMarkDirty"
},
{
"name": "consumerPollProducersForChange"
},
{
"name": "convertToBitFlags"
},
@ -1049,9 +1031,6 @@
{
"name": "importProvidersFrom"
},
{
"name": "inNotificationPhase"
},
{
"name": "includeViewProviders"
},
@ -1319,9 +1298,6 @@
{
"name": "producerRemoveLiveConsumerAtIndex"
},
{
"name": "producerUpdateValueVersion"
},
{
"name": "profiler"
},

View file

@ -107,7 +107,7 @@ describe('NgModule', () => {
const comp = cf.create(Injector.NULL);
return new ComponentFixture(comp, null, false);
return new ComponentFixture(comp, null, null, false);
}
describe('errors', () => {

View file

@ -145,8 +145,8 @@ describe('di', () => {
{}, LViewFlags.CheckAlways, null, null, {
rendererFactory: {} as any,
sanitizer: null,
effectManager: null,
afterRenderEventManager: null
inlineEffectRunner: null,
afterRenderEventManager: null,
},
{} as any, null, null, null);
enterView(contentView);

View file

@ -43,7 +43,7 @@ export function enterViewWithOneDiv() {
const tNode = tView.firstChild = createTNode(tView, null!, TNodeType.Element, 0, 'div', null);
const lView = createLView(
null, tView, null, LViewFlags.CheckAlways, null, null,
{rendererFactory, sanitizer: null, effectManager: null, afterRenderEventManager: null},
{rendererFactory, sanitizer: null, inlineEffectRunner: null, afterRenderEventManager: null},
renderer, null, null, null);
lView[HEADER_OFFSET] = div;
tView.data[HEADER_OFFSET] = tNode;

View file

@ -7,7 +7,7 @@
*/
import {AsyncPipe} from '@angular/common';
import {AfterViewInit, Component, ContentChildren, createComponent, destroyPlatform, effect, EnvironmentInjector, inject, Injector, Input, NgZone, OnChanges, QueryList, signal, SimpleChanges, ViewChild} from '@angular/core';
import {AfterViewInit, Component, ContentChildren, createComponent, createEnvironmentInjector, destroyPlatform, effect, EnvironmentInjector, ErrorHandler, inject, Injector, Input, NgZone, OnChanges, QueryList, signal, SimpleChanges, ViewChild} from '@angular/core';
import {toObservable} from '@angular/core/rxjs-interop';
import {TestBed} from '@angular/core/testing';
import {bootstrapApplication} from '@angular/platform-browser';
@ -17,83 +17,6 @@ describe('effects', () => {
beforeEach(destroyPlatform);
afterEach(destroyPlatform);
it('created in the constructor should run during change detection',
withBody('<test-cmp></test-cmp>', async () => {
const log: string[] = [];
@Component({
selector: 'test-cmp',
standalone: true,
template: '',
})
class Cmp {
constructor() {
log.push('B');
effect(() => {
log.push('E');
});
}
ngDoCheck() {
log.push('C');
}
}
await bootstrapApplication(Cmp);
expect(log).toEqual([
// B: component bootstrapped
'B',
// E: effect runs during change detection
'E',
// C: change detection was observed (first round from `ApplicationRef.tick` called
// manually)
'C',
// C: second change detection happens (from zone becoming stable)
'C',
]);
}));
it('created in ngOnInit should run during change detection',
withBody('<test-cmp></test-cmp>', async () => {
const log: string[] = [];
@Component({
selector: 'test-cmp',
standalone: true,
template: '',
})
class Cmp {
private injector = inject(Injector);
constructor() {
log.push('B');
}
ngOnInit() {
effect(() => {
log.push('E');
}, {injector: this.injector});
}
ngDoCheck() {
log.push('C');
}
}
await bootstrapApplication(Cmp);
expect(log).toEqual([
// B: component bootstrapped
'B',
// ngDoCheck runs before ngOnInit
'C',
// E: effect runs during change detection
'E',
// C: second change detection happens (from zone becoming stable)
'C',
]);
}));
it('should run effects in the zone in which they get created',
withBody('<test-cmp></test-cmp>', async () => {
const log: string[] = [];
@ -121,6 +44,28 @@ describe('effects', () => {
expect(log).not.toEqual(['angular', 'angular']);
}));
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));
effect(() => {
run = true;
throw new Error('fail!');
}, {injector});
expect(() => TestBed.flushEffects()).not.toThrow();
expect(run).toBeTrue();
expect(lastError.message).toBe('fail!');
});
it('should run effect cleanup function on destroy', async () => {
let counterLog: number[] = [];
let cleanupCount = 0;
@ -179,6 +124,11 @@ describe('effects', () => {
const fixture = TestBed.createComponent(Cmp);
fixture.detectChanges();
// Effects don't run during change detection.
expect(didRun).toBeFalse();
TestBed.flushEffects();
expect(didRun).toBeTrue();
});
@ -201,24 +151,13 @@ describe('effects', () => {
await bootstrapApplication(Cmp);
}));
it('should allow writing to signals within effects when option set',
withBody('<test-cmp></test-cmp>', async () => {
@Component({
selector: 'test-cmp',
standalone: true,
template: '',
})
class Cmp {
counter = signal(0);
constructor() {
effect(() => {
expect(() => this.counter.set(1)).not.toThrow();
}, {allowSignalWrites: true});
}
}
it('should allow writing to signals within effects when option set', () => {
const counter = signal(0);
await bootstrapApplication(Cmp);
}));
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({
@ -339,7 +278,7 @@ describe('effects', () => {
expect(fixture.nativeElement.textContent).toBe('1');
});
it('should not execute query setters in the reactive context', () => {
it('should not execute query setters in the reactive context', async () => {
const state = signal('initial');
@Component({
@ -382,14 +321,16 @@ describe('effects', () => {
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)', () => {
it('should allow toObservable subscription in template (with async pipe)', async () => {
@Component({
selector: 'test-cmp',
standalone: true,
@ -403,6 +344,8 @@ describe('effects', () => {
const fixture = TestBed.createComponent(Cmp);
expect(() => fixture.detectChanges(true)).not.toThrow();
fixture.detectChanges();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('0');
});
});

View file

@ -7,7 +7,6 @@
*/
import {Sanitizer, Type, ɵAfterRenderEventManager as AfterRenderEventManager} from '@angular/core';
import {EffectManager} from '@angular/core/src/render3/reactivity/effect';
import {stringifyElement} from '@angular/platform-browser/testing/src/browser_util';
import {extractDirectiveDef} from '../../src/render3/definition';
@ -75,8 +74,8 @@ export class ViewFixture {
null, hostTView, {}, LViewFlags.CheckAlways | LViewFlags.IsRoot, null, null, {
rendererFactory,
sanitizer: sanitizer || null,
effectManager: new EffectManager(),
afterRenderEventManager: new AfterRenderEventManager(),
inlineEffectRunner: null,
},
hostRenderer, null, null, null);

View file

@ -0,0 +1,79 @@
/**
* @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 {Component, effect, inject, Injector} from '@angular/core';
import {TestBed} from '@angular/core/testing';
describe('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');
effect(() => {
log.push('Effect');
});
}
ngDoCheck() {
log.push('DoCheck');
}
}
TestBed.createComponent(Cmp).detectChanges();
expect(log).toEqual([
'Ctor',
'Effect',
'DoCheck',
]);
});
it('created in ngOnInit should not run with detectChanges()', () => {
const log: string[] = [];
@Component({
selector: 'test-cmp',
standalone: true,
template: '',
})
class Cmp {
private injector = inject(Injector);
constructor() {
log.push('Ctor');
}
ngOnInit() {
effect(() => {
log.push('Effect');
}, {injector: this.injector});
}
ngDoCheck() {
log.push('DoCheck');
}
}
TestBed.createComponent(Cmp).detectChanges();
expect(log).toEqual([
// B: component bootstrapped
'Ctor',
// ngDoCheck runs before ngOnInit
'DoCheck',
]);
// effect should not have executed.
});
});

View file

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, NgZone, RendererFactory2} from '@angular/core';
import {ChangeDetectorRef, ComponentRef, DebugElement, ElementRef, getDebugNode, NgZone, RendererFactory2, ɵFlushableEffectRunner as FlushableEffectRunner} from '@angular/core';
import {Subscription} from 'rxjs';
@ -53,7 +53,7 @@ export class ComponentFixture<T> {
constructor(
public componentRef: ComponentRef<T>, public ngZone: NgZone|null,
private _autoDetect: boolean) {
private effectRunner: FlushableEffectRunner|null, private _autoDetect: boolean) {
this.changeDetectorRef = componentRef.changeDetectorRef;
this.elementRef = componentRef.location;
this.debugElement = <DebugElement>getDebugNode(this.elementRef.nativeElement);
@ -121,6 +121,7 @@ export class ComponentFixture<T> {
* Trigger a change detection cycle for the component.
*/
detectChanges(checkNoChanges: boolean = true): void {
this.effectRunner?.flush();
if (this.ngZone != null) {
// 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.

View file

@ -36,7 +36,9 @@ import {
ɵsetAllowDuplicateNgModuleIdsForTest as setAllowDuplicateNgModuleIdsForTest,
ɵsetUnknownElementStrictMode as setUnknownElementStrictMode,
ɵsetUnknownPropertyStrictMode as setUnknownPropertyStrictMode,
ɵstringify as stringify} from '@angular/core';
ɵstringify as stringify,
ɵZoneAwareQueueingScheduler as ZoneAwareQueueingScheduler,
} from '@angular/core';
/* clang-format on */
@ -139,6 +141,14 @@ export interface TestBed {
overrideTemplateUsingTestingModule(component: Type<any>, template: string): TestBed;
createComponent<T>(component: Type<T>): ComponentFixture<T>;
/**
* Execute any pending effects.
*
* @developerPreview
*/
flushEffects(): void;
}
let _nextRootElementId = 0;
@ -363,6 +373,10 @@ export class TestBedImpl implements TestBed {
return TestBedImpl.INSTANCE.ngModule;
}
static flushEffects(): void {
return TestBedImpl.INSTANCE.flushEffects();
}
// Properties
platform: PlatformRef = null!;
@ -613,7 +627,8 @@ export class TestBedImpl implements TestBed {
const initComponent = () => {
const componentRef =
componentFactory.create(Injector.NULL, [], `#${rootElId}`, this.testModuleRef);
return new ComponentFixture<any>(componentRef, ngZone, autoDetect);
return new ComponentFixture<any>(
componentRef, ngZone, this.inject(ZoneAwareQueueingScheduler, null), autoDetect);
};
const fixture = ngZone ? ngZone.run(initComponent) : initComponent();
this._activeFixtures.push(fixture);
@ -749,6 +764,15 @@ export class TestBedImpl implements TestBed {
testRenderer.removeAllRootElements?.();
}
}
/**
* Execute any pending effects.
*
* @developerPreview
*/
flushEffects(): void {
this.inject(ZoneAwareQueueingScheduler).flush();
}
}
/**

View file

@ -7,7 +7,7 @@
*/
import {PlatformLocation} from '@angular/common';
import {MockPlatformLocation} from '@angular/common/testing';
import {APP_ID, createPlatformFactory, NgModule, PLATFORM_INITIALIZER, platformCore, provideZoneChangeDetection, StaticProvider} from '@angular/core';
import {APP_ID, createPlatformFactory, NgModule, PLATFORM_INITIALIZER, platformCore, provideZoneChangeDetection, StaticProvider, ɵEffectScheduler as EffectScheduler, ɵZoneAwareQueueingScheduler as ZoneAwareQueueingScheduler} from '@angular/core';
import {BrowserModule, ɵBrowserDomAdapter as BrowserDomAdapter} from '@angular/platform-browser';
function initBrowserTests() {
@ -36,6 +36,8 @@ export const platformBrowserTesting =
{provide: APP_ID, useValue: 'a'},
provideZoneChangeDetection(),
{provide: PlatformLocation, useClass: MockPlatformLocation},
{provide: ZoneAwareQueueingScheduler},
{provide: EffectScheduler, useExisting: ZoneAwareQueueingScheduler},
]
})
export class BrowserTestingModule {