Skip to content

Commit 07ba3fa

Browse files
feat(component-store): add selectSignal signature that combines provided signals (#3863)
1 parent d604695 commit 07ba3fa

File tree

2 files changed

+102
-12
lines changed

2 files changed

+102
-12
lines changed

modules/component-store/spec/component-store.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,6 +1381,62 @@ describe('Component Store', () => {
13811381
});
13821382
});
13831383

1384+
describe('selectSignal', () => {
1385+
it('creates a signal from the provided state projector function', () => {
1386+
const store = new ComponentStore<{ foo: string }>({ foo: 'bar' });
1387+
let projectorExecutionCount = 0;
1388+
1389+
const foo = store.selectSignal((state) => {
1390+
projectorExecutionCount++;
1391+
return state.foo;
1392+
});
1393+
1394+
expect(foo()).toBe('bar');
1395+
1396+
foo();
1397+
store.patchState({ foo: 'baz' });
1398+
foo();
1399+
1400+
expect(foo()).toBe('baz');
1401+
expect(projectorExecutionCount).toBe(2);
1402+
});
1403+
1404+
it('creates a signal by combining provided signals', () => {
1405+
const store = new ComponentStore<{ x: number; y: number; z: number }>({
1406+
x: 1,
1407+
y: 10,
1408+
z: 100,
1409+
});
1410+
let projectorExecutionCount = 0;
1411+
1412+
const x = store.selectSignal((s) => s.x);
1413+
const y = store.selectSignal((s) => s.y);
1414+
const xPlusY = store.selectSignal(x, y, (x, y) => {
1415+
projectorExecutionCount++;
1416+
return x + y;
1417+
});
1418+
1419+
expect(xPlusY()).toBe(11);
1420+
1421+
// projector should not be executed
1422+
store.patchState({ z: 1000 });
1423+
xPlusY();
1424+
1425+
store.patchState({ x: 10 });
1426+
xPlusY();
1427+
1428+
expect(xPlusY()).toBe(20);
1429+
expect(projectorExecutionCount).toBe(2);
1430+
});
1431+
1432+
it('throws an error when the signal is read before the state initialization', () => {
1433+
const store = new ComponentStore<{ foo: string }>();
1434+
const foo = store.selectSignal((s) => s.foo);
1435+
1436+
expect(() => foo()).toThrowError();
1437+
});
1438+
});
1439+
13841440
describe('effect', () => {
13851441
let componentStore: ComponentStore<object>;
13861442

modules/component-store/src/component-store.ts

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ export type Projector<Selectors extends Observable<unknown>[], Result> = (
5656
...args: SelectorResults<Selectors>
5757
) => Result;
5858

59+
type SignalsProjector<Signals extends Signal<unknown>[], Result> = (
60+
...values: {
61+
[Key in keyof Signals]: Signals[Key] extends Signal<infer Value>
62+
? Value
63+
: never;
64+
}
65+
) => Result;
66+
5967
@Injectable()
6068
export class ComponentStore<T extends object> implements OnDestroy {
6169
// Should be used only in ngOnDestroy.
@@ -67,10 +75,12 @@ export class ComponentStore<T extends object> implements OnDestroy {
6775
private isInitialized = false;
6876
// Needs to be after destroy$ is declared because it's used in select.
6977
readonly state$: Observable<T> = this.select((s) => s);
70-
private ɵhasProvider = false;
71-
7278
// Signal of state$
73-
readonly state: Signal<T>;
79+
readonly state: Signal<T> = toSignal(
80+
this.state$.pipe(takeUntil(this.destroy$)),
81+
{ requireSync: false, manualCleanup: true }
82+
);
83+
private ɵhasProvider = false;
7484

7585
constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) {
7686
// State can be initialized either through constructor or setState.
@@ -79,10 +89,6 @@ export class ComponentStore<T extends object> implements OnDestroy {
7989
}
8090

8191
this.checkProviderForHooks();
82-
this.state = toSignal(this.stateSubject$.pipe(takeUntil(this.destroy$)), {
83-
requireSync: false,
84-
manualCleanup: true,
85-
});
8692
}
8793

8894
/** Completes all relevant Observable streams. */
@@ -293,12 +299,40 @@ export class ComponentStore<T extends object> implements OnDestroy {
293299
}
294300

295301
/**
296-
* Returns a signal of the provided projector function.
297-
*
298-
* @param projector projector function
302+
* Creates a signal from the provided state projector function.
303+
*/
304+
selectSignal<Result>(projector: (state: T) => Result): Signal<Result>;
305+
/**
306+
* Creates a signal by combining provided signals.
299307
*/
300-
selectSignal<K>(projector: (state: T) => K): Signal<K> {
301-
return computed(() => projector(this.state()));
308+
selectSignal<Signals extends Signal<unknown>[], Result>(
309+
...signalsWithProjector: [
310+
...selectors: Signals,
311+
projector: SignalsProjector<Signals, Result>
312+
]
313+
): Signal<Result>;
314+
selectSignal(
315+
...args:
316+
| [(state: T) => unknown]
317+
| [
318+
...signals: Signal<unknown>[],
319+
projector: (...values: unknown[]) => unknown
320+
]
321+
): Signal<unknown> {
322+
if (args.length === 1) {
323+
const projector = args[0] as (state: T) => unknown;
324+
return computed(() => projector(this.state()));
325+
}
326+
327+
const signals = args.slice(0, -1) as Signal<unknown>[];
328+
const projector = args[args.length - 1] as (
329+
...values: unknown[]
330+
) => unknown;
331+
332+
return computed(() => {
333+
const values = signals.map((signal) => signal());
334+
return projector(...values);
335+
});
302336
}
303337

304338
/**

0 commit comments

Comments
 (0)