@angular/core
Version:
Angular - the core framework
627 lines (618 loc) • 24.2 kB
JavaScript
/**
* @license Angular v20.0.3
* (c) 2010-2025 Google LLC. https://angular.io/
* License: MIT
*/
import { inject, ErrorHandler, DestroyRef, RuntimeError, formatRuntimeError, assertNotInReactiveContext, assertInInjectionContext, Injector, ViewContext, ChangeDetectionScheduler, EffectScheduler, setInjectorProfilerContext, emitEffectCreatedEvent, EFFECTS, NodeInjectorDestroyRef, FLAGS, markAncestorsForTraversal, noop, setIsRefreshingViews, signalAsReadonlyFn, PendingTasks, signal } from './root_effect_scheduler-DCy1y1b8.mjs';
import { setActiveConsumer, createComputed, SIGNAL, consumerDestroy, REACTIVE_NODE, isInNotificationPhase, consumerPollProducersForChange, consumerBeforeComputation, consumerAfterComputation } from './signal-nCiHhWf6.mjs';
import { untracked as untracked$1, createLinkedSignal, linkedSignalSetFn, linkedSignalUpdateFn } from './untracked-DmD_2MlC.mjs';
/**
* An `OutputEmitterRef` is created by the `output()` function and can be
* used to emit values to consumers of your directive or component.
*
* Consumers of your directive/component can bind to the output and
* subscribe to changes via the bound event syntax. For example:
*
* ```html
* <my-comp (valueChange)="processNewValue($event)" />
* ```
*
* @publicAPI
*/
class OutputEmitterRef {
destroyed = false;
listeners = null;
errorHandler = inject(ErrorHandler, { optional: true });
/** @internal */
destroyRef = inject(DestroyRef);
constructor() {
// Clean-up all listeners and mark as destroyed upon destroy.
this.destroyRef.onDestroy(() => {
this.destroyed = true;
this.listeners = null;
});
}
subscribe(callback) {
if (this.destroyed) {
throw new RuntimeError(953 /* RuntimeErrorCode.OUTPUT_REF_DESTROYED */, ngDevMode &&
'Unexpected subscription to destroyed `OutputRef`. ' +
'The owning directive/component is destroyed.');
}
(this.listeners ??= []).push(callback);
return {
unsubscribe: () => {
const idx = this.listeners?.indexOf(callback);
if (idx !== undefined && idx !== -1) {
this.listeners?.splice(idx, 1);
}
},
};
}
/** Emits a new value to the output. */
emit(value) {
if (this.destroyed) {
console.warn(formatRuntimeError(953 /* RuntimeErrorCode.OUTPUT_REF_DESTROYED */, ngDevMode &&
'Unexpected emit for destroyed `OutputRef`. ' +
'The owning directive/component is destroyed.'));
return;
}
if (this.listeners === null) {
return;
}
const previousConsumer = setActiveConsumer(null);
try {
for (const listenerFn of this.listeners) {
try {
listenerFn(value);
}
catch (err) {
this.errorHandler?.handleError(err);
}
}
}
finally {
setActiveConsumer(previousConsumer);
}
}
}
/** Gets the owning `DestroyRef` for the given output. */
function getOutputDestroyRef(ref) {
return ref.destroyRef;
}
/**
* Execute an arbitrary function in a non-reactive (non-tracking) context. The executed function
* can, optionally, return a value.
*/
function untracked(nonReactiveReadsFn) {
return untracked$1(nonReactiveReadsFn);
}
/**
* Create a computed `Signal` which derives a reactive value from an expression.
*/
function computed(computation, options) {
const getter = createComputed(computation, options?.equal);
if (ngDevMode) {
getter.toString = () => `[Computed: ${getter()}]`;
getter[SIGNAL].debugName = options?.debugName;
}
return getter;
}
class EffectRefImpl {
[SIGNAL];
constructor(node) {
this[SIGNAL] = node;
}
destroy() {
this[SIGNAL].destroy();
}
}
/**
* Registers an "effect" that will be scheduled & executed whenever the signals that it reads
* changes.
*
* Angular has two different kinds of effect: component effects and root effects. Component effects
* are created when `effect()` is called from a component, directive, or within a service of a
* component/directive. Root effects are created when `effect()` is called from outside the
* component tree, such as in a root service.
*
* The two effect types differ in their timing. Component effects run as a component lifecycle
* event during Angular's synchronization (change detection) process, and can safely read input
* signals or create/destroy views that depend on component state. Root effects run as microtasks
* and have no connection to the component tree or change detection.
*
* `effect()` must be run in injection context, unless the `injector` option is manually specified.
*
* @publicApi 20.0
*/
function effect(effectFn, options) {
ngDevMode &&
assertNotInReactiveContext(effect, 'Call `effect` outside of a reactive context. For example, schedule the ' +
'effect inside the component constructor.');
if (ngDevMode && !options?.injector) {
assertInInjectionContext(effect);
}
if (ngDevMode && options?.allowSignalWrites !== undefined) {
console.warn(`The 'allowSignalWrites' flag is deprecated and no longer impacts effect() (writes are always allowed)`);
}
const injector = options?.injector ?? inject(Injector);
let destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef) : null;
let node;
const viewContext = injector.get(ViewContext, null, { optional: true });
const notifier = injector.get(ChangeDetectionScheduler);
if (viewContext !== null) {
// This effect was created in the context of a view, and will be associated with the view.
node = createViewEffect(viewContext.view, notifier, effectFn);
if (destroyRef instanceof NodeInjectorDestroyRef && destroyRef._lView === viewContext.view) {
// The effect is being created in the same view as the `DestroyRef` references, so it will be
// automatically destroyed without the need for an explicit `DestroyRef` registration.
destroyRef = null;
}
}
else {
// This effect was created outside the context of a view, and will be scheduled independently.
node = createRootEffect(effectFn, injector.get(EffectScheduler), notifier);
}
node.injector = injector;
if (destroyRef !== null) {
// If we need to register for cleanup, do that here.
node.onDestroyFn = destroyRef.onDestroy(() => node.destroy());
}
const effectRef = new EffectRefImpl(node);
if (ngDevMode) {
node.debugName = options?.debugName ?? '';
const prevInjectorProfilerContext = setInjectorProfilerContext({ injector, token: null });
try {
emitEffectCreatedEvent(effectRef);
}
finally {
setInjectorProfilerContext(prevInjectorProfilerContext);
}
}
return effectRef;
}
const BASE_EFFECT_NODE =
/* @__PURE__ */ (() => ({
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
consumerAllowSignalWrites: true,
dirty: true,
hasRun: false,
cleanupFns: undefined,
zone: null,
kind: 'effect',
onDestroyFn: noop,
run() {
this.dirty = false;
if (ngDevMode && isInNotificationPhase()) {
throw new Error(`Schedulers cannot synchronously execute watches while scheduling.`);
}
if (this.hasRun && !consumerPollProducersForChange(this)) {
return;
}
this.hasRun = true;
const registerCleanupFn = (cleanupFn) => (this.cleanupFns ??= []).push(cleanupFn);
const prevNode = consumerBeforeComputation(this);
// We clear `setIsRefreshingViews` so that `markForCheck()` within the body of an effect will
// cause CD to reach the component in question.
const prevRefreshingViews = setIsRefreshingViews(false);
try {
this.maybeCleanup();
this.fn(registerCleanupFn);
}
finally {
setIsRefreshingViews(prevRefreshingViews);
consumerAfterComputation(this, prevNode);
}
},
maybeCleanup() {
if (!this.cleanupFns?.length) {
return;
}
const prevConsumer = setActiveConsumer(null);
try {
// Attempt to run the cleanup functions. Regardless of failure or success, we consider
// cleanup "completed" and clear the list for the next run of the effect. Note that an error
// from the cleanup function will still crash the current run of the effect.
while (this.cleanupFns.length) {
this.cleanupFns.pop()();
}
}
finally {
this.cleanupFns = [];
setActiveConsumer(prevConsumer);
}
},
}))();
const ROOT_EFFECT_NODE =
/* @__PURE__ */ (() => ({
...BASE_EFFECT_NODE,
consumerMarkedDirty() {
this.scheduler.schedule(this);
this.notifier.notify(12 /* NotificationSource.RootEffect */);
},
destroy() {
consumerDestroy(this);
this.onDestroyFn();
this.maybeCleanup();
this.scheduler.remove(this);
},
}))();
const VIEW_EFFECT_NODE =
/* @__PURE__ */ (() => ({
...BASE_EFFECT_NODE,
consumerMarkedDirty() {
this.view[FLAGS] |= 8192 /* LViewFlags.HasChildViewsToRefresh */;
markAncestorsForTraversal(this.view);
this.notifier.notify(13 /* NotificationSource.ViewEffect */);
},
destroy() {
consumerDestroy(this);
this.onDestroyFn();
this.maybeCleanup();
this.view[EFFECTS]?.delete(this);
},
}))();
function createViewEffect(view, notifier, fn) {
const node = Object.create(VIEW_EFFECT_NODE);
node.view = view;
node.zone = typeof Zone !== 'undefined' ? Zone.current : null;
node.notifier = notifier;
node.fn = fn;
view[EFFECTS] ??= new Set();
view[EFFECTS].add(node);
node.consumerMarkedDirty(node);
return node;
}
function createRootEffect(fn, scheduler, notifier) {
const node = Object.create(ROOT_EFFECT_NODE);
node.fn = fn;
node.scheduler = scheduler;
node.notifier = notifier;
node.zone = typeof Zone !== 'undefined' ? Zone.current : null;
node.scheduler.add(node);
node.notifier.notify(12 /* NotificationSource.RootEffect */);
return node;
}
const identityFn = (v) => v;
function linkedSignal(optionsOrComputation, options) {
if (typeof optionsOrComputation === 'function') {
const getter = createLinkedSignal(optionsOrComputation, (identityFn), options?.equal);
return upgradeLinkedSignalGetter(getter);
}
else {
const getter = createLinkedSignal(optionsOrComputation.source, optionsOrComputation.computation, optionsOrComputation.equal);
return upgradeLinkedSignalGetter(getter);
}
}
function upgradeLinkedSignalGetter(getter) {
if (ngDevMode) {
getter.toString = () => `[LinkedSignal: ${getter()}]`;
}
const node = getter[SIGNAL];
const upgradedGetter = getter;
upgradedGetter.set = (newValue) => linkedSignalSetFn(node, newValue);
upgradedGetter.update = (updateFn) => linkedSignalUpdateFn(node, updateFn);
upgradedGetter.asReadonly = signalAsReadonlyFn.bind(getter);
return upgradedGetter;
}
/**
* Whether a `Resource.value()` should throw an error when the resource is in the error state.
*
* This internal flag is being used to gradually roll out this behavior.
*/
const RESOURCE_VALUE_THROWS_ERRORS_DEFAULT = true;
function resource(options) {
if (ngDevMode && !options?.injector) {
assertInInjectionContext(resource);
}
const oldNameForParams = options.request;
const params = (options.params ?? oldNameForParams ?? (() => null));
return new ResourceImpl(params, getLoader(options), options.defaultValue, options.equal ? wrapEqualityFn(options.equal) : undefined, options.injector ?? inject(Injector), RESOURCE_VALUE_THROWS_ERRORS_DEFAULT);
}
/**
* Base class which implements `.value` as a `WritableSignal` by delegating `.set` and `.update`.
*/
class BaseWritableResource {
value;
constructor(value) {
this.value = value;
this.value.set = this.set.bind(this);
this.value.update = this.update.bind(this);
this.value.asReadonly = signalAsReadonlyFn;
}
isError = computed(() => this.status() === 'error');
update(updateFn) {
this.set(updateFn(untracked(this.value)));
}
isLoading = computed(() => this.status() === 'loading' || this.status() === 'reloading');
hasValue() {
// Note: we specifically read `isError()` instead of `status()` here to avoid triggering
// reactive consumers which read `hasValue()`. This way, if `hasValue()` is used inside of an
// effect, it doesn't cause the effect to rerun on every status change.
if (this.isError()) {
return false;
}
return this.value() !== undefined;
}
asReadonly() {
return this;
}
}
/**
* Implementation for `resource()` which uses a `linkedSignal` to manage the resource's state.
*/
class ResourceImpl extends BaseWritableResource {
loaderFn;
equal;
pendingTasks;
/**
* The current state of the resource. Status, value, and error are derived from this.
*/
state;
/**
* Combines the current request with a reload counter which allows the resource to be reloaded on
* imperative command.
*/
extRequest;
effectRef;
pendingController;
resolvePendingTask = undefined;
destroyed = false;
unregisterOnDestroy;
constructor(request, loaderFn, defaultValue, equal, injector, throwErrorsFromValue = RESOURCE_VALUE_THROWS_ERRORS_DEFAULT) {
super(
// Feed a computed signal for the value to `BaseWritableResource`, which will upgrade it to a
// `WritableSignal` that delegates to `ResourceImpl.set`.
computed(() => {
const streamValue = this.state().stream?.();
if (!streamValue) {
return defaultValue;
}
// Prevents `hasValue()` from throwing an error when a reload happened in the error state
if (this.state().status === 'loading' && this.error()) {
return defaultValue;
}
if (!isResolved(streamValue)) {
if (throwErrorsFromValue) {
throw new ResourceValueError(this.error());
}
else {
return defaultValue;
}
}
return streamValue.value;
}, { equal }));
this.loaderFn = loaderFn;
this.equal = equal;
// Extend `request()` to include a writable reload signal.
this.extRequest = linkedSignal({
source: request,
computation: (request) => ({ request, reload: 0 }),
});
// The main resource state is managed in a `linkedSignal`, which allows the resource to change
// state instantaneously when the request signal changes.
this.state = linkedSignal({
// Whenever the request changes,
source: this.extRequest,
// Compute the state of the resource given a change in status.
computation: (extRequest, previous) => {
const status = extRequest.request === undefined ? 'idle' : 'loading';
if (!previous) {
return {
extRequest,
status,
previousStatus: 'idle',
stream: undefined,
};
}
else {
return {
extRequest,
status,
previousStatus: projectStatusOfState(previous.value),
// If the request hasn't changed, keep the previous stream.
stream: previous.value.extRequest.request === extRequest.request
? previous.value.stream
: undefined,
};
}
},
});
this.effectRef = effect(this.loadEffect.bind(this), {
injector,
manualCleanup: true,
});
this.pendingTasks = injector.get(PendingTasks);
// Cancel any pending request when the resource itself is destroyed.
this.unregisterOnDestroy = injector.get(DestroyRef).onDestroy(() => this.destroy());
}
status = computed(() => projectStatusOfState(this.state()));
error = computed(() => {
const stream = this.state().stream?.();
return stream && !isResolved(stream) ? stream.error : undefined;
});
/**
* Called either directly via `WritableResource.set` or via `.value.set()`.
*/
set(value) {
if (this.destroyed) {
return;
}
const current = untracked(this.value);
const state = untracked(this.state);
if (state.status === 'local' && (this.equal ? this.equal(current, value) : current === value)) {
return;
}
// Enter Local state with the user-defined value.
this.state.set({
extRequest: state.extRequest,
status: 'local',
previousStatus: 'local',
stream: signal({ value }),
});
// We're departing from whatever state the resource was in previously, so cancel any in-progress
// loading operations.
this.abortInProgressLoad();
}
reload() {
// We don't want to restart in-progress loads.
const { status } = untracked(this.state);
if (status === 'idle' || status === 'loading') {
return false;
}
// Increment the request reload to trigger the `state` linked signal to switch us to `Reload`
this.extRequest.update(({ request, reload }) => ({ request, reload: reload + 1 }));
return true;
}
destroy() {
this.destroyed = true;
this.unregisterOnDestroy();
this.effectRef.destroy();
this.abortInProgressLoad();
// Destroyed resources enter Idle state.
this.state.set({
extRequest: { request: undefined, reload: 0 },
status: 'idle',
previousStatus: 'idle',
stream: undefined,
});
}
async loadEffect() {
const extRequest = this.extRequest();
// Capture the previous status before any state transitions. Note that this is `untracked` since
// we do not want the effect to depend on the state of the resource, only on the request.
const { status: currentStatus, previousStatus } = untracked(this.state);
if (extRequest.request === undefined) {
// Nothing to load (and we should already be in a non-loading state).
return;
}
else if (currentStatus !== 'loading') {
// We're not in a loading or reloading state, so this loading request is stale.
return;
}
// Cancel any previous loading attempts.
this.abortInProgressLoad();
// Capturing _this_ load's pending task in a local variable is important here. We may attempt to
// resolve it twice:
//
// 1. when the loading function promise resolves/rejects
// 2. when cancelling the loading operation
//
// After the loading operation is cancelled, `this.resolvePendingTask` no longer represents this
// particular task, but this `await` may eventually resolve/reject. Thus, when we cancel in
// response to (1) below, we need to cancel the locally saved task.
let resolvePendingTask = (this.resolvePendingTask =
this.pendingTasks.add());
const { signal: abortSignal } = (this.pendingController = new AbortController());
try {
// The actual loading is run through `untracked` - only the request side of `resource` is
// reactive. This avoids any confusion with signals tracking or not tracking depending on
// which side of the `await` they are.
const stream = await untracked(() => {
return this.loaderFn({
params: extRequest.request,
// TODO(alxhub): cleanup after g3 removal of `request` alias.
request: extRequest.request,
abortSignal,
previous: {
status: previousStatus,
},
});
});
// If this request has been aborted, or the current request no longer
// matches this load, then we should ignore this resolution.
if (abortSignal.aborted || untracked(this.extRequest) !== extRequest) {
return;
}
this.state.set({
extRequest,
status: 'resolved',
previousStatus: 'resolved',
stream,
});
}
catch (err) {
if (abortSignal.aborted || untracked(this.extRequest) !== extRequest) {
return;
}
this.state.set({
extRequest,
status: 'resolved',
previousStatus: 'error',
stream: signal({ error: encapsulateResourceError(err) }),
});
}
finally {
// Resolve the pending task now that the resource has a value.
resolvePendingTask?.();
resolvePendingTask = undefined;
}
}
abortInProgressLoad() {
untracked(() => this.pendingController?.abort());
this.pendingController = undefined;
// Once the load is aborted, we no longer want to block stability on its resolution.
this.resolvePendingTask?.();
this.resolvePendingTask = undefined;
}
}
/**
* Wraps an equality function to handle either value being `undefined`.
*/
function wrapEqualityFn(equal) {
return (a, b) => (a === undefined || b === undefined ? a === b : equal(a, b));
}
function getLoader(options) {
if (isStreamingResourceOptions(options)) {
return options.stream;
}
return async (params) => {
try {
return signal({ value: await options.loader(params) });
}
catch (err) {
return signal({ error: encapsulateResourceError(err) });
}
};
}
function isStreamingResourceOptions(options) {
return !!options.stream;
}
/**
* Project from a state with `ResourceInternalStatus` to the user-facing `ResourceStatus`
*/
function projectStatusOfState(state) {
switch (state.status) {
case 'loading':
return state.extRequest.reload === 0 ? 'loading' : 'reloading';
case 'resolved':
return isResolved(state.stream()) ? 'resolved' : 'error';
default:
return state.status;
}
}
function isResolved(state) {
return state.error === undefined;
}
function encapsulateResourceError(error) {
if (error instanceof Error) {
return error;
}
return new ResourceWrappedError(error);
}
class ResourceValueError extends Error {
constructor(error) {
super(ngDevMode
? `Resource is currently in an error state (see Error.cause for details): ${error.message}`
: error.message, { cause: error });
}
}
class ResourceWrappedError extends Error {
constructor(error) {
super(ngDevMode
? `Resource returned an error that's not an Error instance: ${String(error)}. Check this error's .cause for the actual error.`
: String(error), { cause: error });
}
}
export { OutputEmitterRef, ResourceImpl, computed, effect, encapsulateResourceError, getOutputDestroyRef, linkedSignal, resource, untracked };
//# sourceMappingURL=resource-BarKSp_3.mjs.map