Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion goldens/public-api/core/testing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = "";
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/core_reactivity_export_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 3 additions & 4 deletions packages/core/src/render3/component_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/render3/instructions/change_detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/render3/interfaces/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
234 changes: 187 additions & 47 deletions packages/core/src/render3/reactivity/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>();

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

this.queue.set(watch, zone);
}, allowSignalWrites);
export const APP_EFFECT_SCHEDULER = new InjectionToken('', {
providedIn: 'root',
factory: () => inject(EffectScheduler),
});

this.all.add(w);

// Effects start dirty.
w.notify();
/**
* 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;

let unregisterOnDestroy: (() => void)|undefined;
/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
token: EffectScheduler,
providedIn: 'root',
factory: () => new ZoneAwareMicrotaskScheduler(),
});
}

const destroy = () => {
w.cleanup();
unregisterOnDestroy?.();
this.all.delete(w);
this.queue.delete(w);
};
/**
* Interface to an `EffectScheduler` capable of running scheduled effects synchronously.
*/
export interface FlushableEffectRunner {
/**
* Run any scheduled effects.
*/
flush(): void;
}

unregisterOnDestroy = destroyRef?.onDestroy(destroy);
/**
* 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>>();

return {
destroy,
};
}
scheduleEffect(handle: SchedulableEffect): void {
const zone = handle.creationZone as Zone | null;
if (!this.queues.has(zone)) {
this.queues.set(zone, new Set());
}

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?
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
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;
alxhub marked this conversation as resolved.
Show resolved Hide resolved

// 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.`);
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
}

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.
*
Expand Down Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions packages/core/src/signals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 4 additions & 0 deletions packages/core/src/signals/src/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down