-
Notifications
You must be signed in to change notification settings - Fork 29k
Experimental Resource API #58255
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
Experimental Resource API #58255
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1530,6 +1530,59 @@ export interface RendererType2 { | |
| // @public | ||
| export function resolveForwardRef<T>(type: T): T; | ||
|
|
||
| // @public | ||
| export interface Resource<T> { | ||
| readonly error: Signal<unknown>; | ||
| hasValue(): this is Resource<T> & { | ||
| value: Signal<T>; | ||
| }; | ||
| readonly isLoading: Signal<boolean>; | ||
| reload(): boolean; | ||
| readonly status: Signal<ResourceStatus>; | ||
| readonly value: Signal<T | undefined>; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: Have you thought about exposing the request on the Resource, which triggered the resource?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We talked about it, but didn't really see a use case where that made sense to do. Given that the resource is initialized with the request function in the first place, the user already has both of them in the same place. |
||
| } | ||
|
|
||
| // @public | ||
| export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T>; | ||
|
|
||
| // @public | ||
| export type ResourceLoader<T, R> = (param: ResourceLoaderParams<R>) => PromiseLike<T>; | ||
|
|
||
| // @public | ||
| export interface ResourceLoaderParams<R> { | ||
| // (undocumented) | ||
| abortSignal: AbortSignal; | ||
| // (undocumented) | ||
| previous: { | ||
| status: ResourceStatus; | ||
| }; | ||
| // (undocumented) | ||
| request: Exclude<NoInfer<R>, undefined>; | ||
| } | ||
|
|
||
| // @public | ||
| export interface ResourceOptions<T, R> { | ||
| equal?: ValueEqualityFn<T>; | ||
| injector?: Injector; | ||
| loader: ResourceLoader<T, R>; | ||
| request?: () => R; | ||
| } | ||
|
pkozlowski-opensource marked this conversation as resolved.
|
||
|
|
||
| // @public | ||
| export interface ResourceRef<T> extends WritableResource<T> { | ||
| destroy(): void; | ||
| } | ||
|
|
||
| // @public | ||
| export enum ResourceStatus { | ||
| Error = 1, | ||
| Idle = 0, | ||
| Loading = 2, | ||
| Local = 5, | ||
| Reloading = 3, | ||
| Resolved = 4 | ||
| } | ||
|
|
||
| // @public | ||
| export function runInInjectionContext<ReturnT>(injector: Injector, fn: () => ReturnT): ReturnT; | ||
|
|
||
|
|
@@ -1862,6 +1915,20 @@ export abstract class ViewRef extends ChangeDetectorRef { | |
| abstract onDestroy(callback: Function): void; | ||
| } | ||
|
|
||
| // @public | ||
| export interface WritableResource<T> extends Resource<T> { | ||
| // (undocumented) | ||
| asReadonly(): Resource<T>; | ||
| // (undocumented) | ||
| hasValue(): this is WritableResource<T> & { | ||
| value: WritableSignal<T>; | ||
| }; | ||
| set(value: T | undefined): void; | ||
| update(updater: (value: T | undefined) => T | undefined): void; | ||
| // (undocumented) | ||
| readonly value: WritableSignal<T | undefined>; | ||
| } | ||
|
|
||
| // @public | ||
| export interface WritableSignal<T> extends Signal<T> { | ||
| // (undocumented) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| /** | ||
| * @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 { | ||
| assertInInjectionContext, | ||
| ResourceOptions, | ||
| resource, | ||
| ResourceLoaderParams, | ||
| ResourceRef, | ||
| } from '@angular/core'; | ||
| import {firstValueFrom, Observable, Subject} from 'rxjs'; | ||
| import {takeUntil} from 'rxjs/operators'; | ||
|
|
||
| /** | ||
| * Like `ResourceOptions` but uses an RxJS-based `loader`. | ||
| * | ||
| * @experimental | ||
| */ | ||
| export interface RxResourceOptions<T, R> extends Omit<ResourceOptions<T, R>, 'loader'> { | ||
| loader: (params: ResourceLoaderParams<R>) => Observable<T>; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here, the loader type is "inline". The Promise-based loader type is a type of its own. Perhaps we can do the same here. I think it would be beneficial for two reasons:
This could look as follows or similar:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey @manfredsteyer! We chatted about this. One of the reasons I don't see the same need on the RxJS side, because RxJS already has a composition model of its own (operators), so extra behaviors would more likely be implemented through that mechanism. I'm not opposed to it if there's a clear need, but we do try to keep the space of symbols intentional and not add aliases without a compelling reason.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @alxhub, I see. Yes, this makes sense. In the case of my demo I wrote for trying out resource, I had a I can totally use a respective RxJS operator. I think it's not that much about functionality but more about code-symmetry in user land code. |
||
| } | ||
|
|
||
| /** | ||
| * Like `resource` but uses an RxJS based `loader` which maps the request to an `Observable` of the | ||
| * resource's value. Like `firstValueFrom`, only the first emission of the Observable is considered. | ||
| * | ||
| * @experimental | ||
| */ | ||
| export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T> { | ||
| opts?.injector || assertInInjectionContext(rxResource); | ||
| return resource<T, R>({ | ||
| ...opts, | ||
| loader: (params) => { | ||
| const cancelled = new Subject<void>(); | ||
| params.abortSignal.addEventListener('abort', () => cancelled.next()); | ||
| return firstValueFrom(opts.loader(params).pipe(takeUntil(cancelled))); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will this improve here so that all values of the observable are taken (all, until we switch over to the next request) or is the recommendation to go directly with RxJS when there is a real stream of data? |
||
| }, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| /** | ||
| * @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 {of, Observable} from 'rxjs'; | ||
| import {TestBed} from '@angular/core/testing'; | ||
| import {ApplicationRef, Injector, signal} from '@angular/core'; | ||
| import {rxResource} from '@angular/core/rxjs-interop'; | ||
|
|
||
| describe('rxResource()', () => { | ||
| it('should fetch data using an observable loader', async () => { | ||
| const injector = TestBed.inject(Injector); | ||
| const appRef = TestBed.inject(ApplicationRef); | ||
| const res = rxResource({ | ||
| loader: () => of(1), | ||
| injector, | ||
| }); | ||
| await appRef.whenStable(); | ||
| expect(res.value()).toBe(1); | ||
| }); | ||
|
|
||
| it('should cancel the fetch when a new request comes in', async () => { | ||
| const injector = TestBed.inject(Injector); | ||
| const appRef = TestBed.inject(ApplicationRef); | ||
| let unsub = false; | ||
| const request = signal(1); | ||
| const res = rxResource({ | ||
| request, | ||
| loader: ({request}) => | ||
| new Observable((sub) => { | ||
| if (request === 2) { | ||
| sub.next(true); | ||
| } | ||
| return () => { | ||
| if (request === 1) { | ||
| unsub = true; | ||
| } | ||
| }; | ||
| }), | ||
| injector, | ||
| }); | ||
|
|
||
| // Wait for the resource to reach loading state. | ||
| await waitFor(() => res.isLoading()); | ||
|
|
||
| // Setting request = 2 should cancel request = 1 | ||
| request.set(2); | ||
| await appRef.whenStable(); | ||
| expect(unsub).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| async function waitFor(fn: () => boolean): Promise<void> { | ||
| while (!fn()) { | ||
| await new Promise((resolve) => setTimeout(resolve, 1)); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.