Skip to content
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

feat(store): add selectSignal method for interop with Angular Signals #3856

Merged
merged 2 commits into from
Apr 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions modules/store/spec/integration_signals.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { TestBed } from '@angular/core/testing';
import { ActionReducerMap, Store, provideStore } from '@ngrx/store';

import { State } from '../src/private_export';
import {
ADD_TODO,
COMPLETE_ALL_TODOS,
COMPLETE_TODO,
SET_VISIBILITY_FILTER,
todos,
visibilityFilter,
VisibilityFilters,
resetId,
} from './fixtures/todos';

interface Todo {
id: number;
text: string;
completed: boolean;
}

interface TodoAppSchema {
visibilityFilter: string;
todos: Todo[];
}

describe('NgRx and Signals Integration spec', () => {
let store: Store<TodoAppSchema>;
let state: State<TodoAppSchema>;

const initialState = {
todos: [],
visibilityFilter: VisibilityFilters.SHOW_ALL,
};
const reducers: ActionReducerMap<TodoAppSchema, any> = {
todos: todos,
visibilityFilter: visibilityFilter,
};

beforeEach(() => {
resetId();
spyOn(reducers, 'todos').and.callThrough();

TestBed.configureTestingModule({
providers: [provideStore(reducers, { initialState })],
});

store = TestBed.inject(Store);
state = TestBed.inject(State);
});

describe('todo integration spec', function () {
describe('using the store.selectSignal', () => {
it('should use visibilityFilter to filter todos', () => {
store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } });
store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } });
store.dispatch({
type: COMPLETE_TODO,
payload: { id: state.value.todos[0].id },
});

const filterVisibleTodos = (
visibilityFilter: string,
todos: Todo[]
) => {
let predicate;
if (visibilityFilter === VisibilityFilters.SHOW_ALL) {
predicate = () => true;
} else if (visibilityFilter === VisibilityFilters.SHOW_ACTIVE) {
predicate = (todo: any) => !todo.completed;
} else {
predicate = (todo: any) => todo.completed;
}
return todos.filter(predicate);
};

const filter = TestBed.runInInjectionContext(() =>
store.selectSignal((state) => state.visibilityFilter)
);
const todos = TestBed.runInInjectionContext(() =>
store.selectSignal((state) => state.todos)
);
const currentlyVisibleTodos = () =>
filterVisibleTodos(filter(), todos());

expect(currentlyVisibleTodos().length).toBe(2);

store.dispatch({
type: SET_VISIBILITY_FILTER,
payload: VisibilityFilters.SHOW_ACTIVE,
});

expect(currentlyVisibleTodos().length).toBe(1);
expect(currentlyVisibleTodos()[0].completed).toBe(false);

store.dispatch({
type: SET_VISIBILITY_FILTER,
payload: VisibilityFilters.SHOW_COMPLETED,
});

expect(currentlyVisibleTodos().length).toBe(1);
expect(currentlyVisibleTodos()[0].completed).toBe(true);

store.dispatch({ type: COMPLETE_ALL_TODOS });

expect(currentlyVisibleTodos().length).toBe(2);
expect(currentlyVisibleTodos()[0].completed).toBe(true);
expect(currentlyVisibleTodos()[1].completed).toBe(true);

store.dispatch({
type: SET_VISIBILITY_FILTER,
payload: VisibilityFilters.SHOW_ACTIVE,
});

expect(currentlyVisibleTodos().length).toBe(0);
});
});
});

describe('context integration spec', () => {
it('Store.selectSignal should not throw an error if used outside in the injection context', () => {
let error;

try {
store.selectSignal((state) => state.todos);
} catch (e) {
error = `${e}`;
}

expect(error).toBeUndefined();
});
});
});
58 changes: 58 additions & 0 deletions modules/store/spec/types/select_signal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expecter } from 'ts-snippet';
import { compilerOptions } from './utils';

describe('Store.selectSignal()', () => {
const expectSnippet = expecter(
(code) => `
import { Store, createSelector, createFeatureSelector } from '@ngrx/store';

interface State { foo: { bar: { baz: [] } } };
const store = {} as Store<State>;
const fooSelector = createFeatureSelector<State, State['foo']>('foo')
const barSelector = createSelector(fooSelector, s => s.bar)

${code}
`,
compilerOptions()
);

describe('as property', () => {
describe('with functions', () => {
it('should enforce that properties exists on state (root)', () => {
expectSnippet(
`const selector = store.selectSignal(s => s.mia);`
).toFail(/Property 'mia' does not exist on type 'State'/);
});

it('should enforce that properties exists on state (nested)', () => {
expectSnippet(
`const selector = store.selectSignal(s => s.foo.bar.mia);`
).toFail(/Property 'mia' does not exist on type '\{ baz: \[\]; \}'/);
});

it('should infer correctly (root)', () => {
expectSnippet(
`const selector = store.selectSignal(s => s.foo);`
).toInfer('selector', 'Signal<{ bar: { baz: []; }; }>');
});

it('should infer correctly (nested)', () => {
expectSnippet(
`const selector = store.selectSignal(s => s.foo.bar);`
).toInfer('selector', 'Signal<{ baz: []; }>');
});
});

describe('with selectors', () => {
it('should infer correctly', () => {
expectSnippet(
`const selector = store.selectSignal(fooSelector);`
).toInfer('selector', 'Signal<{ bar: { baz: []; }; }>');

expectSnippet(
`const selector = store.selectSignal(barSelector);`
).toInfer('selector', 'Signal<{ baz: []; }>');
});
});
});
});
16 changes: 15 additions & 1 deletion modules/store/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
// disabled because we have lowercase generics for `select`
import { Injectable, Provider } from '@angular/core';
import { computed, Injectable, Provider, Signal } from '@angular/core';
import { Observable, Observer, Operator } from 'rxjs';
import { distinctUntilChanged, map, pluck } from 'rxjs/operators';

import { ActionsSubject } from './actions_subject';
import { Action, ActionReducer, FunctionIsNotAllowed } from './models';
import { ReducerManager } from './reducer_manager';
import { StateObservable } from './state';
import { toSignal } from './to_signal';

@Injectable()
export class Store<T = object>
extends Observable<T>
implements Observer<Action>
{
private readonly state: Signal<T>;

constructor(
state$: StateObservable,
private actionsObserver: ActionsSubject,
Expand All @@ -21,6 +24,7 @@ export class Store<T = object>
super();

this.source = state$;
this.state = toSignal(state$);
}

select<K>(mapFn: (state: T) => K): Observable<K>;
Expand Down Expand Up @@ -93,6 +97,16 @@ export class Store<T = object>
return (select as any).call(null, pathOrMapFn, ...paths)(this);
}

/**
* Returns a signal of the provided selector.
* This method must be run within in injection context.
*
* @param selectorFn selector function
*/
selectSignal<K>(selector: (state: T) => K): Signal<K> {
return computed(() => selector(this.state()));
}

override lift<R>(operator: Operator<T, R>): Store<R> {
const store = new Store<R>(this, this.actionsObserver, this.reducerManager);
store.operator = operator;
Expand Down
54 changes: 54 additions & 0 deletions modules/store/src/to_signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { computed, signal, Signal } from '@angular/core';

import { StateObservable } from './state';

/**
* Get the current value of an `StateObservable` as a reactive `Signal`.
*
* `toSignal` returns a `Signal` which provides synchronous reactive access to values produced
* by the `StateObservable`, by subscribing to that `StateObservable`. The returned `Signal` will always
* have the most recent value emitted by the subscription, and will throw an error if the
* `StateObservable` errors.
*
* The subscription will last for the lifetime of the application itself.
*
* This function is for internal use only as it differs from the `toSignal`
* provided by the `@angular/core/rxjs-interop` package with relying on
* the injection context to unsubscribe from the provided observable.
*
*/
export function toSignal<T>(state$: StateObservable): Signal<T> {
const state = signal<State<T>>({ kind: StateKind.NoValue });

state$.subscribe({
next: (value) => state.set({ kind: StateKind.Value, value }),
error: (error) => state.set({ kind: StateKind.Error, error }),
});

return computed(() => {
const currentState = state();

switch (currentState.kind) {
case StateKind.Value:
return currentState.value;
case StateKind.Error:
throw currentState.error;
case StateKind.NoValue:
throw new Error(
'@ngrx/store: The state observable must emit the initial value synchronously'
);
}
});
}

enum StateKind {
NoValue,
Value,
Error,
}

type NoValueState = { kind: StateKind.NoValue };
type ValueState<T> = { kind: StateKind.Value; value: T };
type ErrorState = { kind: StateKind.Error; error: unknown };

type State<T> = NoValueState | ValueState<T> | ErrorState;
38 changes: 19 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,17 @@
]
},
"dependencies": {
"@angular/animations": "16.0.0-next.6",
"@angular/cdk": "16.0.0-next.4",
"@angular/common": "16.0.0-next.6",
"@angular/compiler": "16.0.0-next.6",
"@angular/core": "16.0.0-next.6",
"@angular/forms": "16.0.0-next.6",
"@angular/material": "16.0.0-next.4",
"@angular/platform-browser": "16.0.0-next.6",
"@angular/platform-browser-dynamic": "16.0.0-next.6",
"@angular/platform-server": "16.0.0-next.6",
"@angular/router": "16.0.0-next.6",
"@angular/animations": "^16.0.0-rc.0",
"@angular/cdk": "^16.0.0-rc.0",
"@angular/common": "^16.0.0-rc.0",
"@angular/compiler": "^16.0.0-rc.0",
"@angular/core": "^16.0.0-rc.0",
"@angular/forms": "^16.0.0-rc.0",
"@angular/material": "^16.0.0-rc.0",
"@angular/platform-browser": "^16.0.0-rc.0",
"@angular/platform-browser-dynamic": "^16.0.0-rc.0",
"@angular/platform-server": "^16.0.0-rc.0",
"@angular/router": "^16.0.0-rc.0",
"@nrwl/angular": "15.8.7",
"core-js": "^2.5.4",
"eslint-etc": "^5.1.0",
Expand All @@ -87,17 +87,17 @@
"zone.js": "0.13.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "16.0.0-next.6",
"@angular-devkit/core": "16.0.0-next.6",
"@angular-devkit/schematics": "16.0.0-next.6",
"@angular-devkit/build-angular": "^16.0.0-rc.0",
"@angular-devkit/core": "^16.0.0-rc.0",
"@angular-devkit/schematics": "^16.0.0-rc.0",
"@angular-eslint/builder": "15.2.1",
"@angular-eslint/eslint-plugin": "15.2.1",
"@angular-eslint/eslint-plugin-template": "15.2.1",
"@angular-eslint/schematics": "15.2.1",
"@angular-eslint/template-parser": "15.2.1",
"@angular/cli": "16.0.0-next.6",
"@angular/compiler-cli": "16.0.0-next.6",
"@angular/language-service": "16.0.0-next.6",
"@angular/cli": "^16.0.0-rc.0",
"@angular/compiler-cli": "^16.0.0-rc.0",
"@angular/language-service": "^16.0.0-rc.0",
"@babel/core": "7.9.0",
"@nrwl/cli": "15.8.7",
"@nrwl/cypress": "15.8.7",
Expand All @@ -109,7 +109,7 @@
"@nrwl/tao": "15.8.7",
"@nrwl/workspace": "15.8.7",
"@octokit/rest": "^15.17.0",
"@schematics/angular": "16.0.0-next.6",
"@schematics/angular": "^16.0.0-rc.0",
"@testing-library/cypress": "9.0.0",
"@types/fs-extra": "^2.1.0",
"@types/glob": "^5.0.33",
Expand Down Expand Up @@ -160,7 +160,7 @@
"karma-jasmine-html-reporter": "2.0.0",
"lint-staged": "^8.0.0",
"ncp": "^2.0.0",
"ng-packagr": "16.0.0-next.2",
"ng-packagr": "^16.0.0-rc.0",
"npm-run-all": "^4.1.5",
"nx": "15.8.7",
"nyc": "^10.1.2",
Expand Down
Loading