Skip to content

Commit 4e890cc

Browse files
alxhubatscott
authored andcommitted
refactor(core): add support for new effect scheduling. (angular#56501)
The original effect design for Angular had one "bucket" of effects, which are scheduled on the microtask queue. This approach got us pretty far, but as developers have built more complex reactive systems, we've hit the limitations of this design. This commit changes the nature of effects significantly. In particular, effects created in components have a completely new scheduling system, which executes them as a part of the change detection cycle. This results in behavior similar to that of nested effects in other reactive frameworks. The scheduling behavior here uses the "mark for traversal" flag (`HasChildViewsToRefresh`). This has really nice behavior: * if the component is dirty already, effects run following preorder hooks (ngOnInit, etc). * if the component isn't dirty, it doesn't get change detected only because of the dirty effect. This is not a breaking change, since `effect()` is in developer preview (and it remains so). As a part of this redesigned `effect()` behavior, the `allowSignalWrites` flag was removed. Effects no longer prohibit writing to signals at all. This decision was taken in response to feedback / observations of usage patterns, which showed the benefit of the restriction did not justify the DX cost. The new effect timing is not yet enabled - a future PR will flip the flag. PR Close angular#56501
1 parent c6039b5 commit 4e890cc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2457
-745
lines changed

goldens/public-api/core/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,9 @@ export interface CreateComputedOptions<T> {
447447

448448
// @public
449449
export interface CreateEffectOptions {
450+
// @deprecated (undocumented)
450451
allowSignalWrites?: boolean;
452+
forceRoot?: true;
451453
injector?: Injector;
452454
manualCleanup?: boolean;
453455
}

goldens/size-tracking/integration-payloads.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
},
4242
"standalone-bootstrap": {
4343
"uncompressed": {
44-
"main": 89354,
44+
"main": 94769,
4545
"polyfills": 33802
4646
}
4747
},

packages/core/rxjs-interop/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,9 @@
99
export {outputFromObservable} from './output_from_observable';
1010
export {outputToObservable} from './output_to_observable';
1111
export {takeUntilDestroyed} from './take_until_destroyed';
12-
export {toObservable, ToObservableOptions} from './to_observable';
12+
export {
13+
toObservable,
14+
ToObservableOptions,
15+
toObservableMicrotask as ɵtoObservableMicrotask,
16+
} from './to_observable';
1317
export {toSignal, ToSignalOptions} from './to_signal';

packages/core/rxjs-interop/src/to_observable.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Injector,
1515
Signal,
1616
untracked,
17+
ɵmicrotaskEffect as microtaskEffect,
1718
} from '@angular/core';
1819
import {Observable, ReplaySubject} from 'rxjs';
1920

@@ -67,3 +68,33 @@ export function toObservable<T>(source: Signal<T>, options?: ToObservableOptions
6768

6869
return subject.asObservable();
6970
}
71+
72+
export function toObservableMicrotask<T>(
73+
source: Signal<T>,
74+
options?: ToObservableOptions,
75+
): Observable<T> {
76+
!options?.injector && assertInInjectionContext(toObservable);
77+
const injector = options?.injector ?? inject(Injector);
78+
const subject = new ReplaySubject<T>(1);
79+
80+
const watcher = microtaskEffect(
81+
() => {
82+
let value: T;
83+
try {
84+
value = source();
85+
} catch (err) {
86+
untracked(() => subject.error(err));
87+
return;
88+
}
89+
untracked(() => subject.next(value));
90+
},
91+
{injector, manualCleanup: true},
92+
);
93+
94+
injector.get(DestroyRef).onDestroy(() => {
95+
watcher.destroy();
96+
subject.complete();
97+
});
98+
99+
return subject.asObservable();
100+
}

packages/core/src/application/application_ref.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {isPromise} from '../util/lang';
4444
import {NgZone} from '../zone/ng_zone';
4545

4646
import {ApplicationInitStatus} from './application_init';
47+
import {EffectScheduler} from '../render3/reactivity/root_effect_scheduler';
4748

4849
/**
4950
* A DI token that provides a set of callbacks to
@@ -310,6 +311,7 @@ export class ApplicationRef {
310311
private readonly internalErrorHandler = inject(INTERNAL_APPLICATION_ERROR_HANDLER);
311312
private readonly afterRenderManager = inject(AfterRenderManager);
312313
private readonly zonelessEnabled = inject(ZONELESS_ENABLED);
314+
private readonly rootEffectScheduler = inject(EffectScheduler);
313315

314316
/**
315317
* Current dirty state of the application across a number of dimensions (views, afterRender hooks,
@@ -647,6 +649,12 @@ export class ApplicationRef {
647649
this.dirtyFlags |= this.deferredDirtyFlags;
648650
this.deferredDirtyFlags = ApplicationRefDirtyFlags.None;
649651

652+
// First, process any dirty root effects.
653+
if (this.dirtyFlags & ApplicationRefDirtyFlags.RootEffects) {
654+
this.dirtyFlags &= ~ApplicationRefDirtyFlags.RootEffects;
655+
this.rootEffectScheduler.flush();
656+
}
657+
650658
// First check dirty views, if there are any.
651659
if (this.dirtyFlags & ApplicationRefDirtyFlags.ViewTreeAny) {
652660
// Change detection on views starts in targeted mode (only check components if they're
@@ -677,8 +685,12 @@ export class ApplicationRef {
677685

678686
// Check if any views are still dirty after checking and we need to loop back.
679687
this.syncDirtyFlagsWithViews();
680-
if (this.dirtyFlags & ApplicationRefDirtyFlags.ViewTreeAny) {
681-
// If any views are still dirty after checking, loop back before running render hooks.
688+
if (
689+
this.dirtyFlags &
690+
(ApplicationRefDirtyFlags.ViewTreeAny | ApplicationRefDirtyFlags.RootEffects)
691+
) {
692+
// If any views or effects are still dirty after checking, loop back before running render
693+
// hooks.
682694
return;
683695
}
684696
} else {
@@ -873,6 +885,11 @@ export const enum ApplicationRefDirtyFlags {
873885
* After render hooks need to run.
874886
*/
875887
AfterRender = 0b00001000,
888+
889+
/**
890+
* Effects at the `ApplicationRef` level.
891+
*/
892+
RootEffects = 0b00010000,
876893
}
877894

878895
let whenStableStore: WeakMap<ApplicationRef, Promise<void>> | undefined;

packages/core/src/change_detection/scheduling/zoneless_scheduling.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export const enum NotificationSource {
5050
// The scheduler is notified when a pending task is removed via the public API.
5151
// This allows us to make stability async, delayed until the next application tick.
5252
PendingTaskRemoved,
53+
// An `effect()` outside of the view tree became dirty and might need to run.
54+
RootEffect,
5355
}
5456

5557
/**

packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
153153
force = true;
154154
break;
155155
}
156+
case NotificationSource.RootEffect: {
157+
this.appRef.dirtyFlags |= ApplicationRefDirtyFlags.RootEffects;
158+
break;
159+
}
156160
case NotificationSource.PendingTaskRemoved: {
157161
// Removing a pending task via the public API forces a scheduled tick, ensuring that
158162
// stability is async and delayed until there was at least an opportunity to run

packages/core/src/core_reactivity_export_internal.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ export {
2121
EffectRef,
2222
EffectCleanupFn,
2323
EffectCleanupRegisterFn,
24-
EffectScheduler as ɵEffectScheduler,
2524
} from './render3/reactivity/effect';
25+
export {
26+
MicrotaskEffectScheduler as ɵMicrotaskEffectScheduler,
27+
microtaskEffect as ɵmicrotaskEffect,
28+
} from './render3/reactivity/microtask_effect';
29+
export {EffectScheduler as ɵEffectScheduler} from './render3/reactivity/root_effect_scheduler';
2630
export {afterRenderEffect, ɵFirstAvailableSignal} from './render3/reactivity/after_render_effect';
2731
export {assertNotInReactiveContext} from './render3/reactivity/asserts';

packages/core/src/linker/destroy_ref.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ export abstract class DestroyRef {
5656
static __NG_ENV_ID__: (injector: EnvironmentInjector) => DestroyRef = (injector) => injector;
5757
}
5858

59-
class NodeInjectorDestroyRef extends DestroyRef {
60-
constructor(private _lView: LView) {
59+
export class NodeInjectorDestroyRef extends DestroyRef {
60+
constructor(readonly _lView: LView) {
6161
super();
6262
}
6363

packages/core/src/render3/component_ref.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,6 @@ export class ComponentFactory<T> extends AbstractComponentFactory<T> {
266266
const environment: LViewEnvironment = {
267267
rendererFactory,
268268
sanitizer,
269-
// We don't use inline effects (yet).
270-
inlineEffectRunner: null,
271269
changeDetectionScheduler,
272270
};
273271

0 commit comments

Comments
 (0)