From 38c9f08c8d7d2d340ffb875b26dadc03db9bf284 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Fri, 14 Jul 2023 11:02:51 -0700 Subject: [PATCH] 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 --- goldens/public-api/core/testing/index.md | 4 +- .../src/core_reactivity_export_internal.ts | 5 +- packages/core/src/render3/component_ref.ts | 7 +- .../render3/instructions/change_detection.ts | 4 +- packages/core/src/render3/interfaces/view.ts | 4 +- .../core/src/render3/reactivity/effect.ts | 234 ++++++++++++++---- packages/core/src/signals/index.ts | 4 +- packages/core/src/signals/src/graph.ts | 4 + .../bundle.golden_symbols.json | 24 -- .../animations/bundle.golden_symbols.json | 24 -- .../cyclic_import/bundle.golden_symbols.json | 24 -- .../forms_reactive/bundle.golden_symbols.json | 24 -- .../bundle.golden_symbols.json | 24 -- .../hello_world/bundle.golden_symbols.json | 24 -- .../hydration/bundle.golden_symbols.json | 24 -- .../router/bundle.golden_symbols.json | 24 -- .../bundle.golden_symbols.json | 24 -- .../bundling/todo/bundle.golden_symbols.json | 24 -- .../test/linker/ng_module_integration_spec.ts | 2 +- packages/core/test/render3/di_spec.ts | 4 +- .../test/render3/instructions/shared_spec.ts | 2 +- packages/core/test/render3/reactivity_spec.ts | 137 +++------- packages/core/test/render3/view_fixture.ts | 3 +- packages/core/test/test_bed_effect_spec.ts | 79 ++++++ .../core/testing/src/component_fixture.ts | 5 +- packages/core/testing/src/test_bed.ts | 28 ++- .../platform-browser/testing/src/browser.ts | 4 +- 27 files changed, 363 insertions(+), 407 deletions(-) create mode 100644 packages/core/test/test_bed_effect_spec.ts diff --git a/goldens/public-api/core/testing/index.md b/goldens/public-api/core/testing/index.md index 738b93b50c7..93c81a99fb2 100644 --- a/goldens/public-api/core/testing/index.md +++ b/goldens/public-api/core/testing/index.md @@ -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 { - constructor(componentRef: ComponentRef, ngZone: NgZone | null, _autoDetect: boolean); + constructor(componentRef: ComponentRef, ngZone: NgZone | null, effectRunner: ɵFlushableEffectRunner | null, _autoDetect: boolean); autoDetectChanges(autoDetect?: boolean): void; changeDetectorRef: ChangeDetectorRef; checkNoChanges(): void; @@ -110,6 +111,7 @@ export interface TestBed { createComponent(component: Type): ComponentFixture; // (undocumented) execute(tokens: any[], fn: Function, context?: any): any; + flushEffects(): void; // @deprecated (undocumented) get(token: ProviderToken, notFoundValue?: T, flags?: InjectFlags): any; // @deprecated (undocumented) diff --git a/packages/core/src/core_reactivity_export_internal.ts b/packages/core/src/core_reactivity_export_internal.ts index 8fb3d427663..3babac351f6 100644 --- a/packages/core/src/core_reactivity_export_internal.ts +++ b/packages/core/src/core_reactivity_export_internal.ts @@ -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 diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 560bbeedda6..05a678a59d6 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -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 extends AbstractComponentFactory { } 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, }; diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index c3235c291bd..35d88d5eb92 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -48,7 +48,7 @@ export function detectChangesInternal( // 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( // 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 { diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 93d67c94775..f1b678d4600 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -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; diff --git a/packages/core/src/render3/reactivity/effect.ts b/packages/core/src/render3/reactivity/effect.ts index f1fae1260b3..3559b8b6a96 100644 --- a/packages/core/src/render3/reactivity/effect.ts +++ b/packages/core/src/render3/reactivity/effect.ts @@ -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(); - private queue = new Map(); +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>(); - 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): 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; } diff --git a/packages/core/src/signals/index.ts b/packages/core/src/signals/index.ts index 3ef2a4450be..9beebf16ebf 100644 --- a/packages/core/src/signals/index.ts +++ b/packages/core/src/signals/index.ts @@ -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'; diff --git a/packages/core/src/signals/src/graph.ts b/packages/core/src/signals/src/graph.ts index c59f417a274..a4e4c8bd42f 100644 --- a/packages/core/src/signals/src/graph.ts +++ b/packages/core/src/signals/src/graph.ts @@ -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, diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 0b665de8858..80d12ea1443 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index fb0c547315c..0ea34688018 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 90acf781998..b26d04e5c12 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index e577db5b681..c80c085e1cd 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index cd07111a530..90c199a55ed 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 88f680b6415..d352f6aadca 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 8da0717556f..15c78e1ed93 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 08ce73ba560..8127bc70623 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 7a297193d25..94c4cab3446 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 41858b888ff..5d513582574 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -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" }, diff --git a/packages/core/test/linker/ng_module_integration_spec.ts b/packages/core/test/linker/ng_module_integration_spec.ts index 589db8a7401..1b9ea396a98 100644 --- a/packages/core/test/linker/ng_module_integration_spec.ts +++ b/packages/core/test/linker/ng_module_integration_spec.ts @@ -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', () => { diff --git a/packages/core/test/render3/di_spec.ts b/packages/core/test/render3/di_spec.ts index b8d6e8442b8..4c517239d7d 100644 --- a/packages/core/test/render3/di_spec.ts +++ b/packages/core/test/render3/di_spec.ts @@ -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); diff --git a/packages/core/test/render3/instructions/shared_spec.ts b/packages/core/test/render3/instructions/shared_spec.ts index 0c37ebce81b..5013f6405c0 100644 --- a/packages/core/test/render3/instructions/shared_spec.ts +++ b/packages/core/test/render3/instructions/shared_spec.ts @@ -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; diff --git a/packages/core/test/render3/reactivity_spec.ts b/packages/core/test/render3/reactivity_spec.ts index 825c891d027..f53588aa962 100644 --- a/packages/core/test/render3/reactivity_spec.ts +++ b/packages/core/test/render3/reactivity_spec.ts @@ -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('', 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('', 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('', 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('', 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'); }); }); diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts index de88a431714..668398cc063 100644 --- a/packages/core/test/render3/view_fixture.ts +++ b/packages/core/test/render3/view_fixture.ts @@ -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); diff --git a/packages/core/test/test_bed_effect_spec.ts b/packages/core/test/test_bed_effect_spec.ts new file mode 100644 index 00000000000..efb79909af9 --- /dev/null +++ b/packages/core/test/test_bed_effect_spec.ts @@ -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. + }); +}); diff --git a/packages/core/testing/src/component_fixture.ts b/packages/core/testing/src/component_fixture.ts index d7edafe8ee5..c58a59bd911 100644 --- a/packages/core/testing/src/component_fixture.ts +++ b/packages/core/testing/src/component_fixture.ts @@ -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 { constructor( public componentRef: ComponentRef, public ngZone: NgZone|null, - private _autoDetect: boolean) { + private effectRunner: FlushableEffectRunner|null, private _autoDetect: boolean) { this.changeDetectorRef = componentRef.changeDetectorRef; this.elementRef = componentRef.location; this.debugElement = getDebugNode(this.elementRef.nativeElement); @@ -121,6 +121,7 @@ export class ComponentFixture { * 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. diff --git a/packages/core/testing/src/test_bed.ts b/packages/core/testing/src/test_bed.ts index 40f392c49e8..b05bcc2e075 100644 --- a/packages/core/testing/src/test_bed.ts +++ b/packages/core/testing/src/test_bed.ts @@ -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, template: string): TestBed; createComponent(component: Type): ComponentFixture; + + + /** + * 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(componentRef, ngZone, autoDetect); + return new ComponentFixture( + 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(); + } } /** diff --git a/packages/platform-browser/testing/src/browser.ts b/packages/platform-browser/testing/src/browser.ts index f086081aa9b..81cf17d74cf 100644 --- a/packages/platform-browser/testing/src/browser.ts +++ b/packages/platform-browser/testing/src/browser.ts @@ -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 {