/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/core/src/resource/resource.ts
686 строк
22 KB
Matthieu Riegler
docs: correct docs about the chain behavior.
06 июл 2026, 23:37
06 июл 2026, 23:37
5ad937b
Код
Авторство
О чём код?
/** * @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.dev/license */ import {isSignal, Signal, ValueEqualityFn} from '../render3/reactivity/api'; import {computed} from '../render3/reactivity/computed'; import {effect, EffectRef} from '../render3/reactivity/effect'; import {signal, signalAsReadonlyFn, WritableSignal} from '../render3/reactivity/signal'; import {untracked} from '../render3/reactivity/untracked'; import { Resource, ResourceDependencyError, ResourceOptions, ResourceParamsStatus, ResourceSnapshot, ResourceStatus, ResourceStreamingLoader, ResourceStreamItem, StreamingResourceOptions, type ResourceParamsContext, type ResourceRef, type WritableResource, } from './api'; import {assertInInjectionContext} from '../di/contextual'; import {Injector} from '../di/injector'; import {inject} from '../di/injector_compatibility'; import {RuntimeError, RuntimeErrorCode} from '../errors'; import {CACHE_ACTIVE} from '../hydration/cache'; import {DestroyRef} from '../linker/destroy_ref'; import {PendingTasks} from '../pending_tasks'; import {linkedSignal} from '../render3/reactivity/linked_signal'; import {StateKey, TransferState} from '../transfer_state'; /** * Constructs a `Resource` that projects a reactive request to an asynchronous operation defined by * a loader function, which exposes the result of the loading operation via signals. * * Note that `resource` is intended for _read_ operations, not operations which perform mutations. * `resource` will cancel in-progress loads via the `AbortSignal` when destroyed or when a new * request object becomes available, which could prematurely abort mutations. * * @see [Async reactivity with resources](guide/signals/resource) * * @publicApi 22.0 */ export function resource<T, R>( options: ResourceOptions<T, R> & {defaultValue: NoInfer<T>}, ): ResourceRef<T>; /** * Constructs a `Resource` that projects a reactive request to an asynchronous operation defined by * a loader function, which exposes the result of the loading operation via signals. * * Note that `resource` is intended for _read_ operations, not operations which perform mutations. * `resource` will cancel in-progress loads via the `AbortSignal` when destroyed or when a new * request object becomes available, which could prematurely abort mutations. * * @publicApi 22.0 * @see [Async reactivity with resources](guide/signals/resource) */ export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>; export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined> { if (ngDevMode && !options?.injector) { assertInInjectionContext(resource); } const oldNameForParams = ( options as ResourceOptions<T, R> & {request: ResourceOptions<T, R>['params']} ).request; const params = options.params ?? oldNameForParams ?? (() => null!); return new ResourceImpl<T | undefined, R>( params, getLoader(options), options.defaultValue, options.equal ? wrapEqualityFn(options.equal) : undefined, options.debugName, options.injector ?? inject(Injector), options.id as StateKey<T>, ); } type ResourceInternalStatus = 'idle' | 'loading' | 'resolved' | 'local'; /** * Internal state of a resource. */ interface ResourceProtoState<T> { extRequest: WrappedRequest; // For simplicity, status is internally tracked as a subset of the public status enum. // Reloading and Error statuses are projected from Loading and Resolved based on other state. status: ResourceInternalStatus; } interface ResourceState<T> extends ResourceProtoState<T> { previousStatus: ResourceStatus; stream: Signal<ResourceStreamItem<T>> | undefined; } type WrappedRequest = { request?: unknown; reload: number; status?: ResourceInternalStatus; error?: Error; }; /** * Base class which implements `.value` as a `WritableSignal` by delegating `.set` and `.update`. */ abstract class BaseWritableResource<T> implements WritableResource<T> { readonly value: WritableSignal<T>; abstract readonly status: Signal<ResourceStatus>; abstract readonly error: Signal<Error | undefined>; abstract reload(): boolean; readonly isLoading: Signal<boolean>; constructor(value: Signal<T>, debugName: string | undefined) { this.value = value as WritableSignal<T>; this.value.set = this.set.bind(this); this.value.update = this.update.bind(this); this.value.asReadonly = signalAsReadonlyFn; this.isLoading = computed( () => this.status() === 'loading' || this.status() === 'reloading', ngDevMode ? createDebugNameObject(debugName, 'isLoading') : undefined, ); } abstract set(value: T): void; private readonly isError = computed(() => this.status() === 'error'); update(updateFn: (value: T) => T): void { this.set(updateFn(untracked(this.value))); } // Use a computed here to avoid triggering reactive consumers if the value changes while staying // either defined or undefined. private readonly isValueDefined = computed(() => { // Check if it's in an error state first to prevent the error from bubbling up. if (this.isError()) { return false; } return this.value() !== undefined; }); private _snapshot: Signal<ResourceSnapshot<T>> | undefined; get snapshot(): Signal<ResourceSnapshot<T>> { return (this._snapshot ??= computed(() => { const status = this.status(); if (status === 'error') { return {status: 'error', error: this.error()!}; } else { return {status, value: this.value()}; } })); } hasValue(): this is ResourceRef<Exclude<T, undefined>> { return this.isValueDefined(); } asReadonly(): Resource<T> { return this; } } /** * Implementation for `resource()` which uses a `linkedSignal` to manage the resource's state. */ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements ResourceRef<T> { private readonly pendingTasks: PendingTasks; /** * The current state of the resource. Status, value, and error are derived from this. */ private readonly state: WritableSignal<ResourceState<T>>; /** * Combines the current request with a reload counter which allows the resource to be reloaded on * imperative command. */ protected readonly extRequest: WritableSignal<WrappedRequest>; private readonly effectRef: EffectRef; private pendingController: AbortController | undefined; private resolvePendingTask: (() => void) | undefined = undefined; private destroyed = false; private unregisterOnDestroy: () => void; override readonly status: Signal<ResourceStatus>; override readonly error: Signal<Error | undefined>; private readonly transferState: TransferState | undefined; constructor( request: (ctx: ResourceParamsContext) => R, private readonly loaderFn: ResourceStreamingLoader<T, R>, defaultValue: T, private readonly equal: ValueEqualityFn<T> | undefined, private readonly debugName: string | undefined, injector: Injector, private transferCacheKey: StateKey<T> | undefined, getInitialStream?: (request: R) => Signal<ResourceStreamItem<T>> | undefined, ) { if (isInParamsFunction()) { throw invalidResourceCreationInParams(); } 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)) { throw new ResourceValueError(this.error()!); } return streamValue.value; }, {equal, ...(ngDevMode ? createDebugNameObject(debugName, 'value') : undefined)}, ), debugName, ); const cacheState = injector.get(CACHE_ACTIVE, undefined, {optional: true}) ?? {isActive: false}; this.transferState = injector.get(TransferState, undefined, {optional: true}) ?? undefined; this.extRequest = linkedSignal<WrappedRequest>( () => { try { setInParamsFunction(true); return {request: request(paramsContext), reload: 0}; } catch (error) { rethrowFatalErrors(error); if (error === ResourceParamsStatus.IDLE) { return {status: 'idle', reload: 0}; } else if (error === ResourceParamsStatus.LOADING) { return {status: 'loading', reload: 0}; } return {error: error as Error, reload: 0}; } finally { setInParamsFunction(false); } }, ngDevMode ? createDebugNameObject(debugName, 'extRequest') : undefined, ); // 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<WrappedRequest, ResourceState<T>>({ // Whenever the request changes, source: this.extRequest, // Compute the state of the resource given a change in status. computation: (extRequest, previous) => { let {request, status, error} = extRequest; let stream: Signal<ResourceStreamItem<T>> | undefined; if (error) { status = 'resolved'; stream = signal( {error: encapsulateResourceError(error)}, ngDevMode ? createDebugNameObject(this.debugName, 'stream') : undefined, ); } else if (!status) { if (!previous) { const transferState = this.transferState; const cacheKey = this.transferCacheKey; if (cacheState.isActive && cacheKey && transferState && request !== undefined) { const key = this.transferCacheKey; if (transferState.hasKey(cacheKey)) { stream = signal( {value: transferState.get(cacheKey, defaultValue)}, ngDevMode ? createDebugNameObject(this.debugName, 'stream') : undefined, ); } } if (!stream) { stream = getInitialStream?.(extRequest.request as R); } // Clear getInitialStream so it doesn't hold onto memory getInitialStream = undefined; status = request === undefined ? 'idle' : stream ? 'resolved' : 'loading'; } else { status = request === undefined ? 'idle' : 'loading'; if (previous.value.extRequest.request === request) { stream = previous.value.stream; } } } return { extRequest, status, previousStatus: previous ? projectStatusOfState(previous.value) : 'idle', stream, }; }, ...(ngDevMode ? createDebugNameObject(debugName, 'state') : undefined), }); this.effectRef = effect(this.loadEffect.bind(this), { injector, manualCleanup: true, ...(ngDevMode ? createDebugNameObject(debugName, 'loadEffect') : undefined), }); this.pendingTasks = injector.get(PendingTasks); // Cancel any pending request when the resource itself is destroyed. this.unregisterOnDestroy = injector.get(DestroyRef).onDestroy(() => this.destroy()); this.status = computed( () => projectStatusOfState(this.state()), ngDevMode ? createDebugNameObject(debugName, 'status') : undefined, ); this.error = computed( () => { const stream = this.state().stream?.(); return stream && !isResolved(stream) ? stream.error : undefined; }, ngDevMode ? createDebugNameObject(debugName, 'error') : undefined, ); } /** * Called either directly via `WritableResource.set` or via `.value.set()`. */ override set(value: T): void { if (this.destroyed) { return; } const error = untracked(this.error); const state = untracked(this.state); if (!error) { const current = untracked(this.value); 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}, ngDevMode ? createDebugNameObject(this.debugName, 'stream') : undefined, ), }); // We're departing from whatever state the resource was in previously, so cancel any in-progress // loading operations. this.abortInProgressLoad(); } override reload(): boolean { // 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(): void { 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, }); } private async loadEffect(): Promise<void> { 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: (() => void) | undefined = (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 = untracked(() => { return this.loaderFn({ params: extRequest.request as Exclude<R, undefined>, 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. const shouldDiscard = () => abortSignal.aborted || untracked(this.extRequest) !== extRequest; if (isSignal(stream)) { if (shouldDiscard()) { return; } this.state.set({ extRequest, status: 'resolved', previousStatus: 'resolved', stream, }); const result = untracked(stream); if (typeof ngServerMode !== 'undefined' && ngServerMode) { saveToTransferState(result, this.transferCacheKey, this.transferState); } } else { const resolvedStream = await stream; if (shouldDiscard()) { return; } this.state.set({ extRequest, status: 'resolved', previousStatus: 'resolved', stream: resolvedStream, }); // Use a local variable for the result so TypeScript can narrow `resolvedStream` correctly. const result = resolvedStream ? untracked(resolvedStream) : undefined; if (typeof ngServerMode !== 'undefined' && ngServerMode) { saveToTransferState(result, this.transferCacheKey, this.transferState); } } } catch (err) { rethrowFatalErrors(err); if (abortSignal.aborted || untracked(this.extRequest) !== extRequest) { return; } this.state.set({ extRequest, status: 'resolved', previousStatus: 'error', stream: signal( {error: encapsulateResourceError(err)}, ngDevMode ? createDebugNameObject(this.debugName, 'stream') : undefined, ), }); } finally { // Resolve the pending task now that the resource has a value. resolvePendingTask?.(); resolvePendingTask = undefined; } } private abortInProgressLoad(): void { 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; } } function saveToTransferState<R, T>( result: ResourceStreamItem<T> | undefined, transferCacheKey: StateKey<T> | undefined, transferState: TransferState | undefined, ): void { if (transferCacheKey && transferState && result && isResolved(result)) { transferState.set(transferCacheKey, result.value); } } /** * Wraps an equality function to handle either value being `undefined`. */ function wrapEqualityFn<T>(equal: ValueEqualityFn<T>): ValueEqualityFn<T | undefined> { return (a, b) => (a === undefined || b === undefined ? a === b : equal(a, b)); } function getLoader<T, R>(options: ResourceOptions<T, R>): ResourceStreamingLoader<T, R> { if (isStreamingResourceOptions(options)) { return options.stream; } return async (params) => { try { return signal( {value: await options.loader(params)}, ngDevMode ? createDebugNameObject(options.debugName, 'stream') : undefined, ); } catch (err) { return signal( {error: encapsulateResourceError(err)}, ngDevMode ? createDebugNameObject(options.debugName, 'stream') : undefined, ); } }; } function isStreamingResourceOptions<T, R>( options: ResourceOptions<T, R>, ): options is StreamingResourceOptions<T, R> { return !!(options as StreamingResourceOptions<T, R>).stream; } /** * Project from a state with `ResourceInternalStatus` to the user-facing `ResourceStatus` */ function projectStatusOfState(state: ResourceState<unknown>): ResourceStatus { 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<T>(state: ResourceStreamItem<T>): state is {value: T} { return (state as {error: unknown}).error === undefined; } /** * Creates a debug name object for an internal signal. */ function createDebugNameObject( resourceDebugName: string | undefined, internalSignalDebugName: string, ): {debugName?: string} { return { debugName: `Resource${resourceDebugName ? '#' + resourceDebugName : ''}.${internalSignalDebugName}`, }; } export function encapsulateResourceError(error: unknown): Error { if (isErrorLike(error)) { return error; } return new ResourceWrappedError(error); } export function isErrorLike(error: unknown): error is Error { return ( error instanceof Error || (typeof error === 'object' && typeof (error as Error).name === 'string' && typeof (error as Error).message === 'string') ); } export class ResourceValueError extends Error { constructor(error: 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: unknown) { 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}, ); } } /** * Chains the current params off of the value of another resource, returning the value * of the other resource only when its status is `resolved` or `local`, or propagating status to * the current resource by throwing the appropriate status code when the value is not available. */ export function chain<T>(resource: Resource<T>): T { switch (resource.status()) { case 'idle': throw ResourceParamsStatus.IDLE; case 'error': throw new ResourceDependencyError(resource); case 'loading': case 'reloading': throw ResourceParamsStatus.LOADING; } return resource.value(); } export const paramsContext: ResourceParamsContext = { chain, }; let inParamsFunction = false; export function isInParamsFunction() { return inParamsFunction; } export function setInParamsFunction(value: boolean) { inParamsFunction = value; } export function invalidResourceCreationInParams(): Error { return new RuntimeError( RuntimeErrorCode.INVALID_RESOURCE_CREATION_IN_PARAMS, ngDevMode && `Cannot create a resource inside the \`params\` of another resource`, ); } export function rethrowFatalErrors(error: unknown) { if ( error instanceof RuntimeError && error.code === RuntimeErrorCode.INVALID_RESOURCE_CREATION_IN_PARAMS ) { throw error; } }