Skip to content

Commit df0e61a

Browse files
committed
fix(core): align resource params typing with runtime behavior (#68167)
Align resource and rxResource typings with the runtime null sentinel when params are omitted or explicitly undefined.
1 parent a89b565 commit df0e61a

File tree

6 files changed

+178
-10
lines changed

6 files changed

+178
-10
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,12 +1637,20 @@ export interface Resource<T> {
16371637
}
16381638

16391639
// @public
1640-
export function resource<T, R>(options: ResourceOptions<T, R> & {
1640+
export function resource<T, R>(options: ResourceOptionsWithRequiredLoader<T, R> & {
1641+
defaultValue: NoInfer<T>;
1642+
}): ResourceRef<T>;
1643+
1644+
// @public (undocumented)
1645+
export function resource<T, R = never>(options: ResourceOptionsWithOptionalLoader<T, R> & {
16411646
defaultValue: NoInfer<T>;
16421647
}): ResourceRef<T>;
16431648

16441649
// @public
1645-
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>;
1650+
export function resource<T, R>(options: ResourceOptionsWithRequiredLoader<T, R>): ResourceRef<T | undefined>;
1651+
1652+
// @public (undocumented)
1653+
export function resource<T, R = never>(options: ResourceOptionsWithOptionalLoader<T, R>): ResourceRef<T | undefined>;
16461654

16471655
// @public
16481656
export class ResourceDependencyError extends Error {

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,26 @@ export function outputToObservable<T>(ref: OutputRef<T>): Observable<T>;
1919
export function pendingUntilEvent<T>(injector?: Injector): MonoTypeOperatorFunction<T>;
2020

2121
// @public
22-
export function rxResource<T, R>(opts: RxResourceOptions<T, R> & {
22+
export function rxResource<T, R>(opts: ResourceOptionsWithRequiredParams<T, R> & {
23+
stream: (params: ResourceLoaderParams<R>) => Observable<T>;
2324
defaultValue: NoInfer<T>;
2425
}): ResourceRef<T>;
2526

26-
// @public
27-
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined>;
27+
// @public (undocumented)
28+
export function rxResource<T, R>(opts: ResourceOptionsWithRequiredParams<T, R> & {
29+
stream: (params: ResourceLoaderParams<R>) => Observable<T>;
30+
}): ResourceRef<T | undefined>;
31+
32+
// @public (undocumented)
33+
export function rxResource<T, R = never>(opts: ResourceOptionsWithOptionalParams<T, R> & {
34+
stream: (params: ResourceLoaderParams<Exclude<R, undefined> | null>) => Observable<T>;
35+
defaultValue: NoInfer<T>;
36+
}): ResourceRef<T>;
37+
38+
// @public (undocumented)
39+
export function rxResource<T, R = never>(opts: ResourceOptionsWithOptionalParams<T, R> & {
40+
stream: (params: ResourceLoaderParams<Exclude<R, undefined> | null>) => Observable<T>;
41+
}): ResourceRef<T | undefined>;
2842

2943
// @public
3044
export interface RxResourceOptions<T, R> extends BaseResourceOptions<T, R> {

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

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import {
2020
ɵRuntimeErrorCode,
2121
} from '../../src/core';
2222
import {encapsulateResourceError} from '../../src/resource/resource';
23+
import {
24+
ResourceOptionsWithOptionalParams,
25+
ResourceOptionsWithRequiredParams,
26+
} from '../../src/resource/resource_option_types';
2327

2428
/**
2529
* Like `ResourceOptions` but uses an RxJS-based `loader`.
@@ -38,17 +42,41 @@ export interface RxResourceOptions<T, R> extends BaseResourceOptions<T, R> {
3842
*
3943
* @experimental
4044
*/
45+
46+
// Overload A: params is DEFINITELY a function → no null
4147
export function rxResource<T, R>(
42-
opts: RxResourceOptions<T, R> & {defaultValue: NoInfer<T>},
48+
opts: ResourceOptionsWithRequiredParams<T, R> & {
49+
stream: (params: ResourceLoaderParams<R>) => Observable<T>;
50+
defaultValue: NoInfer<T>;
51+
},
4352
): ResourceRef<T>;
4453

54+
export function rxResource<T, R>(
55+
opts: ResourceOptionsWithRequiredParams<T, R> & {
56+
stream: (params: ResourceLoaderParams<R>) => Observable<T>;
57+
},
58+
): ResourceRef<T | undefined>;
59+
60+
// Overload B: params is OPTIONAL → null possible
61+
export function rxResource<T, R = never>(
62+
opts: ResourceOptionsWithOptionalParams<T, R> & {
63+
stream: (params: ResourceLoaderParams<Exclude<R, undefined> | null>) => Observable<T>;
64+
defaultValue: NoInfer<T>;
65+
},
66+
): ResourceRef<T>;
67+
68+
export function rxResource<T, R = never>(
69+
opts: ResourceOptionsWithOptionalParams<T, R> & {
70+
stream: (params: ResourceLoaderParams<Exclude<R, undefined> | null>) => Observable<T>;
71+
},
72+
): ResourceRef<T | undefined>;
73+
4574
/**
4675
* Like `resource` but uses an RxJS based `loader` which maps the request to an `Observable` of the
4776
* resource's value.
4877
*
4978
* @experimental
5079
*/
51-
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined>;
5280
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined> {
5381
if (ngDevMode && !opts?.injector) {
5482
assertInInjectionContext(rxResource);
@@ -75,7 +103,9 @@ export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T |
75103
resolve = undefined;
76104
}
77105

78-
const streamFn = opts.stream;
106+
const streamFn = opts.stream as (
107+
params: ResourceLoaderParams<Exclude<R, undefined> | null>,
108+
) => Observable<T>;
79109
if (streamFn === undefined) {
80110
throw new ɵRuntimeError(
81111
ɵRuntimeErrorCode.MUST_PROVIDE_STREAM_OPTION,

packages/core/src/resource/resource.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {effect, EffectRef} from '../render3/reactivity/effect';
1212
import {signal, signalAsReadonlyFn, WritableSignal} from '../render3/reactivity/signal';
1313
import {untracked} from '../render3/reactivity/untracked';
1414
import {
15+
PromiseResourceOptions,
1516
Resource,
1617
ResourceDependencyError,
1718
ResourceLoaderParams,
@@ -26,6 +27,10 @@ import {
2627
type ResourceRef,
2728
type WritableResource,
2829
} from './api';
30+
import {
31+
ResourceOptionsWithOptionalParams,
32+
ResourceOptionsWithRequiredParams,
33+
} from './resource_option_types';
2934

3035
import {assertInInjectionContext} from '../di/contextual';
3136
import {Injector} from '../di/injector';
@@ -35,6 +40,18 @@ import {DestroyRef} from '../linker/destroy_ref';
3540
import {PendingTasks} from '../pending_tasks';
3641
import {linkedSignal} from '../render3/reactivity/linked_signal';
3742

43+
type ResourceOptionsWithRequiredLoader<T, R> = (
44+
| Omit<PromiseResourceOptions<T, R>, 'params'>
45+
| Omit<StreamingResourceOptions<T, R>, 'params'>
46+
) &
47+
ResourceOptionsWithRequiredParams<T, R>;
48+
49+
type ResourceOptionsWithOptionalLoader<T, R> = (
50+
| Omit<PromiseResourceOptions<T, Exclude<R, undefined> | null>, 'params'>
51+
| Omit<StreamingResourceOptions<T, Exclude<R, undefined> | null>, 'params'>
52+
) &
53+
ResourceOptionsWithOptionalParams<T, R>;
54+
3855
/**
3956
* Constructs a `Resource` that projects a reactive request to an asynchronous operation defined by
4057
* a loader function, which exposes the result of the loading operation via signals.
@@ -48,7 +65,11 @@ import {linkedSignal} from '../render3/reactivity/linked_signal';
4865
* @experimental 19.0
4966
*/
5067
export function resource<T, R>(
51-
options: ResourceOptions<T, R> & {defaultValue: NoInfer<T>},
68+
options: ResourceOptionsWithRequiredLoader<T, R> & {defaultValue: NoInfer<T>},
69+
): ResourceRef<T>;
70+
71+
export function resource<T, R = never>(
72+
options: ResourceOptionsWithOptionalLoader<T, R> & {defaultValue: NoInfer<T>},
5273
): ResourceRef<T>;
5374

5475
/**
@@ -62,7 +83,12 @@ export function resource<T, R>(
6283
* @experimental 19.0
6384
* @see [Async reactivity with resources](guide/signals/resource)
6485
*/
65-
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>;
86+
export function resource<T, R>(
87+
options: ResourceOptionsWithRequiredLoader<T, R>,
88+
): ResourceRef<T | undefined>;
89+
export function resource<T, R = never>(
90+
options: ResourceOptionsWithOptionalLoader<T, R>,
91+
): ResourceRef<T | undefined>;
6692
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined> {
6793
if (ngDevMode && !options?.injector) {
6894
assertInInjectionContext(resource);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {BaseResourceOptions} from './api';
10+
11+
export type ResourceOptionsWithRequiredParams<T, R> = Omit<BaseResourceOptions<T, R>, 'params'> & {
12+
params: NonNullable<BaseResourceOptions<T, R>['params']>;
13+
};
14+
15+
export type ResourceOptionsWithOptionalParams<T, R> = Omit<BaseResourceOptions<T, R>, 'params'> & {
16+
params?: BaseResourceOptions<T, R>['params'] | undefined;
17+
};

packages/core/test/resource/resource_spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,41 @@ describe('resource', () => {
274274
expect(effectRuns).toBe(2);
275275
});
276276

277+
it('should pass null params to the loader when params are omitted', async () => {
278+
const appRef = TestBed.inject(ApplicationRef);
279+
let seenParams: string | null | undefined;
280+
281+
const res = resource<string, string>({
282+
loader: async ({params}) => {
283+
seenParams = params;
284+
return 'foo';
285+
},
286+
injector: TestBed.inject(Injector),
287+
});
288+
289+
await appRef.whenStable();
290+
expect(seenParams).toBeNull();
291+
expect(res.value()).toBe('foo');
292+
});
293+
294+
it('should pass null params to the loader when params is undefined', async () => {
295+
const appRef = TestBed.inject(ApplicationRef);
296+
let seenParams: string | null | undefined;
297+
298+
const res = resource<string, string>({
299+
params: undefined,
300+
loader: async ({params}) => {
301+
seenParams = params;
302+
return 'foo';
303+
},
304+
injector: TestBed.inject(Injector),
305+
});
306+
307+
await appRef.whenStable();
308+
expect(seenParams).toBeNull();
309+
expect(res.value()).toBe('foo');
310+
});
311+
277312
it('should update computed signals', async () => {
278313
const backend = new MockEchoBackend();
279314
const counter = signal(0);
@@ -944,6 +979,44 @@ describe('resource', () => {
944979
});
945980

946981
describe('types', () => {
982+
it('should type loader params as nullable when params are omitted', () => {
983+
resource<string, string>({
984+
loader: async ({params}) => {
985+
const _nullable: string | null = params;
986+
// @ts-expect-error
987+
const _nonNullable: string = params;
988+
return '';
989+
},
990+
injector: TestBed.inject(Injector),
991+
});
992+
});
993+
994+
it('should type loader params as nullable when params is undefined', () => {
995+
resource<string, string>({
996+
params: undefined,
997+
loader: async ({params}) => {
998+
const _nullable: string | null = params;
999+
// @ts-expect-error
1000+
const _nonNullable: string = params;
1001+
return '';
1002+
},
1003+
injector: TestBed.inject(Injector),
1004+
});
1005+
});
1006+
1007+
it('should keep loader params non-nullable when params is provided', () => {
1008+
resource<string, string | undefined>({
1009+
params: () => 'foo',
1010+
loader: async ({params}) => {
1011+
const _nonNullable: string = params;
1012+
// @ts-expect-error
1013+
const _nullableOnly: null = params;
1014+
return '';
1015+
},
1016+
injector: TestBed.inject(Injector),
1017+
});
1018+
});
1019+
9471020
it('should narrow hasValue() when the value can be undefined', () => {
9481021
const result: ResourceRef<number | undefined> = resource({
9491022
params: () => 1,

0 commit comments

Comments
 (0)