Skip to content

Commit b918bed

Browse files
authored
feat(core): allow debouncing signals
Adds a utility `debounced` to create a debounced version of a signal, represented as a `Resource`. The resource's value contained the debounced value of the signal, while its status (`resolved`, `loading`, or `error`) indicates if the value is settled, if there is a value currently pending debounce, or if the source signal threw an error.
1 parent 82b758e commit b918bed

File tree

8 files changed

+529
-2
lines changed

8 files changed

+529
-2
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export const enum RuntimeErrorCode {
9494
// (undocumented)
9595
INVALID_MULTI_PROVIDER = -209,
9696
// (undocumented)
97+
INVALID_RESOURCE_CREATION_IN_PARAMS = 992,
98+
// (undocumented)
9799
INVALID_SET_INPUT_CALL = 317,
98100
// (undocumented)
99101
INVALID_SKIP_HYDRATION_HOST = -504,

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,15 @@ export const CSP_NONCE: InjectionToken<string | null>;
499499
// @public
500500
export const CUSTOM_ELEMENTS_SCHEMA: SchemaMetadata;
501501

502+
// @public
503+
export function debounced<T>(source: () => T, wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>, options?: NoInfer<DebouncedOptions<T>>): Resource<T>;
504+
505+
// @public
506+
export interface DebouncedOptions<T> {
507+
equal?: ValueEqualityFn<T>;
508+
injector?: Injector;
509+
}
510+
502511
// @public (undocumented)
503512
export class DebugElement extends DebugNode {
504513
constructor(nativeNode: Element);

packages/core/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export const enum RuntimeErrorCode {
157157
// resource() API errors
158158
MUST_PROVIDE_STREAM_OPTION = 990,
159159
RESOURCE_COMPLETED_BEFORE_PRODUCING_VALUE = 991,
160+
INVALID_RESOURCE_CREATION_IN_PARAMS = 992,
160161

161162
// Upper bounds for core runtime errors is 999
162163
}

packages/core/src/resource/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,11 @@ export type ResourceSnapshot<T> =
297297
| {readonly status: 'loading' | 'reloading'; readonly value: T}
298298
| {readonly status: 'resolved' | 'local'; readonly value: T}
299299
| {readonly status: 'error'; readonly error: Error};
300+
301+
/** Options for `debounced`. */
302+
export interface DebouncedOptions<T> {
303+
/** The `Injector` to use for the debounced resource. */
304+
injector?: Injector;
305+
/** The equality function to use for comparing values. */
306+
equal?: ValueEqualityFn<T>;
307+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 {assertInInjectionContext, inject, Injector} from '../di';
10+
import {DestroyRef} from '../linker';
11+
import {effect} from '../render3/reactivity/effect';
12+
import {signal} from '../render3/reactivity/signal';
13+
import {untracked} from '../render3/reactivity/untracked';
14+
import {Resource, ResourceSnapshot, type DebouncedOptions} from './api';
15+
import {resourceFromSnapshots} from './from_snapshots';
16+
import {
17+
invalidResourceCreationInParams,
18+
isInParamsFunction,
19+
rethrowFatalErrors,
20+
setInParamsFunction,
21+
} from './resource';
22+
23+
/**
24+
* Creates a resource representing a debounced version of the source signal.
25+
*
26+
* @param source The source signal to debounce.
27+
* @param wait The amount of time to wait before calling the source signal, or a function that
28+
* returns a promise that resolves when the debounced value should be updated.
29+
* @param options The options to use for the debounced signal.
30+
* @returns A resource representing the debounced signal.
31+
* @experimental 22.0
32+
*/
33+
export function debounced<T>(
34+
source: () => T,
35+
wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>,
36+
options?: NoInfer<DebouncedOptions<T>>,
37+
): Resource<T> {
38+
if (isInParamsFunction()) {
39+
throw invalidResourceCreationInParams();
40+
}
41+
if (ngDevMode && !options?.injector) {
42+
assertInInjectionContext(debounced);
43+
}
44+
const injector = options?.injector ?? inject(Injector);
45+
46+
const state = signal<ResourceSnapshot<T>>({
47+
status: 'resolved',
48+
value: untracked(() => {
49+
try {
50+
setInParamsFunction(true);
51+
return source();
52+
} finally {
53+
setInParamsFunction(false);
54+
}
55+
}),
56+
});
57+
58+
let active: Promise<void> | void | undefined;
59+
let pendingValue: T | undefined;
60+
61+
injector.get(DestroyRef).onDestroy(() => {
62+
active = undefined;
63+
});
64+
65+
effect(
66+
() => {
67+
// Enter error state if the source throws.
68+
let value: T;
69+
try {
70+
setInParamsFunction(true);
71+
value = source();
72+
} catch (err) {
73+
rethrowFatalErrors(err);
74+
state.set({status: 'error', error: err as Error});
75+
active = pendingValue = undefined;
76+
return;
77+
} finally {
78+
setInParamsFunction(false);
79+
}
80+
81+
const currentState = untracked(state);
82+
83+
// Check if the value is the same as the previous one.
84+
const equal = options?.equal ?? Object.is;
85+
if (currentState.status === 'reloading') {
86+
if (equal(value, pendingValue!)) return;
87+
} else if (currentState.status === 'resolved') {
88+
if (equal(value, currentState.value!)) return;
89+
}
90+
91+
const waitFn =
92+
typeof wait === 'number'
93+
? () => new Promise<void>((resolve) => setTimeout(resolve, wait))
94+
: wait;
95+
96+
const result = waitFn(value, currentState);
97+
98+
if (result === undefined) {
99+
// Synchronous case, go straight to resolved.
100+
state.set({status: 'resolved', value});
101+
active = pendingValue = undefined;
102+
} else {
103+
// Asynchronous case:
104+
// If we're in error state or loading state, remain in that state.
105+
// Otherwise, change to loading state but keep the current value until the new one loads.
106+
if (currentState.status !== 'loading' && currentState.status !== 'error') {
107+
state.set({status: 'loading', value: currentState.value});
108+
}
109+
active = result;
110+
pendingValue = value;
111+
112+
// Once the promise resolves, update the state to resolved.
113+
result.then(() => {
114+
if (active === result) {
115+
state.set({status: 'resolved', value});
116+
active = pendingValue = undefined;
117+
}
118+
});
119+
}
120+
},
121+
{injector},
122+
);
123+
124+
return resourceFromSnapshots(state);
125+
}

packages/core/src/resource/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
*/
88

99
export * from './api';
10+
export {debounced} from './debounce';
1011
export {resourceFromSnapshots} from './from_snapshots';
1112
export {resource} from './resource';

packages/core/src/resource/resource.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,20 @@ import {
1717
ResourceLoaderParams,
1818
ResourceOptions,
1919
ResourceParamsStatus,
20-
ResourceRef,
2120
ResourceSnapshot,
2221
ResourceStatus,
2322
ResourceStreamingLoader,
2423
ResourceStreamItem,
2524
StreamingResourceOptions,
26-
WritableResource,
2725
type ResourceParamsContext,
26+
type ResourceRef,
27+
type WritableResource,
2828
} from './api';
2929

3030
import {assertInInjectionContext} from '../di/contextual';
3131
import {Injector} from '../di/injector';
3232
import {inject} from '../di/injector_compatibility';
33+
import {RuntimeError, RuntimeErrorCode} from '../errors';
3334
import {DestroyRef} from '../linker/destroy_ref';
3435
import {PendingTasks} from '../pending_tasks';
3536
import {linkedSignal} from '../render3/reactivity/linked_signal';
@@ -205,6 +206,10 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
205206
injector: Injector,
206207
getInitialStream?: (request: R) => Signal<ResourceStreamItem<T>> | undefined,
207208
) {
209+
if (isInParamsFunction()) {
210+
throw invalidResourceCreationInParams();
211+
}
212+
208213
super(
209214
// Feed a computed signal for the value to `BaseWritableResource`, which will upgrade it to a
210215
// `WritableSignal` that delegates to `ResourceImpl.set`.
@@ -235,14 +240,18 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
235240
this.extRequest = linkedSignal<WrappedRequest>(
236241
() => {
237242
try {
243+
setInParamsFunction(true);
238244
return {request: request(paramsContext), reload: 0};
239245
} catch (error) {
246+
rethrowFatalErrors(error);
240247
if (error === ResourceParamsStatus.IDLE) {
241248
return {status: 'idle', reload: 0};
242249
} else if (error === ResourceParamsStatus.LOADING) {
243250
return {status: 'loading', reload: 0};
244251
}
245252
return {error: error as Error, reload: 0};
253+
} finally {
254+
setInParamsFunction(false);
246255
}
247256
},
248257
ngDevMode ? createDebugNameObject(debugName, 'extRequest') : undefined,
@@ -438,6 +447,7 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
438447
stream,
439448
});
440449
} catch (err) {
450+
rethrowFatalErrors(err);
441451
if (abortSignal.aborted || untracked(this.extRequest) !== extRequest) {
442452
return;
443453
}
@@ -589,3 +599,29 @@ export const paramsContext: ResourceParamsContext = {
589599
return resource.value();
590600
},
591601
};
602+
603+
let inParamsFunction = false;
604+
605+
export function isInParamsFunction() {
606+
return inParamsFunction;
607+
}
608+
609+
export function setInParamsFunction(value: boolean) {
610+
inParamsFunction = value;
611+
}
612+
613+
export function invalidResourceCreationInParams(): Error {
614+
return new RuntimeError(
615+
RuntimeErrorCode.INVALID_RESOURCE_CREATION_IN_PARAMS,
616+
ngDevMode && `Cannot create a resource inside the \`params\` of another resource`,
617+
);
618+
}
619+
620+
export function rethrowFatalErrors(error: unknown) {
621+
if (
622+
error instanceof RuntimeError &&
623+
error.code === RuntimeErrorCode.INVALID_RESOURCE_CREATION_IN_PARAMS
624+
) {
625+
throw error;
626+
}
627+
}

0 commit comments

Comments
 (0)