Skip to content

Commit

Permalink
feat(store): add selectSignal method for interop with Angular Signals (
Browse files Browse the repository at this point in the history
  • Loading branch information
brandonroberts authored Apr 25, 2023
1 parent 0df3419 commit 999dcb6
Show file tree
Hide file tree
Showing 9 changed files with 1,099 additions and 234 deletions.
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

0 comments on commit 999dcb6

Please sign in to comment.