Skip to content

Commit

Permalink
feat(core): rework effect scheduling
Browse files Browse the repository at this point in the history
The original effect design for Angular had one "bucket" of effects, which
are scheduled on the microtask queue. This approach got us pretty far, but
as developers have built more complex reactive systems, we've hit the
limitations of this design.

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

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

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

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

Fixes angular#55311
Fixes angular#55808
Fixes angular#55644
Fixes angular#56863
  • Loading branch information
alxhub committed Jul 17, 2024
1 parent cfb7bfa commit d052c86
Show file tree
Hide file tree
Showing 30 changed files with 1,128 additions and 705 deletions.
54 changes: 54 additions & 0 deletions adev/src/content/guide/signals/effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Effects

Signals track values and their changes, but this isn't very useful without the ability to react to those values changing. When signals are used in a template, for example, Angular reacts when they change by updating the UI, but sometimes you'll want to react to signals changing in other ways: logging values, updating a cache, sending network requests, etc. For these cases, you can create _effects_.

Effects are created with the `effect()` API, which accepts a function that performs an action which depends on the value of one or more signals. Angular will execute that action, and re-execute it whenever its signal dependencies have new values.

A common example is an effect that logs the value of a signal:

```ts
effect(() => console.log(currentUser()));
```

As a slightly more advanced example, you can use an effect to keep the value of a signal in sync with `localStorage`:

```ts
const CACHE_KEY = 'name';
@Injectable({providedIn: 'root'})
export class NameService {
readonly name: WritableSignal<number>;

constructor() {
// Read the initial name from local storage:
this.name = signal(window.localStorage.getItem(CACHE_KEY) ?? '');

// Create an effect to update local storage every time the name changes.
effect(() => {
window.localStorage.setItem(CACHE_KEY, this.name());
});
}
}
```

<docs-callout helpful title="Effect Style">
Both of these effects have something in common: the reaction function is written to perform an action that uses the value of one or more signals. Because Angular tracks which signals are used as part of that action, it knows to only re-run the action _if it could produce a different outcome_.
</docs-callout>

## When do effects run?

Effects are never synchronous - they don't run immediately when one of their signal dependencies changes. Instead, their executions are scheduled by the framework to run at an optimal time in the future. Ideally, you should not need to consider exactly when your effects will run, and leave this up to Angular.

Generally, there are two different kinds of effects, which have different timings:

- Component effects, which are associated with a specific component or location in the UI.
- Root effects, which aren't tied to any specific component.

Where you call `effect()` determines which kind of effect is created. When used within a component, directive, or a service provided within a component or directive, `effect()` creates a component effect associated with that part of the UI. When called outside of that context (for example, from a service created by the application injector), `effect()` creates a root effect.

When a component effect is dirty, it runs

## Testing of effects

## Advanced Topics

### Why are effects not synchronous?
2 changes: 2 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,9 @@ export interface CreateComputedOptions<T> {

// @public
export interface CreateEffectOptions {
// @deprecated (undocumented)
allowSignalWrites?: boolean;
forceRoot?: true;
injector?: Injector;
manualCleanup?: boolean;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/application/application_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {isPromise} from '../util/lang';
import {NgZone} from '../zone/ng_zone';

import {ApplicationInitStatus} from './application_init';
import {EffectScheduler} from '../render3/reactivity/effect';

/**
* A DI token that provides a set of callbacks to
Expand Down Expand Up @@ -305,6 +306,7 @@ export class ApplicationRef {
private readonly internalErrorHandler = inject(INTERNAL_APPLICATION_ERROR_HANDLER);
private readonly afterRenderEffectManager = inject(AfterRenderEventManager);
private readonly zonelessEnabled = inject(ZONELESS_ENABLED);
private readonly rootEffectScheduler = inject(EffectScheduler);

/** @internal */
dirtyFlags = ApplicationRefDirtyFlags.None;
Expand Down Expand Up @@ -583,6 +585,11 @@ export class ApplicationRef {
// Some notifications to run a `tick` will only trigger render hooks. so we can potentially
// skip refreshing views.
while (this.dirtyFlags !== ApplicationRefDirtyFlags.None && runs < MAXIMUM_REFRESH_RERUNS) {
if (this.dirtyFlags & ApplicationRefDirtyFlags.RootEffects) {
this.dirtyFlags &= ~ApplicationRefDirtyFlags.RootEffects;
this.rootEffectScheduler.flush();
}

if (this.dirtyFlags & ApplicationRefDirtyFlags.ViewTreeAny) {
const useGlobalCheck = Boolean(ApplicationRefDirtyFlags.ViewTreeGlobal);
// Remove the ViewTree bits.
Expand Down Expand Up @@ -654,6 +661,8 @@ export class ApplicationRef {
'that afterRender hooks always mark views for check.',
);
}

this.dirtyFlags = ApplicationRefDirtyFlags.None;
}

/**
Expand Down Expand Up @@ -805,6 +814,11 @@ export const enum ApplicationRefDirtyFlags {
* After render hooks need to run.
*/
AfterRender = 0b00001000,

/**
* Effects at the `ApplicationRef` level.
*/
RootEffects = 0b00010000,
}

let whenStableStore: WeakMap<ApplicationRef, Promise<void>> | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export const enum NotificationSource {
ViewDetachedFromDOM,
// Applying animations might result in new DOM state and should rerun render hooks
AsyncAnimationsLoaded,

// An `effect()` outside of the view tree became dirty and might need to run.
RootEffect,
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
this.appRef.dirtyFlags |= ApplicationRefDirtyFlags.ViewTreeCheck;
break;
}
case NotificationSource.RootEffect: {
this.appRef.dirtyFlags |= ApplicationRefDirtyFlags.RootEffects;
break;
}
case NotificationSource.ViewDetachedFromDOM:
case NotificationSource.ViewAttached:
case NotificationSource.NewRenderHook:
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/linker/destroy_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ export abstract class DestroyRef {
static __NG_ENV_ID__: (injector: EnvironmentInjector) => DestroyRef = (injector) => injector;
}

class NodeInjectorDestroyRef extends DestroyRef {
constructor(private _lView: LView) {
export class NodeInjectorDestroyRef extends DestroyRef {
constructor(readonly _lView: LView) {
super();
}

Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/render3/component_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,6 @@ export class ComponentFactory<T> extends AbstractComponentFactory<T> {
const environment: LViewEnvironment = {
rendererFactory,
sanitizer,
// We don't use inline effects (yet).
inlineEffectRunner: null,
afterRenderEventManager,
changeDetectionScheduler,
};
Expand Down
9 changes: 3 additions & 6 deletions packages/core/src/render3/instructions/change_detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
processHostBindingOpCodes,
refreshContentQueries,
} from './shared';
import {runEffectsInView} from '../reactivity/effect';

/**
* The maximum number of times the change detection traversal will rerun before throwing an error.
Expand Down Expand Up @@ -101,10 +102,6 @@ export function detectChangesInternal(
} finally {
if (!checkNoChangesMode) {
rendererFactory.end?.();

// One final flush of the effects queue to catch any effects created in `ngAfterViewInit` or
// other post-order hooks.
environment.inlineEffectRunner?.flush();
}
}
}
Expand Down Expand Up @@ -204,8 +201,6 @@ export function refreshView<T>(
const isInCheckNoChangesPass = ngDevMode && isInCheckNoChangesMode();
const isInExhaustiveCheckNoChangesPass = ngDevMode && isExhaustiveCheckNoChanges();

!isInCheckNoChangesPass && lView[ENVIRONMENT].inlineEffectRunner?.flush();

// Start component reactive context
// - We might already be in a reactive context if this is an embedded view of the host.
// - We might be descending into a view that needs a consumer.
Expand Down Expand Up @@ -269,6 +264,7 @@ export function refreshView<T>(
// `LView` but its declaration appears after the insertion component.
markTransplantedViewsForRefresh(lView);
}
runEffectsInView(lView);
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Global);

// Content query results must be refreshed before content hooks are called.
Expand Down Expand Up @@ -496,6 +492,7 @@ function detectChangesInView(lView: LView, mode: ChangeDetectionMode) {
if (shouldRefreshView) {
refreshView(tView, lView, tView.template, lView[CONTEXT]);
} else if (flags & LViewFlags.HasChildViewsToRefresh) {
runEffectsInView(lView);
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
const components = tView.components;
if (components !== null) {
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/render3/interfaces/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {SchemaMetadata} from '../../metadata/schema';
import {Sanitizer} from '../../sanitization/sanitizer';
import type {AfterRenderEventManager} from '../after_render_hooks';
import type {ReactiveLViewConsumer} from '../reactive_lview_consumer';
import type {EffectScheduler} from '../reactivity/effect';
import type {ViewEffectNode} from '../reactivity/effect';

import {LContainer} from './container';
import {
Expand Down Expand Up @@ -66,7 +66,8 @@ export const ID = 19;
export const EMBEDDED_VIEW_INJECTOR = 20;
export const ON_DESTROY_HOOKS = 21;
export const EFFECTS_TO_SCHEDULE = 22;
export const REACTIVE_TEMPLATE_CONSUMER = 23;
export const EFFECTS = 23;
export const REACTIVE_TEMPLATE_CONSUMER = 24;

/**
* Size of LView's header. Necessary to adjust for it when setting slots.
Expand Down Expand Up @@ -346,6 +347,8 @@ export interface LView<T = unknown> extends Array<any> {
*/
[EFFECTS_TO_SCHEDULE]: Array<() => void> | null;

[EFFECTS]: Set<ViewEffectNode> | null;

/**
* A collection of callbacks functions that are executed when a given LView is destroyed. Those
* are user defined, LView-specific destroy callbacks that don't have any corresponding TView
Expand All @@ -372,9 +375,6 @@ export interface LViewEnvironment {
/** An optional custom sanitizer. */
sanitizer: Sanitizer | null;

/** Container for reactivity system `effect`s. */
inlineEffectRunner: EffectScheduler | null;

/** Container for after render hooks */
afterRenderEventManager: AfterRenderEventManager | null;

Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/render3/node_manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
DECLARATION_COMPONENT_VIEW,
DECLARATION_LCONTAINER,
DestroyHookData,
EFFECTS,
ENVIRONMENT,
FLAGS,
HookData,
Expand Down Expand Up @@ -91,6 +92,7 @@ import {
unwrapRNode,
updateAncestorTraversalFlagsOnAttach,
} from './util/view_utils';
import {EMPTY_ARRAY} from '../util/empty';

const enum WalkTNodeTreeAction {
/** node create in the native environment. Run on initial creation. */
Expand Down Expand Up @@ -553,6 +555,15 @@ function processCleanups(tView: TView, lView: LView): void {
destroyHooksFn();
}
}

// Destroy effects registered to the view. Many of these will have been processed above.
const effects = lView[EFFECTS];
if (effects !== null) {
lView[EFFECTS] = null;
for (const effect of effects) {
effect.destroy();
}
}
}

/** Calls onDestroy hooks for this view */
Expand Down
Loading

0 comments on commit d052c86

Please sign in to comment.