Skip to content

Commit 195d5ac

Browse files
feat(component-store): add selectSignal method for interop with Angular Signals (#3861)
1 parent 16d92ac commit 195d5ac

File tree

4 files changed

+300
-2
lines changed

4 files changed

+300
-2
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { ComponentStore } from '@ngrx/component-store';
2+
3+
export const VisibilityFilters = {
4+
SHOW_ALL: 'SHOW_ALL',
5+
SHOW_COMPLETED: 'SHOW_COMPLETED',
6+
SHOW_ACTIVE: 'SHOW_ACTIVE',
7+
};
8+
9+
interface Todo {
10+
id: number;
11+
text: string;
12+
completed: boolean;
13+
}
14+
15+
interface TodoState {
16+
visibilityFilter: string;
17+
todos: Todo[];
18+
}
19+
20+
describe('NgRx ComponentStore and Signals Integration spec', () => {
21+
let store: ComponentStore<TodoState>;
22+
23+
const initialState = {
24+
todos: [],
25+
visibilityFilter: VisibilityFilters.SHOW_ALL,
26+
};
27+
28+
beforeEach(() => {
29+
store = new ComponentStore<TodoState>(initialState);
30+
});
31+
32+
describe('todo integration spec', function () {
33+
describe('using the store.selectSignal', () => {
34+
it('should use visibilityFilter to filter todos', () => {
35+
store.setState((state) => {
36+
return {
37+
...state,
38+
todos: [
39+
{ id: 1, text: 'first todo', completed: false },
40+
{ id: 2, text: 'second todo', completed: false },
41+
],
42+
};
43+
});
44+
45+
const filterVisibleTodos = (
46+
visibilityFilter: string,
47+
todos: Todo[]
48+
) => {
49+
let predicate;
50+
if (visibilityFilter === VisibilityFilters.SHOW_ALL) {
51+
predicate = () => true;
52+
} else if (visibilityFilter === VisibilityFilters.SHOW_ACTIVE) {
53+
predicate = (todo: any) => !todo.completed;
54+
} else {
55+
predicate = (todo: any) => todo.completed;
56+
}
57+
return todos.filter(predicate);
58+
};
59+
60+
const filter = () => store.state().visibilityFilter;
61+
const todos = store.selectSignal((state) => state.todos);
62+
const currentlyVisibleTodos = () =>
63+
filterVisibleTodos(filter(), todos());
64+
65+
expect(currentlyVisibleTodos().length).toBe(2);
66+
67+
store.setState((state) => {
68+
const todos = state.todos.map((todo) => {
69+
if (todo.id === 1) {
70+
return { ...todo, completed: true };
71+
}
72+
73+
return todo;
74+
});
75+
76+
return {
77+
...state,
78+
visibilityFilter: VisibilityFilters.SHOW_ACTIVE,
79+
todos,
80+
};
81+
});
82+
83+
expect(currentlyVisibleTodos().length).toBe(1);
84+
expect(currentlyVisibleTodos()[0].completed).toBe(false);
85+
86+
store.setState((state) => ({
87+
...state,
88+
visibilityFilter: VisibilityFilters.SHOW_COMPLETED,
89+
}));
90+
91+
expect(currentlyVisibleTodos().length).toBe(1);
92+
expect(currentlyVisibleTodos()[0].completed).toBe(true);
93+
94+
store.setState((state) => {
95+
const todos = state.todos.map((todo) => {
96+
return { ...todo, completed: true };
97+
});
98+
99+
return {
100+
...state,
101+
todos,
102+
};
103+
});
104+
105+
expect(currentlyVisibleTodos().length).toBe(2);
106+
expect(currentlyVisibleTodos()[0].completed).toBe(true);
107+
expect(currentlyVisibleTodos()[1].completed).toBe(true);
108+
109+
store.setState((state) => ({
110+
...state,
111+
visibilityFilter: VisibilityFilters.SHOW_ACTIVE,
112+
}));
113+
114+
expect(currentlyVisibleTodos().length).toBe(0);
115+
});
116+
});
117+
});
118+
119+
describe('context integration spec', () => {
120+
it('ComponentStore.selectSignal should not throw an error if used outside in the injection context', () => {
121+
let error;
122+
123+
try {
124+
store.selectSignal((state) => state.todos);
125+
} catch (e) {
126+
error = `${e}`;
127+
}
128+
129+
expect(error).toBeUndefined();
130+
});
131+
});
132+
});

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ import {
3232
InjectionToken,
3333
Inject,
3434
isDevMode,
35+
Signal,
36+
computed,
3537
} from '@angular/core';
3638
import { isOnStateInitDefined, isOnStoreInitDefined } from './lifecycle_hooks';
39+
import { toSignal } from './to-signal';
3740

3841
export interface SelectConfig {
3942
debounce?: boolean;
@@ -66,13 +69,20 @@ export class ComponentStore<T extends object> implements OnDestroy {
6669
readonly state$: Observable<T> = this.select((s) => s);
6770
private ɵhasProvider = false;
6871

72+
// Signal of state$
73+
readonly state: Signal<T>;
74+
6975
constructor(@Optional() @Inject(INITIAL_STATE_TOKEN) defaultState?: T) {
7076
// State can be initialized either through constructor or setState.
7177
if (defaultState) {
7278
this.initState(defaultState);
7379
}
7480

7581
this.checkProviderForHooks();
82+
this.state = toSignal(this.stateSubject$.pipe(takeUntil(this.destroy$)), {
83+
requireSync: false,
84+
manualCleanup: true,
85+
});
7686
}
7787

7888
/** Completes all relevant Observable streams. */
@@ -282,6 +292,15 @@ export class ComponentStore<T extends object> implements OnDestroy {
282292
);
283293
}
284294

295+
/**
296+
* Returns a signal of the provided projector function.
297+
*
298+
* @param projector projector function
299+
*/
300+
selectSignal<K>(projector: (state: T) => K): Signal<K> {
301+
return computed(() => projector(this.state()));
302+
}
303+
285304
/**
286305
* Creates an effect.
287306
*
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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.io/license
7+
*/
8+
9+
import {
10+
assertInInjectionContext,
11+
computed,
12+
DestroyRef,
13+
inject,
14+
isDevMode,
15+
signal,
16+
Signal,
17+
untracked,
18+
WritableSignal,
19+
} from '@angular/core';
20+
import { Observable } from 'rxjs';
21+
22+
/**
23+
* Get the current value of an `State` as a reactive `Signal`.
24+
*
25+
* `toSignal` returns a `Signal` which provides synchronous reactive access to values produced
26+
* by the `State`, by subscribing to that `State`. The returned `Signal` will always
27+
* have the most recent value emitted by the subscription, and will throw an error if the
28+
* `State` errors.
29+
*
30+
* The subscription will last for the lifetime of the application itself.
31+
*
32+
* This function is for internal use only as it differs from the `toSignal`
33+
* provided by the `@angular/core/rxjs-interop` package with relying on
34+
* the injection context to unsubscribe from the provided observable.
35+
*
36+
*/
37+
export function toSignal<T, U extends T | null | undefined>(
38+
// toSignal(Observable<Animal>, {initialValue: null}) -> Signal<Animal|null>
39+
source: Observable<T>,
40+
options: { initialValue: U; requireSync?: false; manualCleanup?: boolean }
41+
): Signal<T | U>;
42+
export function toSignal<T>(
43+
// toSignal(Observable<Animal>, {requireSync: true}) -> Signal<Animal>
44+
source: Observable<T>,
45+
options: { requireSync: boolean; manualCleanup?: boolean }
46+
): Signal<T>;
47+
// toSignal(Observable<Animal>) -> Signal<Animal|undefined>
48+
export function toSignal<T, U = undefined>(
49+
source: Observable<T>,
50+
options?: { initialValue?: U; requireSync?: boolean; manualCleanup?: boolean }
51+
): Signal<T | U> {
52+
if (!options?.manualCleanup) {
53+
assertInInjectionContext(toSignal);
54+
}
55+
56+
// Note: T is the Observable value type, and U is the initial value type. They don't have to be
57+
// the same - the returned signal gives values of type `T`.
58+
let state: WritableSignal<State<T | U>>;
59+
if (options?.requireSync) {
60+
// Initially the signal is in a `NoValue` state.
61+
state = signal({ kind: StateKind.NoValue });
62+
} else {
63+
// If an initial value was passed, use it. Otherwise, use `undefined` as the initial value.
64+
state = signal<State<T | U>>({
65+
kind: StateKind.Value,
66+
value: options?.initialValue as U,
67+
});
68+
}
69+
70+
const sub = source.subscribe({
71+
next: (value) => state.set({ kind: StateKind.Value, value }),
72+
error: (error) => state.set({ kind: StateKind.Error, error }),
73+
// Completion of the Observable is meaningless to the signal. Signals don't have a concept of
74+
// "complete".
75+
});
76+
77+
if (
78+
isDevMode() &&
79+
options?.requireSync &&
80+
untracked(state).kind === StateKind.NoValue
81+
) {
82+
throw new Error(
83+
'`@ngrx/component-store: toSignal()` called with `requireSync` but `Observable` did not emit synchronously.'
84+
);
85+
}
86+
87+
if (!options?.manualCleanup) {
88+
// Unsubscribe when the current context is destroyed.
89+
inject(DestroyRef).onDestroy(sub.unsubscribe.bind(sub));
90+
}
91+
92+
// The actual returned signal is a `computed` of the `State` signal, which maps the various states
93+
// to either values or errors.
94+
return computed(() => {
95+
const current = state();
96+
switch (current.kind) {
97+
case StateKind.Value:
98+
return current.value;
99+
case StateKind.Error:
100+
throw current.error;
101+
case StateKind.NoValue:
102+
// This shouldn't really happen because the error is thrown on creation.
103+
// TODO(alxhub): use a RuntimeError when we finalize the error semantics
104+
throw new Error(
105+
'@ngrx/component-store: The initial state must set before reading the signal.'
106+
);
107+
}
108+
});
109+
}
110+
111+
const enum StateKind {
112+
NoValue,
113+
Value,
114+
Error,
115+
}
116+
117+
interface NoValueState {
118+
kind: StateKind.NoValue;
119+
}
120+
121+
interface ValueState<T> {
122+
kind: StateKind.Value;
123+
value: T;
124+
}
125+
126+
interface ErrorState {
127+
kind: StateKind.Error;
128+
error: unknown;
129+
}
130+
131+
type State<T> = NoValueState | ValueState<T> | ErrorState;
Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
1-
import { Component } from '@angular/core';
1+
import { Component, inject, signal } from '@angular/core';
22
import { RouterModule } from '@angular/router';
3+
import {
4+
ComponentStore,
5+
INITIAL_STATE_TOKEN,
6+
provideComponentStore,
7+
} from '@ngrx/component-store';
38

49
@Component({
510
selector: 'ngrx-root',
611
standalone: true,
712
imports: [RouterModule],
813
template: `
9-
<h1>Welcome {{ title }}</h1>
14+
<h1>Welcome {{ title }} {{ val() }}</h1>
1015
1116
<a routerLink="/feature">Load Feature</a>
1217
1318
<router-outlet></router-outlet>
1419
`,
20+
providers: [
21+
provideComponentStore(ComponentStore),
22+
{ provide: INITIAL_STATE_TOKEN, useValue: { test: true } },
23+
],
1524
})
1625
export class AppComponent {
1726
title = 'ngrx-standalone-app';
27+
cs = inject(ComponentStore<{ test: number }>);
28+
num = signal(1);
29+
val = this.cs.selectSignal((s) => s.test);
30+
31+
ngOnInit() {
32+
this.num.set(2);
33+
}
1834
}

0 commit comments

Comments
 (0)