= K | null | "undefined" | "delete";
20 |
21 | export interface ActionDispatch {
22 | actionName: string;
23 | actionPayload: P;
24 | }
25 |
26 | export interface StateChangeNotification {
27 | actionName: string | undefined;
28 | actionPayload: any;
29 | newState: S;
30 | }
31 |
--------------------------------------------------------------------------------
/test/test_action.ts:
--------------------------------------------------------------------------------
1 | import { expect } from "chai";
2 | import "mocha";
3 | import { Subject } from "rxjs";
4 | import { Reducer, Store } from "../src/index";
5 |
6 | describe("Action tests", () => {
7 | interface GenericState {
8 | value?: any;
9 | }
10 | class Foo {}
11 |
12 | let store: Store;
13 | let genericAction: Subject;
14 | let genericReducer: Reducer;
15 |
16 | beforeEach(() => {
17 | store = Store.create({ value: undefined });
18 | genericAction = new Subject();
19 | genericReducer = (state, payload) => ({ ...state, value: payload });
20 | store.addReducer(genericAction, genericReducer);
21 | });
22 |
23 | it("should not throw an error when an action emits a non-plain object", () => {
24 | expect(() => genericAction.next(new Foo())).not.to.throw();
25 | });
26 |
27 | // Should be ok for primitive types
28 | it("should not throw an error when an action emits a plain object", () => {
29 | expect(() => genericAction.next({})).not.to.throw();
30 | });
31 |
32 | it("should not throw an error when an action emits an array", () => {
33 | expect(() => genericAction.next([])).not.to.throw();
34 | });
35 |
36 | it("should not throw an error when an action emits a string", () => {
37 | expect(() => genericAction.next("foobar")).not.to.throw();
38 | });
39 |
40 | it("should not throw an error when an action emits a number", () => {
41 | expect(() => genericAction.next(5)).not.to.throw();
42 | });
43 |
44 | it("should not throw an error when an action emits a boolean", () => {
45 | expect(() => genericAction.next(false)).not.to.throw();
46 | });
47 |
48 | it("should not throw an error when an action emits null", () => {
49 | expect(() => genericAction.next(null)).not.to.throw();
50 | });
51 |
52 | it("should not throw an error when an action emits undefined", () => {
53 | expect(() => genericAction.next(undefined)).not.to.throw();
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/shallowEqual.ts:
--------------------------------------------------------------------------------
1 | // shallowEqual taken from Facebooks fbjs util and converted to typescript from flow
2 | // since this is used in react 16.x we trust FB that it works and disable code coverage here
3 |
4 | /**
5 | * Copyright (c) 2013-present, Facebook, Inc.
6 | *
7 | * This source code is licensed under the MIT license found in the
8 | * LICENSE file in the root directory of this source tree.
9 | */
10 |
11 | const hasOwnProperty = Object.prototype.hasOwnProperty;
12 |
13 | /**
14 | * inlined Object.is polyfill to avoid requiring consumers ship their own
15 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
16 | */
17 | /* istanbul ignore next */
18 | function is(x: any, y: any): boolean {
19 | // SameValue algorithm
20 | if (x === y) {
21 | // Steps 1-5, 7-10
22 | // Steps 6.b-6.e: +0 != -0
23 | // Added the nonzero y check to make Flow happy, but it is redundant
24 | return x !== 0 || y !== 0 || 1 / x === 1 / y;
25 | } else {
26 | // Step 6.a: NaN == NaN
27 | return x !== x && y !== y;
28 | }
29 | }
30 |
31 | /**
32 | * Performs equality by iterating through keys on an object and returning false
33 | * when any key has values which are not strictly equal between the arguments.
34 | * Returns true when the values of all keys are strictly equal.
35 | */
36 | /* istanbul ignore next */
37 | export function shallowEqual(objA: any, objB: any): boolean {
38 | if (is(objA, objB)) {
39 | return true;
40 | }
41 |
42 | if (typeof objA !== "object" || objA === null || typeof objB !== "object" || objB === null) {
43 | return false;
44 | }
45 |
46 | const keysA = Object.keys(objA);
47 | const keysB = Object.keys(objB);
48 |
49 | if (keysA.length !== keysB.length) {
50 | return false;
51 | }
52 |
53 | // Test for A's keys different from B.
54 | for (let i = 0; i < keysA.length; i++) {
55 | if (!hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) {
56 | return false;
57 | }
58 | }
59 |
60 | return true;
61 | }
62 |
--------------------------------------------------------------------------------
/react/actions.tsx:
--------------------------------------------------------------------------------
1 | import { Observer, Observable } from "rxjs";
2 | import { ExtractProps } from "./connect";
3 |
4 | // Taken from the TypeScript docs, allows to extract all functions of a type
5 | export type FunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T];
6 | export type FunctionProperties = Pick>;
7 |
8 | // Type that can be used to extract the first argument type of a function
9 | export type UnaryFunction = (t: T, ...args: any[]) => any;
10 |
11 | // This will be a function that dispatches actions, but should not return anything
12 | export type ActionFunction = (...args: any[]) => any;
13 |
14 | // An ActionMap is a map with a list of properties, that are functions in the component props, and assigns these properties
15 | // either a ActionFunction or an Observer
16 | export type ActionMap = {
17 | [P in keyof FunctionProperties>]?:
18 | | ActionFunction
19 | | Observer>[P] extends UnaryFunction ? A : never>
20 | };
21 |
22 | /**
23 | * A map specifying which property on the components state should be populated with
24 | * the value of the map value (=Observable)
25 | *
26 | * @example
27 | * const map = {
28 | * secondsPassed: Observable.interval(1000)
29 | * }
30 | */
31 | export type UnpackMap = { [P in keyof TComponentState]?: Observable };
32 |
33 | export function assembleActionProps(actionMap: ActionMap): Partial {
34 | const actionProps: any = {};
35 | for (let ownProp in actionMap) {
36 | const field = (actionMap as any)[ownProp];
37 | const observerField = field as Observer;
38 |
39 | if (field === undefined) continue;
40 |
41 | if (typeof field === "function") {
42 | let func = (actionMap as any)[ownProp];
43 | actionProps[ownProp] = func;
44 | }
45 | // check if its an observable - TODO typeguard?
46 | else if (typeof observerField.next === "function") {
47 | actionProps[ownProp] = (arg1: any, ...args: any[]) => observerField.next(arg1);
48 | } else {
49 | throw new Error(
50 | `unknown property value for property named "${ownProp}" in action map. Expected function or Observer`,
51 | );
52 | }
53 | }
54 | return actionProps;
55 | }
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reactive-state",
3 | "version": "3.7.1",
4 | "description": "Redux-like state management using RxJS and TypeScript",
5 | "main": "src/index.js",
6 | "files": [
7 | "src/**/*.js",
8 | "src/**/*.js.map",
9 | "src/**/*.d.ts",
10 | "react/**/*.js",
11 | "react/**/*.js.map",
12 | "react/**/*.d.ts"
13 | ],
14 | "types": "src/index.d.ts",
15 | "sideEffects": false,
16 | "scripts": {
17 | "build": "tsc",
18 | "build-tests": "tsc -p test",
19 | "bundle": "webpack",
20 | "coverage": "node node_modules/.bin/istanbul cover _mocha -- test/test",
21 | "coveralls": "istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage",
22 | "prepublishOnly": "npm run build",
23 | "prettier": "prettier --write {src,test,react}/**/*.{ts,tsx}",
24 | "watch": "tsc -w --preserveWatchOutput",
25 | "watch-tests": "tsc -w -p test --preserveWatchOutput",
26 | "develop": "concurrently \"npm run watch\" \"npm run watch-tests\" ",
27 | "run-tests": "mocha --timeout 10000 test/test.js",
28 | "test": "npm run build-tests && npm run coverage"
29 | },
30 | "repository": {
31 | "type": "git",
32 | "url": "git+https://github.com/Dynalon/reactive-state.git"
33 | },
34 | "keywords": [
35 | "Redux",
36 | "State",
37 | "reactive",
38 | "RxJS",
39 | "store",
40 | "React"
41 | ],
42 | "author": "Timo Dörr",
43 | "license": "MIT",
44 | "bugs": {
45 | "url": "https://github.com/Dynalon/reactive-state/issues"
46 | },
47 | "homepage": "https://github.com/Dynalon/reactive-state",
48 | "devDependencies": {
49 | "@types/chai": "^4.1.7",
50 | "@types/enzyme": "^3.1.13",
51 | "@types/jsdom": "^16.2.5",
52 | "@types/lodash.isobject": "^3.0.3",
53 | "@types/lodash.isplainobject": "^4.0.3",
54 | "@types/mocha": "^8.2.0",
55 | "@types/node": "^14.14.16",
56 | "@types/node-fetch": "^2.5.4",
57 | "@types/react": "17.0.0",
58 | "@types/react-dom": "17.0.0",
59 | "chai": "^4.2.0",
60 | "concurrently": "^5.3.0",
61 | "coveralls": "^3.0.0",
62 | "enzyme": "^3.9.0",
63 | "enzyme-adapter-react-16": "^1.15.5",
64 | "jsdom": "^16.4.0",
65 | "mocha": "^8.2.1",
66 | "mocha-lcov-reporter": "^1.3.0",
67 | "node-fetch": "^2.6.1",
68 | "prettier": "^2.2.1",
69 | "uglifyjs-webpack-plugin": "^2.1.2",
70 | "webpack": "^5.11.0",
71 | "webpack-cli": "^4.2.0",
72 | "istanbul": "^0.4.5"
73 | },
74 | "dependencies": {
75 | "lodash.isobject": "^3.0.2",
76 | "lodash.isplainobject": "^4.0.6",
77 | "rxjs": "^6.6.3",
78 | "typescript": "^4.1.3"
79 | },
80 | "optionalDependencies": {
81 | "redux": "^4.0.0"
82 | },
83 | "peerDependencies": {
84 | "react": "^16.0.0 || ^17.0.1",
85 | "react-dom": "^16.0.0 || ^17.0.1"
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/test/test_destroy.ts:
--------------------------------------------------------------------------------
1 | import "mocha";
2 | import { Subject } from "rxjs";
3 | import { Reducer, Store } from "../src/index";
4 | import { ExampleState } from "./test_common_types";
5 |
6 | describe("destroy logic", () => {
7 | interface SliceState {
8 | foo: string;
9 | }
10 | interface RootState {
11 | slice?: SliceState;
12 | }
13 | let store: Store;
14 |
15 | beforeEach(() => {
16 | store = Store.create();
17 | });
18 |
19 | it("should trigger the onCompleted subscription for the state observable returned by .select() when the store is destroyed", done => {
20 | store.select().subscribe(undefined, undefined, done);
21 |
22 | store.destroy();
23 | });
24 |
25 | it("should trigger the onCompleted on the state observable returned by select for any child slice when the parent store is destroyed", done => {
26 | const sliceStore = store.createSlice("slice");
27 |
28 | sliceStore.select().subscribe(undefined, undefined, done);
29 |
30 | store.destroy();
31 | });
32 |
33 | it("should unsubscribe any reducer subscription when the store is destroyed for the root store", done => {
34 | const store = Store.create({ counter: 0 });
35 | const incrementAction = new Subject();
36 | const incrementReducer: Reducer = (state, payload) => ({
37 | ...state,
38 | counter: state.counter + 1,
39 | });
40 |
41 | const subscription = store.addReducer(incrementAction, incrementReducer);
42 | subscription.add(done);
43 |
44 | store.destroy();
45 | });
46 |
47 | it("should unsubscribe any reducer subscription when a sliceStore is destroyed", done => {
48 | const store = Store.create({ counter: 0 });
49 | const sliceStore = store.createSlice("counter");
50 | const incrementReducer: Reducer = state => state + 1;
51 |
52 | const subscription = sliceStore.addReducer(new Subject(), incrementReducer);
53 | subscription.add(done);
54 |
55 | sliceStore.destroy();
56 | });
57 |
58 | it("should unsubscribe any reducer subscription for a sliceStore when the root store is destroyed", done => {
59 | const store = Store.create({ counter: 0 });
60 | const sliceStore = store.createSlice("counter");
61 | const incrementAction = new Subject();
62 | const incrementReducer: Reducer = state => state + 1;
63 |
64 | const subscription = sliceStore.addReducer(incrementAction, incrementReducer);
65 | subscription.add(done);
66 |
67 | store.destroy();
68 | });
69 |
70 | it("should trigger the public destroyed observable when destroyed", done => {
71 | const sliceStore = store.createSlice("slice");
72 |
73 | sliceStore.destroyed.subscribe(done);
74 |
75 | store.destroy();
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/react/state.tsx:
--------------------------------------------------------------------------------
1 | // THESE HAVE BEEN REMOVED IN v2.0
2 |
3 | // Justification: These helpers encourage usage of observable inside of presentation components. This violates
4 | // the smart/dumb (a.k.a. container/presentational) pattern. To not encourage new users to bad practices, they are not
5 | // exposed anymore. Code is kept for reference and possible future internal use.
6 |
7 | // import { Observable, Subscription } from 'rxjs';
8 |
9 | // /**
10 | // * A map specifying which property on the components state should be populated with the value of the map value (=observable)
11 | // *
12 | // * @example
13 | // * const map = {
14 | // * secondsPassed: Observable.interval(1000)
15 | // * }
16 | // */
17 | // export type UnpackMap = {
18 | // [P in keyof TComponentState]?: Observable
19 | // }
20 |
21 | // /*
22 | // * Can be used to bind the last emitted item of multiple observables to a component's internal state.
23 | // *
24 | // * @param component - The component of which we set the internal state
25 | // * @param map - A map for which each key in the map will used as target state property to set the observable item to
26 | // */
27 | // export function unpackToState(
28 | // component: React.Component,
29 | // map: UnpackMap
30 | // ): Subscription {
31 | // const subscriptions = new Subscription();
32 | // for (let key in map) {
33 | // const observable = map[key];
34 | // if (observable === undefined)
35 | // continue;
36 |
37 | // if (typeof observable.subscribe !== "function") {
38 | // throw new Error(`Could not map non-observable for property ${key}`)
39 | // }
40 | // subscriptions.add(bindToState(component, observable!, key));
41 | // }
42 | // return subscriptions;
43 | // }
44 |
45 | // export function mapToState(
46 | // component: React.Component,
47 | // source: Observable,
48 | // setStateFn: (item: T, prevState: TComponentState, props: TComponentProps) => TComponentState
49 | // ): Subscription {
50 |
51 | // return source.subscribe((item: T) => {
52 | // component.setState((prevState: TComponentState, props: TComponentProps) => {
53 | // return setStateFn(item, prevState, props);
54 | // })
55 | // })
56 | // }
57 |
58 | // /**
59 | // * Sets the emitted values of an observable to a components state using setState(). The
60 | // * subscription to the source observable is automatically unsubscribed when the component
61 | // * unmounts.
62 | // */
63 | // export function bindToState(
64 | // component: React.Component ,
65 | // source: Observable,
66 | // stateKey: keyof TState
67 | // ): Subscription {
68 | // const subscription = source.subscribe((item: T) => {
69 | // const patch = { [stateKey]: item };
70 | // // TODO eliminate any
71 | // component.setState((prevState: any) => ({ ...prevState, ...patch }))
72 | // })
73 |
74 | // // unsubscribe then the component is unmounted
75 | // const originalUnmount = component.componentWillUnmount;
76 | // component.componentWillUnmount = function() {
77 | // subscription.unsubscribe();
78 | // if (originalUnmount) {
79 | // originalUnmount.call(component);
80 | // }
81 | // }.bind(component);
82 |
83 | // return subscription;
84 | // }
85 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/Dynalon/reactive-state)
2 | [](https://badge.fury.io/js/reactive-state)
3 | 
4 |
5 | Reactive State
6 | ====
7 |
8 | A typed, wrist-friendly state container aimed as an alternative to Redux when using RxJS. Written with RxJS in TypeScript but perfectly usable from plain JavaScript. Originally inspired by the blog posting from [Michael Zalecki](http://michalzalecki.com/use-rxjs-with-react/) but heavily modified and extended since.
9 |
10 | Features
11 | ----
12 |
13 | * type-safe actions: no boilerplate code, no mandatory string constants, and not a single switch statement
14 | * Actions are just Observables, so are Subjects. Just call `.next()` to dispatch an action.
15 | * dynamically add and remove reducers during runtime
16 | * no need for async middlewares such as redux-thunk/redux-saga; actions are Observables and can be composed and transformed async using RxJS operators
17 | * no need for selector libraries like MobX or Reselect, RxJS already ships it
18 | * single, application-wide Store concept as in Redux. Possibility to create slices/substates for decoupling (easier reducer composition and state separation by module)
19 | * Strictly typed to find errors during compile time
20 | * Heavily unit tested, 100+ tests for ~250 lines of code
21 | * React bridge (like `react-redux`) included, though using React is not mandatory
22 | * Support for React-Devtool Extension
23 |
24 | Installation
25 | ----
26 | ```
27 | npm install reactive-state
28 | ```
29 |
30 | Documentation
31 | ----
32 |
33 | * [Wiki](https://github.com/Dynalon/reactive-state/wiki)
34 | * [Demo App with annotated source](https://github.com/Dynalon/reactive-state-react-example) (includes react bridge examples)
35 |
36 | Additionally, there is a small [example.ts file](https://github.com/Dynalon/reactive-state/blob/master/src/example.ts) and see also see the included [unit tests](https://github.com/Dynalon/reactive-state/tree/master/test) as well.
37 |
38 |
39 | Example Usage
40 | ----
41 |
42 | ```typescript
43 | import { Store } from "reactive-state";
44 | import { Subject } from "rxjs";
45 | import { take } from "rxjs/operators";
46 |
47 | // The state for our example app
48 | interface AppState {
49 | counter: number;
50 | }
51 |
52 | const initialState: AppState = { counter: 0 }
53 |
54 | const store = Store.create(initialState);
55 |
56 | // The .watch() function returns an Observable that emits the selected state change, so we can subscribe to it
57 | store.watch().subscribe(newState => console.log("STATE:", JSON.stringify(newState)));
58 |
59 | // the watch() observable always caches the last emitted state, so we will immediately print our inital state:
60 | // [CONSOLE.LOG]: STATE: {"counter":0}
61 |
62 | // use a RxJS Subjects as an action
63 | const incrementAction = new Subject();
64 |
65 | // A reducer is a function that takes a state and an optional payload, and returns a new state
66 | function incrementReducer(state, payload) {
67 | return { ...state, counter: state.counter + payload };
68 | };
69 |
70 | store.addReducer(incrementAction, incrementReducer);
71 |
72 | // lets dispatch some actions
73 |
74 | incrementAction.next(1);
75 | // [CONSOLE.LOG]: STATE: {"counter":1}
76 | incrementAction.next(1);
77 | // [CONSOLE.LOG]: STATE: {"counter":2}
78 |
79 | // async actions? No problem, no need for a "middleware", just use RxJS
80 | interval(1000).pipe(take(3)).subscribe(() => incrementAction.next(1));
81 | //
82 | // [CONSOLE.LOG]: STATE: {"counter":3}
83 | //
84 | // [CONSOLE.LOG]: STATE: {"counter":4}
85 | //
86 | // [CONSOLE.LOG]: STATE: {"counter":5}
87 | ```
88 |
89 | License
90 | ----
91 |
92 | MIT.
93 |
--------------------------------------------------------------------------------
/src/example.ts:
--------------------------------------------------------------------------------
1 | import { Store, Reducer } from "./index";
2 | import { Subject } from "rxjs";
3 |
4 | // you can run this example with node: "node dist/example" from the project root.
5 |
6 | // The main (root) state for our example app
7 | interface AppState {
8 | counter: number;
9 |
10 | // Example of a typed substate/slice
11 | todoState: TodoState;
12 | }
13 |
14 | interface Todo {
15 | id: number;
16 | title: string;
17 | done: boolean;
18 | }
19 |
20 | interface TodoState {
21 | todos: Todo[];
22 | }
23 |
24 | const initialState: AppState = {
25 | counter: 0,
26 | todoState: {
27 | todos: [{ id: 1, title: "Homework", done: false }, { id: 2, title: "Walk dog", done: false }],
28 | },
29 | };
30 |
31 | // create our root store
32 | const store = Store.create(initialState);
33 |
34 | // Log all state changes using the .select() function
35 | store.select().subscribe(newState => console.log(JSON.stringify(newState)));
36 |
37 | // Any Observable can be an action - we use a Subject here
38 | const incrementAction = new Subject();
39 | const incrementReducer: Reducer = (state: number, payload: void) => state + 1;
40 |
41 | const decrementAction = new Subject();
42 | const decrementReducer: Reducer = (state: number, payload: void) => state - 1;
43 |
44 | // while it looks like a magic string, it is NOT: 'counter' is of type "keyof AppState"; so putting
45 | // any non-property name of AppState here is actually a compilation error!
46 | const counterStore = store.createSlice("counter");
47 |
48 | counterStore.addReducer(incrementAction, incrementReducer);
49 | counterStore.addReducer(decrementAction, decrementReducer);
50 |
51 | // dispatch some actions - we just call .next() (here with no payload)
52 | incrementAction.next();
53 | incrementAction.next();
54 | decrementAction.next();
55 |
56 | // wire up ToDos
57 | const deleteToDoAction = new Subject();
58 | const deleteToDoReducer: Reducer = (state, payload) => {
59 | const filteredTodos = state.todos.filter(todo => todo.id != payload);
60 | return { ...state, todos: filteredTodos };
61 | };
62 |
63 | const markTodoAsDoneAction = new Subject();
64 | // This reducer purposely is more complicated than it needs to be, but shows how you would do it in Redux
65 | // you will find a little easier solution using a more specific slice below
66 |
67 | const markTodoAsDoneReducer: Reducer = (state: TodoState, payload: number) => {
68 | const todos = state.todos.map(todo => {
69 | if (todo.id != payload) return todo;
70 | return {
71 | ...todo,
72 | done: true,
73 | };
74 | });
75 | return { ...state, todos };
76 | };
77 |
78 | const todoStore = store.createSlice("todoState");
79 | todoStore.addReducer(deleteToDoAction, deleteToDoReducer);
80 | const reducerSubscription = todoStore.addReducer(markTodoAsDoneAction, markTodoAsDoneReducer);
81 |
82 | markTodoAsDoneAction.next(1);
83 | deleteToDoAction.next(1);
84 |
85 | // now, using .createSlice() can be used to select the todos array directly and our reducer becomes less complex
86 |
87 | // first, disable the previous complex reducer
88 | reducerSubscription.unsubscribe();
89 |
90 | // create a slice pointing directly to the todos array
91 | const todosArraySlice = store.createSlice("todoState").createSlice("todos");
92 |
93 | // create simpler reducer
94 | const markTodoAsDoneSimpleReducer: Reducer = (state: Todo[], payload: number) => {
95 | return state.map(todo => {
96 | if (todo.id != payload) return todo;
97 | return {
98 | ...todo,
99 | done: true,
100 | };
101 | });
102 | };
103 |
104 | todosArraySlice.addReducer(markTodoAsDoneAction, markTodoAsDoneSimpleReducer);
105 | markTodoAsDoneAction.next(2);
106 | deleteToDoAction.next(2);
107 |
--------------------------------------------------------------------------------
/test/test_example.ts:
--------------------------------------------------------------------------------
1 | import "mocha";
2 | import { interval, Subject, zip } from "rxjs";
3 | import { map, take } from "rxjs/operators";
4 | import { Reducer, Store } from "../src/index";
5 |
6 | // make sure the example in the README.md actually works and compiles
7 | // use this test as playground
8 | export function testExample() {
9 | // The state for our example app
10 | interface AppState {
11 | counter: number;
12 | }
13 |
14 | const initialState: AppState = { counter: 0 };
15 |
16 | const store = Store.create(initialState);
17 |
18 | // The .select() function returns an Observable that emits every state change, so we can subscribe to it
19 | store.select().subscribe(newState => console.log("STATE:", JSON.stringify(newState)));
20 |
21 | // the select() observable always caches the last emitted state, so we will immediately print our inital state:
22 | // [CONSOLE.LOG]: STATE: {"counter":0}
23 |
24 | // use a RxJS Subjects as an action
25 | const incrementAction = new Subject();
26 |
27 | // A reducer is a function that takes a state and an optional payload, and returns a new state
28 | function incrementReducer(state, payload) {
29 | return { ...state, counter: state.counter + payload };
30 | }
31 |
32 | store.addReducer(incrementAction, incrementReducer);
33 |
34 | // lets dispatch some actions
35 |
36 | incrementAction.next(1);
37 | // [CONSOLE.LOG]: STATE: {"counter":1}
38 | incrementAction.next(1);
39 | // [CONSOLE.LOG]: STATE: {"counter":2}
40 |
41 | // async actions? No problem, no need for a "middleware", just use RxJS
42 | interval(1000)
43 | .pipe(take(3))
44 | .subscribe(() => incrementAction.next(1));
45 | //
46 | // [CONSOLE.LOG]: STATE: {"counter":3}
47 | //
48 | // [CONSOLE.LOG]: STATE: {"counter":4}
49 | //
50 | // [CONSOLE.LOG]: STATE: {"counter":5}
51 | }
52 |
53 | describe.skip("example", () => {
54 | it("should run the example", done => {
55 | testExample();
56 | setTimeout(() => {
57 | done();
58 | }, 10000);
59 | });
60 | });
61 |
62 | export function testComputedValuesExample() {
63 | interface Todo {
64 | id: number;
65 | title: string;
66 | done: boolean;
67 | }
68 |
69 | interface TodoState {
70 | todos: Todo[];
71 | }
72 |
73 | const store: Store = Store.create({
74 | todos: [
75 | {
76 | id: 0,
77 | title: "Walk the dog",
78 | done: false,
79 | },
80 | {
81 | id: 1,
82 | title: "Homework",
83 | done: false,
84 | },
85 | {
86 | id: 2,
87 | title: "Do laundry",
88 | done: false,
89 | },
90 | ],
91 | });
92 |
93 | const markTodoAsDone = new Subject();
94 | const markTodoAsDoneReducer: Reducer = (state, id) => {
95 | let todo = state.filter(t => t.id === id)[0];
96 | todo = { ...todo, done: true };
97 | return [...state.filter(t => t.id !== id), todo];
98 | };
99 |
100 | const todoStore = store.createSlice("todos");
101 | todoStore.addReducer(markTodoAsDone, markTodoAsDoneReducer);
102 |
103 | const todos = todoStore.select();
104 |
105 | // create an auto computed observables using RxJS basic operators
106 |
107 | const openTodos = todos.pipe(map(todos => todos.filter(t => t.done == false).length));
108 | const completedTodos = todos.pipe(map(todos => todos.filter(t => t.done == true).length));
109 |
110 | // whenever the number of open or completed todos changes, log a message
111 | zip(openTodos, completedTodos).subscribe(([open, completed]) =>
112 | console.log(`I have ${open} open todos and ${completed} completed todos`),
113 | );
114 |
115 | markTodoAsDone.next(0);
116 | markTodoAsDone.next(1);
117 | markTodoAsDone.next(2);
118 | }
119 |
120 | // testComputedValuesExample();
121 |
--------------------------------------------------------------------------------
/src/devtool.ts:
--------------------------------------------------------------------------------
1 | import { createStore, StoreEnhancer, compose, Action as ReduxAction } from "redux";
2 | import { Store } from "./store";
3 | import { Subject } from "rxjs";
4 | import { take } from "rxjs/operators";
5 | import { StateChangeNotification } from "./types";
6 |
7 | /* istanbul ignore next */
8 |
9 | // symbols only for debugging and devtools
10 | export { StateChangeNotification } from "./types";
11 |
12 | export function enableDevTool(store: Store) {
13 | console.warn(
14 | "enableDevTool requires the browser extension. Note: the 'skip action' feature is not supported (but 'jump' works as expected')",
15 | );
16 |
17 | if (typeof window === "undefined") {
18 | // nodejs deployments?
19 | return;
20 | }
21 |
22 | const extension = (window as any)["__REDUX_DEVTOOLS_EXTENSION__"] || (window as any)["devToolsExtension"];
23 | if (!extension) {
24 | console.warn("devToolsExtension not found in window (extension not installed?). Could not enable devTool");
25 | return;
26 | }
27 |
28 | const devtoolExtension: StoreEnhancer = extension();
29 | const reduxToReactiveSync = new Subject();
30 | const reactiveStateUpdate = new Subject();
31 |
32 | store
33 | .select()
34 | .pipe(take(1))
35 | .subscribe(initialState => {
36 | const enhancer: StoreEnhancer = next => {
37 | return (reducer, preloadedState) => {
38 | // run any other store enhancers
39 | const reduxStore = next(reducer, initialState as any);
40 |
41 | // write back the state from DevTools/Redux to our ReactiveState
42 | reduxStore.subscribe(() => {
43 | // const reduxState = reduxStore.getState();
44 | // console.info("RDX UPD STATE: ", reduxState)
45 | // console.info("JUMP/SKIP not supported, do not use or you get undefined behaviour!")
46 | // reduxToReactiveSync.next(reduxState);
47 | });
48 |
49 | reactiveStateUpdate.subscribe((p: any) => {
50 | // console.info("RDX DISP", p)
51 | reduxStore.dispatch({ type: p.actionName, payload: p.payload, state: p.state } as any);
52 | });
53 | return reduxStore;
54 | };
55 | };
56 |
57 | // TODO: State should be type S, but TS does not yet support it
58 | // maybe after TS 2.7: https://github.com/Microsoft/TypeScript/issues/10727
59 | const reduxReducer = (state: any, action: ReduxAction & { state: any }) => {
60 | // TODO: "skip" in devtools does not work here. In plain redux, we could call our reducers with the state
61 | // and the action payload of the (replayed-) action. But we can"t to it with our store, as even if we
62 | // could reset it to a state and replay the action, the operation is async. But we must return a state
63 | // here in a sync manner... :(
64 |
65 | if (action.type === "@@INIT") {
66 | // redux internal action
67 | return { ...state };
68 | }
69 | // What we actually do is instead of returning reduce(state, action) we return the result-state we have
70 | // attached to action, that is kept in the log
71 | return { ...action.state };
72 | };
73 |
74 | createStore(
75 | reduxReducer as any,
76 | initialState,
77 | compose(
78 | enhancer,
79 | devtoolExtension,
80 | ),
81 | );
82 | });
83 |
84 | store.stateChangedNotification.subscribe((notification: StateChangeNotification) => {
85 | const { actionName, actionPayload, newState } = notification;
86 | if (actionName !== "__INTERNAL_SYNC")
87 | reactiveStateUpdate.next({ actionName: actionName || "UNNAMED", payload: actionPayload, state: newState });
88 | });
89 |
90 | const syncReducer = (state: S, payload: any) => {
91 | return { ...payload };
92 | };
93 | store.addReducer(reduxToReactiveSync, syncReducer, "__INTERNAL_SYNC");
94 | }
95 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | v3.5
2 | * Fix a typing bug that made it impossible to use hooks-based (functional) Components with react bridge
3 | * Bump dependencies to latest version
4 | * Due to a change in @types/react we can no longer use ActionMap - use ActionMap instead
5 |
6 | v3.4
7 |
8 | * Make current state of a store available in `store.currentState` just as in a BehaviorSubject. This helps in synchronous code (i.e. react state init).
9 |
10 | v3.3
11 |
12 | * add useStore() hook to consume a store provided via through new Hooks API
13 |
14 | v3.2
15 |
16 | * react bridge: Removed `mapStatetoProps` function and use much simpler `props` which is just an Observable emitting
17 | the props of the connected component
18 | * react bridge: Pass down an Observable of the input props given to a connected component
19 | * react bridge: Remove `cleanup` return property in connect: subscribe to the store.destroy observable instead which
20 | gets called upon unmount
21 | * react bridge: The `store` argument passed as the `ConnectCallback` in the `connect()` function now calls .clone()
22 | on the store internally and automatically calls .destroy() on the clone when the component is unmount. That way we
23 | don't need custom cleanup logic inside `connect()`.
24 |
25 | v3.0
26 |
27 | * Removed `Action` type (use Subject and specify a name as 3rd argument to .addReducer() instead)
28 | * New way of creating slices: Projections. Use .createProjection() to map any properties from a state to another (sliced) state.
29 | * Add .clone() method to Store which is like a slice without any transformation but uses the same state object.
30 | Useful to scope .select()/.watch() subscriptions, as .destroy() will end all subscriptions of the clone but
31 | will not affect the original.
32 | * We do not create immutable copies for initial states anymore but re-use the object passed in
33 | as initial state. Create immutable copies yourself if needed before creating a store.
34 | * Remove fully bundled UMD module from published package, you should use your own bundler like webpack.
35 | * Requires React >=16.4 for react bridge
36 | * Switch to reacts new context API for react bridge StoreProvider
37 | * Drop deprecated lifecycle hooks to be ready for React v17
38 | * Drop `undefined` as a valid return type for the `ConnectCallback` (you can use empty object `{}` though)
39 |
40 | v2.0.0
41 |
42 | * fully RxJS 6 based (without need for rxjs-compat)
43 | * store.select() now emits on every state change, no matter if the result in the selection function is affected by
44 | the changes (disregards shallow identity)
45 | * introduce store.watch() that works as .select(), but performs a shallow equal check on each state change, not emitting
46 | a state if it is shallow-equal to the previous state
47 | * react bridge: complete change of react connect() API: usage of Components as wrapper now discouraged, everything can
48 | be wired inside a single function now passed to connect()
49 | * react bridge: very strict typing of MapStateToProps and ActionMap types using TypeScript 2.8 conditional types
50 | * react bridge: is now a first-class citizen: Enzyme based tests with full DOM rendering implemented; react bridge tests
51 | contribute to overall code coverage
52 | * react bridge: Use to provide a store instance via React's context API
53 | * react bridge: Introduce "keyOfState"}> to create store slices in a declarative way
54 |
55 | v1.0.0
56 |
57 | * Fix type-inference for .createSlice() - this breaks existing code (just remove the type argument from
58 | .createSlice() to fix). Contributed by Sebastian Nemeth.
59 |
60 | v0.5.0
61 | * React bridge now considered mature and can be imported from 'reactive-state/react'
62 | * Do not overwrite any initialstate on a slice if that prop is not undefined
63 | * Breaking change: Do not clone initialState/cleanupState for stores/slices. This means that whatever you pass
64 | as initial state object can be modified by the store, and modifications will be visisble to whoever uses that
65 | instance. The non-clone behaviour is no coherent with Redux behaviour and allows us to drop a cloneDeep()
66 | implementation which save a lot of kilobytes in the output bundle.
67 | * Better devtool integration with notifyStateChange observable on the store
68 |
69 |
70 | v0.4.0
71 | * Use lettable operators from RxJS 5.5
72 | * Change API for devtool
73 |
74 | v0.2.2
75 |
76 | * Fixed tslib only being a dev dependency, although it is needed as runtime dep
77 | when included from another project
78 |
79 | v0.2.1
80 |
81 | * Fixed .select() not correctly infering the type when given no arguments
82 | * Fixed behaviour of special cleanup state string "undefined" which would delete they key -
83 | This will now set the key on the state to real undefined (non-string) upon cleanup
84 | * Added a "delete" special cleanup state string that will behave as "undefined" as before
85 | (remove the key from the parent state alltogether)
86 | * Started a changelog.
87 |
--------------------------------------------------------------------------------
/test/test_stringbased_action_dispatch.ts:
--------------------------------------------------------------------------------
1 | import "mocha";
2 | import { expect } from "chai";
3 | import { Store, Reducer } from "../src/index";
4 | import { ExampleState } from "./test_common_types";
5 | import { Subject } from "rxjs";
6 |
7 | describe("String based action dispatch", () => {
8 | let store: Store;
9 | let incrementReducer: Reducer;
10 | const INCREMENT_ACTION = "INCREMENT_ACTION";
11 |
12 | beforeEach(() => {
13 | const initialState = {
14 | counter: 0,
15 | };
16 | store = Store.create(initialState);
17 | incrementReducer = (state, payload = 1) => ({ ...state, counter: state.counter + payload });
18 | });
19 |
20 | afterEach(() => {
21 | store.destroy();
22 | });
23 |
24 | describe(" unsliced ", () => {
25 | it("should be possible to add an action string identifier instead of an observable", () => {
26 | expect(() => {
27 | store.addReducer(INCREMENT_ACTION, incrementReducer);
28 | }).not.to.throw();
29 | });
30 |
31 | it("should not be possible to add an action string identifier and an additional action name at the same time", () => {
32 | expect(() => {
33 | store.addReducer(INCREMENT_ACTION, incrementReducer, "FOO");
34 | }).to.throw();
35 | });
36 |
37 | it("should not be possible to add an empty string as action name", () => {
38 | expect(() => {
39 | store.addReducer("", incrementReducer);
40 | }).to.throw();
41 | });
42 |
43 | it("should be possible to add an action by string and trigger a manual dispatch on it", done => {
44 | store.addReducer(INCREMENT_ACTION, incrementReducer);
45 | store.dispatch(INCREMENT_ACTION, 1);
46 | store.select().subscribe(state => {
47 | expect(state.counter).to.equal(1);
48 | done();
49 | });
50 | });
51 |
52 | it("should be possible to add an action as unnamed observable with additional action identifier and trigger a manual dispatch on it", done => {
53 | const incrementAction = new Subject();
54 | store.addReducer(incrementAction, incrementReducer, INCREMENT_ACTION);
55 | store.dispatch(INCREMENT_ACTION, 1);
56 | store.select().subscribe(state => {
57 | expect(state.counter).to.equal(1);
58 | done();
59 | });
60 | });
61 |
62 | it("should be possible to add an action completely unnamed and nothing should happend when dispatching undefined", done => {
63 | const incrementAction = new Subject();
64 | store.addReducer(incrementAction, incrementReducer);
65 | store.dispatch(INCREMENT_ACTION, undefined);
66 | store.select().subscribe(state => {
67 | expect(state.counter).to.equal(0);
68 | done();
69 | });
70 | });
71 | });
72 |
73 | describe(" sliced ", () => {
74 | let sliceStore: Store;
75 | let sliceIncrementReducer: Reducer;
76 |
77 | beforeEach(() => {
78 | sliceStore = store.createSlice("counter");
79 | sliceIncrementReducer = (state, payload) => state + payload;
80 | });
81 |
82 | afterEach(() => {
83 | sliceStore.destroy();
84 | });
85 |
86 | it("should be possible to add an action by string and trigger a manual dispatch on it, and slice and root receive the change", done => {
87 | sliceStore.addReducer(INCREMENT_ACTION, sliceIncrementReducer);
88 | sliceStore.dispatch(INCREMENT_ACTION, 1);
89 | store.select().subscribe(state => {
90 | expect(state.counter).to.equal(1);
91 | sliceStore.select().subscribe(counter => {
92 | expect(counter).to.equal(1);
93 | done();
94 | });
95 | });
96 | });
97 |
98 | it("should be possible to add an action by string on a slice and dispatch it on the root store", done => {
99 | sliceStore.addReducer(INCREMENT_ACTION, sliceIncrementReducer);
100 | store.dispatch(INCREMENT_ACTION, 1);
101 | store.select().subscribe(state => {
102 | expect(state.counter).to.equal(1);
103 | sliceStore.select().subscribe(counter => {
104 | expect(counter).to.equal(1);
105 | done();
106 | });
107 | });
108 | });
109 |
110 | it("should be possible to add an action by string on the root and dispatch it on the slice", done => {
111 | store.addReducer(INCREMENT_ACTION, incrementReducer);
112 | sliceStore.dispatch(INCREMENT_ACTION, 1);
113 | store.select().subscribe(state => {
114 | expect(state.counter).to.equal(1);
115 | sliceStore.select().subscribe(counter => {
116 | expect(counter).to.equal(1);
117 | done();
118 | });
119 | });
120 | });
121 | });
122 | });
123 |
--------------------------------------------------------------------------------
/test/test_reducer.ts:
--------------------------------------------------------------------------------
1 | import { expect } from "chai";
2 | import "mocha";
3 | import { of, range, Subject } from "rxjs";
4 | import { skip, take, toArray } from "rxjs/operators";
5 | import { Reducer, Store } from "../src/index";
6 | import { ExampleState, SliceState } from "./test_common_types";
7 |
8 | describe("Reducer tests", () => {
9 | let store: Store;
10 | let slice: Store;
11 |
12 | beforeEach(() => {
13 | store = Store.create();
14 | slice = store.createSlice("counter", 0);
15 | });
16 |
17 | afterEach(() => {
18 | store.destroy();
19 | });
20 |
21 | it("should be possible to add a reducer", done => {
22 | // This is a compile time test: we do not want to give a generic type argument to addReducer
23 | // but compiling with a incompatible reducer will result in compile errors
24 | // Note: type arguments not expressed on purpose for this test!
25 | const addAction = new Subject();
26 | const addReducer = (state, n) => state + n;
27 | slice.addReducer(addAction, addReducer);
28 |
29 | slice
30 | .select()
31 | .pipe(
32 | take(2),
33 | toArray(),
34 | )
35 | .subscribe(s => {
36 | expect(s).to.deep.equal([0, 1]);
37 | done();
38 | });
39 |
40 | addAction.next(1);
41 | });
42 |
43 | it("should be possible to add a reducer with an Observable as action", done => {
44 | // Note: type arguments not expressed on purpose for this test!
45 | const addAction = of(1);
46 | const addReducer: Reducer = (state, n) => state + n;
47 |
48 | slice
49 | .select()
50 | .pipe(
51 | take(2),
52 | toArray(),
53 | )
54 | .subscribe(s => {
55 | expect(s).to.deep.equal([0, 1]);
56 | done();
57 | });
58 |
59 | slice.addReducer(addAction, addReducer);
60 | });
61 |
62 | it("should not be possible to pass anything else but observable/string as first argument to addReducer", () => {
63 | expect(() => {
64 | store.addReducer(5 as any, state => state);
65 | }).to.throw();
66 | });
67 |
68 | it("should not be possible to pass non-function argument as reducer to addReducer", () => {
69 | expect(() => {
70 | store.addReducer("foo", 5 as any);
71 | }).to.throw();
72 | });
73 |
74 | it("should not invoke reducers which have been unsubscribed", done => {
75 | const incrementAction = new Subject();
76 | const subscription = store.addReducer(incrementAction, (state, payload) => {
77 | return { ...state, counter: state.counter + payload };
78 | });
79 |
80 | store
81 | .select()
82 | .pipe(
83 | skip(1),
84 | toArray(),
85 | )
86 | .subscribe(states => {
87 | expect(states[0].counter).to.equal(1);
88 | expect(states.length).to.equal(1);
89 | done();
90 | });
91 |
92 | incrementAction.next(1);
93 | subscription.unsubscribe();
94 | incrementAction.next(1);
95 | store.destroy();
96 | });
97 |
98 | it("should be possible to omit the payload type argument in reducers", done => {
99 | // This is a compile-time only test to verify the API works nicely.
100 |
101 | const incrementReducer: Reducer = state => state + 1;
102 | const incrementAction = new Subject();
103 | slice.addReducer(incrementAction, incrementReducer);
104 | slice
105 | .select()
106 | .pipe(skip(1))
107 | .subscribe(n => {
108 | expect(n).to.equal(1);
109 | done();
110 | });
111 | incrementAction.next();
112 | });
113 |
114 | it("should be possible to have reducers on lots of slices and have each reducer act on a slice", done => {
115 | const nestingLevel = 100;
116 | const rootStore = Store.create({ foo: "0", slice: undefined });
117 |
118 | let left = nestingLevel;
119 | const allDone = () => {
120 | left--;
121 | if (left == 1) done();
122 | };
123 |
124 | let currentStore = rootStore;
125 | range(1, nestingLevel).subscribe(n => {
126 | const nestedStore = currentStore.createSlice("slice", { foo: "" }) as Store;
127 |
128 | const nAsString = n.toString();
129 | const fooAction = new Subject();
130 | const fooReducer: Reducer = (state, payload) => ({ ...state, foo: payload });
131 | nestedStore.addReducer(fooAction, fooReducer);
132 | nestedStore
133 | .select()
134 | .pipe(
135 | skip(1),
136 | take(1),
137 | )
138 | .subscribe(s => {
139 | expect(s!.foo).to.equal(nAsString);
140 | allDone();
141 | });
142 |
143 | fooAction.next(nAsString);
144 | currentStore = nestedStore;
145 | });
146 | });
147 | });
148 |
--------------------------------------------------------------------------------
/test/test_devtool.ts:
--------------------------------------------------------------------------------
1 | import "mocha";
2 | import { expect } from "chai";
3 | import { Subscription, Subject, range, zip } from "rxjs";
4 | import { take, toArray } from "rxjs/operators";
5 | import { Store, Reducer } from "../src/index";
6 | import { ExampleState } from "./test_common_types";
7 |
8 | describe("Devtool notification tests", () => {
9 | let notifyOnStateChange = (store: Store) => store.stateChangedNotification;
10 |
11 | let store: Store;
12 | let incrementAction: Subject;
13 | let incrementReducer: Reducer;
14 | let incrementReducerSubscription: Subscription;
15 |
16 | beforeEach(() => {
17 | const initialState = {
18 | counter: 0,
19 | };
20 | store = Store.create(initialState);
21 | incrementAction = new Subject();
22 | incrementReducer = (state, payload = 1) => ({ ...state, counter: state.counter + payload });
23 | incrementReducerSubscription = store.addReducer(incrementAction, incrementReducer);
24 | });
25 |
26 | afterEach(() => {
27 | store.destroy();
28 | });
29 |
30 | it("should call the devtool callback function when a state change occurs", done => {
31 | notifyOnStateChange(store).subscribe(({ newState }) => {
32 | expect(newState).to.deep.equal({ counter: 1 });
33 | done();
34 | });
35 | incrementAction.next();
36 | });
37 |
38 | // This was changed in v3 - we can't relay on reference equal as our projections might change
39 | // that. Consuming APIs must implement their on distinctUntilChanged()
40 | it.skip("should not call the devtool callback function when the reducer returned the previous state", done => {
41 | const initialState = {};
42 | const store = Store.create(initialState);
43 | const identityAction = new Subject();
44 | store.addReducer(identityAction, state => state, "IDENTITY");
45 | notifyOnStateChange(store).subscribe(({ actionName, actionPayload, newState }) => {
46 | done("Error, notifyOnStateChange called by action: " + actionName);
47 | });
48 |
49 | identityAction.next(undefined);
50 | setTimeout(done, 50);
51 | });
52 |
53 | it("should call the devtool callback function with the correct payload when a state change occurs", done => {
54 | notifyOnStateChange(store).subscribe(({ actionName, actionPayload, newState }) => {
55 | expect(actionPayload).to.equal(3);
56 | done();
57 | });
58 | incrementAction.next(3);
59 | });
60 |
61 | it("should use the overriden action name when one is given to addReducer", done => {
62 | incrementReducerSubscription.unsubscribe();
63 |
64 | notifyOnStateChange(store).subscribe(({ actionName, actionPayload, newState }) => {
65 | expect(newState).to.deep.equal({ counter: 1 });
66 | expect(actionName).to.equal("CUSTOM_ACTION_NAME");
67 | done();
68 | });
69 |
70 | store.addReducer(incrementAction, incrementReducer, "CUSTOM_ACTION_NAME");
71 | incrementAction.next();
72 | });
73 |
74 | it("should trigger a state change notification on a slice", done => {
75 | const slice = store.createSlice("counter");
76 |
77 | notifyOnStateChange(slice).subscribe(({ actionName, actionPayload, newState }) => {
78 | expect(newState).to.deep.equal({ counter: 1 });
79 | expect(actionName).to.equal("INCREMENT_ACTION");
80 | expect(actionPayload).to.equal(1);
81 | done();
82 | });
83 |
84 | const incrementAction = new Subject();
85 | const incrementReducer: Reducer = (state, payload = 1) => state + payload;
86 | slice.addReducer(incrementAction, incrementReducer, "INCREMENT_ACTION");
87 |
88 | incrementAction.next(1);
89 | });
90 |
91 | it("should trigger a state change notification on the parent if a slice changes", done => {
92 | const store = Store.create({ counter: 0 });
93 | notifyOnStateChange(store).subscribe(notification => {
94 | expect(notification.actionName).to.equal("INCREMENT_ACTION");
95 | expect(notification.actionPayload).to.equal(1);
96 | expect(notification.newState).to.deep.equal({ counter: 1 });
97 | done();
98 | });
99 | const slice = store.createSlice("counter");
100 | const incrementAction = new Subject();
101 | const incrementReducer: Reducer = (state, payload = 1) => state + payload;
102 | slice.addReducer(incrementAction, incrementReducer, "INCREMENT_ACTION");
103 |
104 | incrementAction.next(1);
105 | });
106 |
107 | it("should trigger the correct actions matching to the state", done => {
108 | const setValueAction = new Subject();
109 | const store = Store.create({ value: 0 });
110 | const N_ACTIONS = 100000;
111 | store.addReducer(setValueAction, (state, value) => ({ value }), "SET_VALUE");
112 |
113 | const counter1 = new Subject();
114 | const counter2 = new Subject();
115 | // finish after 100 actions dispatched
116 |
117 | zip(counter1, counter2)
118 | .pipe(
119 | take(N_ACTIONS),
120 | toArray(),
121 | )
122 | .subscribe(() => done());
123 |
124 | notifyOnStateChange(store).subscribe(({ actionName, actionPayload, newState }) => {
125 | expect(newState.value).to.equal(actionPayload);
126 | expect(actionName).to.equal("SET_VALUE");
127 | counter2.next();
128 | });
129 |
130 | range(1, N_ACTIONS).subscribe(n => {
131 | // random wait between 1 ms and 500ms
132 | const wait = Math.ceil(Math.random() * 500);
133 | setTimeout(() => {
134 | setValueAction.next(n);
135 | counter1.next();
136 | }, wait);
137 | });
138 | }).timeout(10000);
139 | });
140 |
--------------------------------------------------------------------------------
/react/connect.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Subscription, Observable, ReplaySubject } from "rxjs";
3 | import { Store } from "../src/store";
4 | import { StoreConsumer } from "./provider";
5 |
6 | import { ActionMap, assembleActionProps } from "./actions";
7 | import { takeUntil } from "rxjs/operators";
8 |
9 | // Allows to get the props of a component, or pass the props themselves.
10 | // See: https://stackoverflow.com/questions/50084643/typescript-conditional-types-extract-component-props-type-from-react-component/50084862#50084862
11 | export type ExtractProps = TComponentOrTProps extends React.ComponentType
12 | ? TProps
13 | : TComponentOrTProps;
14 |
15 | export interface ConnectResult {
16 | props?: Observable;
17 | actionMap?: ActionMap;
18 | }
19 |
20 | export type ConnectCallback = (
21 | store: Store,
22 | inputProps: Observable,
23 | ) => ConnectResult;
24 |
25 | export interface ConnectState {
26 | connectedProps?: TConnectedProps;
27 | ready: boolean;
28 | }
29 |
30 | /**
31 | * Connects a Component's props to a set of props of the application state coming from a Store object.
32 | */
33 | // TODO: earlier TS version could infer TOriginalProps, why is this not working anymore? Bug in TS?
34 | // possible candidate: https://github.com/Microsoft/TypeScript/issues/21734
35 | export function connect(
36 | ComponentToConnect: React.ComponentType,
37 | connectCallback: ConnectCallback,
38 | ) {
39 | class ConnectedComponent extends React.Component<
40 | Omit & { reactiveStateStore: Store },
41 | ConnectState
42 | > {
43 | private subscription: Subscription = new Subscription();
44 | private actionProps: Partial = {};
45 | private connectResult?: ConnectResult;
46 | private parentDestroyed?: Observable;
47 | private inputProps = new ReplaySubject(1);
48 |
49 | /**
50 | * we might use the connected component without a store (i.e. in test scenarios). In this case we do
51 | * not do anything and just behave as if we were not connected at all. So we allow undefined here.
52 | */
53 | private store?: Store;
54 |
55 | state: ConnectState = {
56 | connectedProps: undefined,
57 | ready: false,
58 | };
59 |
60 | constructor(props: TInputProps) {
61 | super(props as any);
62 |
63 | this.inputProps.next(this.getProps());
64 | this.subscription.add(() => this.inputProps.complete());
65 |
66 | if (this.props.reactiveStateStore) {
67 | this.store = this.props.reactiveStateStore.clone();
68 | // TODO this hack is necesseary because we seem to have a bug in the destroy logic for clones
69 | this.parentDestroyed = this.props.reactiveStateStore.destroyed;
70 | }
71 | this.connect();
72 | }
73 |
74 | private connect() {
75 | if (this.store === undefined) return;
76 |
77 | this.connectResult = connectCallback(this.store, this.inputProps.asObservable());
78 |
79 | if (this.connectResult.actionMap) {
80 | this.actionProps = assembleActionProps(this.connectResult.actionMap) as TOriginalProps;
81 | }
82 | }
83 |
84 | private subscribeToStateChanges() {
85 | if (this.store === undefined) return;
86 |
87 | const connectResult = this.connectResult!;
88 | if (connectResult.props) {
89 | this.subscription.add(
90 | connectResult.props.pipe(takeUntil(this.parentDestroyed!)).subscribe(connectedProps => {
91 | this.setState((prevState: ConnectState) => {
92 | return {
93 | ...prevState,
94 | connectedProps,
95 | ready: true,
96 | };
97 | });
98 | }),
99 | );
100 | } else {
101 | this.setState((prevState: ConnectState) => ({ ready: true }));
102 | }
103 | }
104 |
105 | /**
106 | * We need to remove the remoteReacticeState properties from our input props; the remainder input props
107 | * are passed down to the connected component
108 | */
109 | private getProps(): TInputProps {
110 | const props: TInputProps & { reactiveStateStore: any } = { ...(this.props as any) };
111 | delete props.reactiveStateStore;
112 | return props;
113 | }
114 |
115 | componentWillUnmount() {
116 | if (this.store !== undefined) {
117 | this.store.destroy();
118 | }
119 | this.subscription.unsubscribe();
120 | }
121 |
122 | componentDidMount() {
123 | this.subscribeToStateChanges();
124 | }
125 |
126 | componentDidUpdate(prevProps: any) {
127 | if (prevProps !== this.props) {
128 | this.inputProps.next(this.getProps());
129 | }
130 | }
131 |
132 | render() {
133 | const props = this.getProps();
134 |
135 | if (this.store === undefined || this.state.ready === true) {
136 | return (
137 |
142 | );
143 | } else {
144 | return null;
145 | }
146 | }
147 | }
148 |
149 | return class extends React.Component, ConnectState> {
150 | constructor(props: any) {
151 | super(props);
152 | }
153 |
154 | render() {
155 | return (
156 |
157 | {value => }
158 |
159 | );
160 | }
161 | };
162 | }
163 |
--------------------------------------------------------------------------------
/react/provider.tsx:
--------------------------------------------------------------------------------
1 | import { Store } from "../src/index";
2 | import * as React from "react";
3 |
4 | const context = React.createContext | undefined>(undefined);
5 | const { Provider, Consumer } = context;
6 |
7 | export interface StoreProviderProps {
8 | store: Store<{}>;
9 | }
10 |
11 | export class StoreProvider extends React.Component {
12 | render() {
13 | return {this.props.children} ;
14 | }
15 | }
16 |
17 | export const StoreConsumer = Consumer;
18 |
19 | export interface StoreSliceProps {
20 | slice: (store: Store) => TKey;
21 | initialState?: TAppState[TKey];
22 | cleanupState?: TAppState[TKey] | "delete" | "undefined";
23 | }
24 |
25 | export class StoreSlice extends React.Component<
26 | StoreSliceProps,
27 | {}
28 | > {
29 | slice?: Store;
30 |
31 | componentWillUnmount() {
32 | this.slice!.destroy();
33 | }
34 |
35 | render() {
36 | return (
37 |
38 | {(store: Store | undefined) => {
39 | if (!store)
40 | throw new Error(
41 | "StoreSlice used outside of a Store context. Did forget to add a ?",
42 | );
43 |
44 | // we ignore this else due to a limitation in enzyme - we can't trigger a
45 | // forceUpdate here to test the else branch;
46 | /* istanbul ignore else */
47 | if (this.slice === undefined) {
48 | this.slice = store.createSlice(
49 | this.props.slice(store),
50 | this.props.initialState,
51 | this.props.cleanupState,
52 | );
53 | }
54 | return {this.props.children} ;
55 | }}
56 |
57 | );
58 | }
59 | }
60 |
61 | export interface StoreProjectionProps {
62 | forwardProjection: (state: TState) => TProjected;
63 | backwardProjection: (projectedState: TProjected, parentState: TState) => TState;
64 | cleanup?: (state: TProjected, parentState: TState) => TState;
65 | initial?: (state: TState) => TProjected;
66 | }
67 |
68 | export const StoreProjection = class StoreProjection extends React.Component<
69 | StoreProjectionProps,
70 | {}
71 | > {
72 | slice?: Store;
73 |
74 | componentWillUnmount() {
75 | this.slice!.destroy();
76 | }
77 |
78 | render() {
79 | return (
80 |
81 | {(store: Store | undefined) => {
82 | if (!store)
83 | throw new Error(
84 | "StoreProjection/Slice used outside of a Store context. Did forget to add a ?",
85 | );
86 |
87 | // we ignore this else due to a limitation in enzyme - we can't trigger a
88 | // forceUpdate here to test the else branch;
89 | /* istanbul ignore else */
90 | if (this.slice === undefined) {
91 | this.slice = store.createProjection(
92 | this.props.forwardProjection,
93 | this.props.backwardProjection,
94 | this.props.initial,
95 | this.props.cleanup,
96 | );
97 | }
98 | return {this.props.children} ;
99 | }}
100 |
101 | );
102 | }
103 | };
104 |
105 | export class WithStore extends React.Component<{}, {}> {
106 | render() {
107 | return (
108 |
109 | {store => {
110 | const child = this.props.children as (store: Store) => React.ReactNode;
111 | if (!store)
112 | throw new Error(
113 | "WithStore used but no store could be found in context. Did you suppliy a StoreProvider?",
114 | );
115 | else if (typeof this.props.children !== "function")
116 | throw new Error("WithStore used but its child is not a function.");
117 | else return child(store);
118 | }}
119 |
120 | );
121 | }
122 | }
123 |
124 | /**
125 | * A react hook to obtain the current store, depending on the context.
126 | */
127 | export function useStore() {
128 | const store = React.useContext(context);
129 | if (store === undefined) {
130 | throw new Error("No store found in context, did you forget to add a Provider for it?");
131 | }
132 | return store as Store;
133 | }
134 |
135 | /**
136 | * A react hook to mirror the pattern of connect through a hooks-based interface.
137 | */
138 | export function useStoreState(): object;
139 | export function useStoreState(): TState;
140 | export function useStoreState(projection: (state: TState) => TSlice): TSlice;
141 | export function useStoreState(projection?: (state: TState) => TSlice): TSlice {
142 | const store = useStore();
143 | const [slice, setSlice] = React.useState(projection ? projection(store.currentState) : store.currentState as unknown as TSlice);
144 |
145 | // do not introduce a unneeded second re-render whenever using this hook
146 | const firstRender = React.useRef(true)
147 |
148 | React.useEffect(() => {
149 | const sub = store.watch(projection).subscribe((slice) => {
150 | if (!firstRender.current) {
151 | setSlice(slice)
152 | }
153 | });
154 | firstRender.current = false;
155 | return () => sub.unsubscribe();
156 | }, [store]);
157 |
158 | return slice;
159 | }
160 |
161 | /**
162 | * A react hook to create a fluent interface for producing a hook that makes state slices.
163 | * Useful mainly for infering the type of the slice; when the type of slice is known, useStoreState is cleaner.
164 | */
165 | export function useStoreSlices(): (projection: (state: TState) => TSlice) => TSlice {
166 | // note: a named function is needed here to keep react devtools looking clean
167 | return function useStoreSlice(projection: (state: TState) => TSlice): TSlice {
168 | return useStoreState(projection);
169 | };
170 | }
--------------------------------------------------------------------------------
/test/test_select.ts:
--------------------------------------------------------------------------------
1 | import { expect } from "chai";
2 | import "mocha";
3 | import { Subject } from "rxjs";
4 | import { skip, take, toArray } from "rxjs/operators";
5 | import { Reducer, Store } from "../src/index";
6 | import { ExampleState } from "./test_common_types";
7 |
8 | describe("Store .select() and .watch() tests", () => {
9 | let store: Store;
10 | let incrementAction: Subject;
11 | let incrementReducer: Reducer;
12 | let mergeAction: Subject>;
13 | let noChangesAction: Subject;
14 | let shallowCopyAction: Subject;
15 |
16 | const mergeReducer = (state, patch) => {
17 | const newState: ExampleState = {
18 | ...state,
19 | someArray: patch.someArray ? [...state.someArray, ...patch.someArray] : state.someArray,
20 | someObject: patch.someObject ? { ...state.someObject, ...patch.someObject } : state.someObject,
21 | };
22 | return newState;
23 | };
24 | const noChangesReducer = state => state;
25 | const shallowCopyReducer = state => ({ ...state });
26 |
27 | const initialState = {
28 | counter: 0,
29 | message: "initialMessage",
30 | bool: false,
31 | someArray: ["Apple", "Banana", "Cucumber"],
32 | someObject: {
33 | foo: "bar",
34 | },
35 | };
36 |
37 | beforeEach(() => {
38 | store = Store.create(initialState);
39 | incrementAction = new Subject();
40 | incrementReducer = state => ({ ...state, counter: state.counter + 1 });
41 | store.addReducer(incrementAction, incrementReducer);
42 |
43 | mergeAction = new Subject>();
44 | store.addReducer(mergeAction, mergeReducer);
45 | noChangesAction = new Subject();
46 | store.addReducer(noChangesAction, noChangesReducer);
47 | shallowCopyAction = new Subject();
48 | store.addReducer(shallowCopyAction, shallowCopyReducer);
49 | });
50 |
51 | afterEach(() => {
52 | store.destroy();
53 | });
54 |
55 | describe("select(): ", () => {
56 | it("should emit a state change on select", done => {
57 | store
58 | .select()
59 | .pipe(
60 | skip(1),
61 | take(1),
62 | )
63 | .subscribe(state => {
64 | expect(state.counter).to.equal(1);
65 | done();
66 | });
67 | incrementAction.next();
68 | });
69 |
70 | it("should use the identity function as default if no selector function is passed", done => {
71 | store
72 | .select()
73 | .pipe(
74 | skip(1),
75 | take(1),
76 | )
77 | .subscribe(state => {
78 | expect(state).to.be.an("Object");
79 | expect(state.counter).not.to.be.undefined;
80 | done();
81 | });
82 |
83 | incrementAction.next();
84 | });
85 |
86 | it("should immediately emit the last-emitted (might be initial) state when subscription happens", done => {
87 | store
88 | .select()
89 | .pipe(take(1))
90 | .subscribe(state => {
91 | expect(state.counter).to.equal(0);
92 | done();
93 | });
94 | });
95 |
96 | it("should emit the last state immediately when selecting when its not initial state", done => {
97 | incrementAction.next();
98 |
99 | store
100 | .select()
101 | .pipe(take(1))
102 | .subscribe(state => {
103 | expect(state.counter).to.equal(1);
104 | done();
105 | });
106 | });
107 |
108 | it("should emit a state change when the state changes, even when the selector result is shallow-equal to the previous value", done => {
109 | store
110 | .select(state => state.message)
111 | .pipe(
112 | skip(1),
113 | take(1),
114 | )
115 | .subscribe(msg => {
116 | expect(msg).to.equal(initialState.message);
117 | done();
118 | });
119 | incrementAction.next();
120 | });
121 | });
122 |
123 | describe(".watch(): ", () => {
124 | it("should not emit a state change for .watch() when the reducer returns the unmofified, previous state or a shallow copy of it", done => {
125 | store
126 | .watch()
127 | .pipe(
128 | skip(1),
129 | toArray(),
130 | )
131 | .subscribe(state => {
132 | expect(state.length).to.equal(0);
133 | done();
134 | });
135 |
136 | noChangesAction.next();
137 | shallowCopyAction.next();
138 | store.destroy();
139 | });
140 | it(".watch() should not emit a state change when a the state changes but not the selected value", done => {
141 | store
142 | .watch(state => state.counter)
143 | .pipe(
144 | skip(1),
145 | toArray(),
146 | )
147 | .subscribe(state => {
148 | expect(state.length).to.equal(0);
149 | done();
150 | });
151 |
152 | noChangesAction.next();
153 | shallowCopyAction.next();
154 | store.destroy();
155 | });
156 |
157 | it(".watch() should emit a state change when a primitive type in a selector changes", done => {
158 | store
159 | .watch(state => state.counter)
160 | .pipe(
161 | skip(1),
162 | toArray(),
163 | )
164 | .subscribe(state => {
165 | expect(state.length).to.equal(1);
166 | done();
167 | });
168 |
169 | incrementAction.next();
170 | store.destroy();
171 | });
172 |
173 | it(".watch() should emit a state change when an array is changed immutably", done => {
174 | store
175 | .watch(state => state.someArray)
176 | .pipe(
177 | skip(1),
178 | take(1),
179 | )
180 | .subscribe(state => {
181 | expect(state).to.deep.equal([...initialState.someArray, "Dades"]);
182 | done();
183 | });
184 |
185 | mergeAction.next({ someArray: ["Dades"] });
186 | });
187 |
188 | it(".watch() should emit a state change when an object is changed immutably", done => {
189 | store
190 | .watch(state => state.someObject)
191 | .pipe(
192 | skip(1),
193 | take(1),
194 | )
195 | .subscribe(state => {
196 | expect(state).to.deep.equal({ ...initialState.someObject, foo: "foo" });
197 | done();
198 | });
199 |
200 | mergeAction.next({ someObject: { foo: "foo" } });
201 | });
202 | });
203 | });
204 |
--------------------------------------------------------------------------------
/test/test_slicing.ts:
--------------------------------------------------------------------------------
1 | import "mocha";
2 | import { expect } from "chai";
3 | import { Subscription, zip as zipStatic, Subject } from "rxjs";
4 | import { take, skip, toArray } from "rxjs/operators";
5 | import { Store, Reducer } from "../src/index";
6 |
7 | import { ExampleState, RootState, SliceState } from "./test_common_types";
8 |
9 | describe("Store slicing tests", () => {
10 | let store: Store;
11 | let counterSlice: Store;
12 | let incrementAction: Subject;
13 | let incrementReducer: Reducer;
14 | let incrementSubscription: Subscription;
15 |
16 | beforeEach(() => {
17 | incrementAction = new Subject();
18 | incrementReducer = state => state + 1;
19 | store = Store.create({ counter: 0 });
20 | });
21 |
22 | afterEach(() => {
23 | store.destroy();
24 | });
25 |
26 | describe(" using legacy string-based key slicing", () => {
27 | beforeEach(() => {
28 | counterSlice = store.createSlice("counter");
29 | incrementSubscription = counterSlice.addReducer(incrementAction, incrementReducer);
30 | });
31 |
32 | it("should emit the initial state when subscribing to a freshly sliced store", done => {
33 | // sync
34 | expect(counterSlice.currentState).to.equal(0);
35 |
36 | // async
37 | counterSlice.select().subscribe(counter => {
38 | expect(counter).to.equal(0);
39 | done();
40 | });
41 | });
42 |
43 | it("should select a slice and emit the slice value", done => {
44 | incrementAction.next();
45 | // sync
46 | expect(counterSlice.currentState).to.equal(1);
47 |
48 | // async
49 | counterSlice.select().subscribe(counter => {
50 | expect(counter).to.equal(1);
51 | done();
52 | });
53 | });
54 |
55 | it("should be possible to pass a projection function to .select()", done => {
56 | store
57 | .select(state => state.counter)
58 | .pipe(
59 | take(4),
60 | toArray(),
61 | )
62 | .subscribe(values => {
63 | expect(values).to.deep.equal([0, 1, 2, 3]);
64 | done();
65 | });
66 |
67 | incrementAction.next();
68 | expect(counterSlice.currentState).to.equal(1);
69 | incrementAction.next();
70 | expect(counterSlice.currentState).to.equal(2);
71 | incrementAction.next();
72 | expect(counterSlice.currentState).to.equal(3);
73 | });
74 |
75 | it("should not invoke reducers which have been unsubscribed", done => {
76 | incrementSubscription.unsubscribe();
77 |
78 | counterSlice
79 | .select()
80 | .pipe(skip(1))
81 | .subscribe(state => {
82 | done("Error: This should have not been called");
83 | });
84 |
85 | incrementAction.next();
86 | done();
87 | });
88 |
89 | it("should emit a state change on the slice if the root store changes even when the subtree is not affected and forceEmitEveryChange is set", done => {
90 | const simpleSubject = new Subject();
91 | const simpleMutation: Reducer = state => ({ ...state });
92 | store.addReducer(simpleSubject, simpleMutation);
93 |
94 | counterSlice
95 | .select()
96 | .pipe(
97 | skip(1),
98 | take(1),
99 | )
100 | .subscribe(counter => {
101 | expect(counter).to.equal(0);
102 | done();
103 | });
104 |
105 | simpleSubject.next();
106 | });
107 |
108 | it("should not emit a state change on the slice if the root store changes and forceEmitEveryChange is not set", done => {
109 | const simpleSubject = new Subject();
110 | const simpleMutation: Reducer = state => ({ ...state });
111 | store.addReducer(simpleSubject, simpleMutation);
112 |
113 | counterSlice
114 | .watch()
115 | .pipe(
116 | skip(1),
117 | toArray(),
118 | )
119 | .subscribe(changes => {
120 | expect(changes).to.deep.equal([]);
121 | done();
122 | });
123 |
124 | simpleSubject.next();
125 | store.destroy();
126 | });
127 |
128 | it("should trigger state changes on slice siblings", done => {
129 | const siblingStore = store.createSlice("counter");
130 |
131 | // async
132 | siblingStore
133 | .select()
134 | .pipe(skip(1))
135 | .subscribe(n => {
136 | expect(n).to.equal(1);
137 | done();
138 | });
139 |
140 | incrementAction.next();
141 |
142 | // sync
143 | expect(siblingStore.currentState).to.equal(1);
144 | });
145 |
146 | it("should trigger state changes on slice siblings for complex states", done => {
147 | const rootStore: Store = Store.create({
148 | slice: { foo: "bar" },
149 | });
150 | const action = new Subject();
151 | const reducer: Reducer = state => {
152 | return { ...state, foo: "baz" };
153 | };
154 |
155 | const slice1 = rootStore.createSlice("slice", { foo: "bar" });
156 | // TODO eliminate any
157 | slice1.addReducer(action, reducer as any);
158 |
159 | const slice2 = rootStore.createSlice("slice", { foo: "bar2" });
160 | slice2
161 | .select()
162 | .pipe(skip(1))
163 | .subscribe(slice => {
164 | if (!slice) {
165 | done("ERROR");
166 | return;
167 | } else {
168 | expect(slice.foo).to.equal("baz");
169 | done();
170 | }
171 | });
172 |
173 | action.next();
174 | });
175 | });
176 |
177 | describe(" using projection based slicing", () => {
178 | it("should be possible to create a clone (with identity projections) and their states should be equal", done => {
179 | const slice = store.clone();
180 |
181 | store.select().subscribe(storeState => {
182 | slice.select().subscribe(sliceState => {
183 | expect(storeState).to.equal(sliceState);
184 | expect(storeState).to.deep.equal(sliceState);
185 | done();
186 | });
187 | });
188 | });
189 |
190 | it("should be possible to create a clone (with identity projections) and after reducing, their states should be equal", done => {
191 | const slice = store.clone();
192 | slice.addReducer(incrementAction, state => ({ counter: state.counter + 1 }));
193 |
194 | incrementAction.next();
195 |
196 | // sync
197 | expect(slice.currentState).to.equal(store.currentState);
198 |
199 | store.select().subscribe(storeState => {
200 | // async
201 | slice.select().subscribe(sliceState => {
202 | expect(storeState).to.equal(sliceState);
203 | expect(storeState).to.deep.equal(sliceState);
204 | done();
205 | });
206 | });
207 | });
208 |
209 | it("should change both states in clone and original but fire a NamedObservable Subject only on the store that registers it", done => {
210 | const slice = store.clone();
211 | store.addReducer(incrementAction, state => ({ ...state, counter: state.counter + 1 }));
212 |
213 | zipStatic(store.select().pipe(skip(1)), slice.select().pipe(skip(1))).subscribe(
214 | ([originalState, cloneState]) => {
215 | expect(originalState.counter).to.equal(1);
216 | expect(cloneState.counter).to.equal(1);
217 | expect(cloneState).to.deep.equal(originalState);
218 | done();
219 | },
220 | );
221 |
222 | incrementAction.next();
223 | });
224 |
225 | it("should change both states in clone and original but fire a NamedObservable Subject only on the store that registers it", done => {
226 | const slice = store.clone();
227 | store.addReducer("INCREMENT_Subject", state => ({ ...state, counter: state.counter + 1 }));
228 |
229 | zipStatic(store.select().pipe(skip(1)), slice.select().pipe(skip(1))).subscribe(
230 | ([originalState, cloneState]) => {
231 | expect(originalState.counter).to.equal(1);
232 | expect(cloneState.counter).to.equal(1);
233 | expect(cloneState).to.deep.equal(originalState);
234 | done();
235 | },
236 | );
237 |
238 | store.dispatch("INCREMENT_Subject", undefined);
239 | });
240 |
241 | // was a regression
242 | it("should correctly apply recursive state transformations", done => {
243 | const action = new Subject();
244 | const is = {
245 | prop: {
246 | someArray: [] as number[],
247 | },
248 | };
249 | const rootStore = Store.create(is);
250 | const slice1 = rootStore.createProjection(
251 | state => state.prop,
252 | (state, parent) => ({ ...parent, prop: state }),
253 | );
254 | // const slice1 = rootStore.createSlice("prop");
255 | const slice2 = slice1.createSlice("someArray");
256 |
257 | const reducer: Reducer = state => {
258 | expect(state).to.deep.equal([]);
259 | return [1];
260 | };
261 | slice2.addReducer(action, reducer);
262 |
263 | rootStore
264 | .select()
265 | .pipe(skip(1))
266 | .subscribe(state => {
267 | expect(state).to.deep.equal({
268 | prop: {
269 | someArray: [1],
270 | },
271 | });
272 | rootStore.destroy();
273 | done();
274 | });
275 |
276 | action.next(undefined);
277 | });
278 | });
279 | });
280 |
--------------------------------------------------------------------------------
/test/test_initial_state.ts:
--------------------------------------------------------------------------------
1 | import { expect } from "chai";
2 | import "mocha";
3 | import { range, Subject } from "rxjs";
4 | import { skip, take } from "rxjs/operators";
5 | import { Reducer, Store } from "../src/index";
6 | import { ExampleState, GenericState, RootState, SliceState } from "./test_common_types";
7 |
8 | describe("initial state setting", () => {
9 | class Foo {}
10 | let store: Store;
11 | let genericStore: Store;
12 | let genericAction: Subject;
13 | const genericReducer: Reducer = (state, payload) => ({ ...state, value: payload });
14 |
15 | beforeEach(() => {
16 | store = Store.create();
17 | genericStore = Store.create();
18 | genericAction = new Subject();
19 | genericStore.addReducer(genericAction, genericReducer);
20 | });
21 |
22 | // justification: Slices can have any type like "number" etc., so makes no sense to initialize with {}
23 | it("should accept an initial state of undefined and create and empty object as initial root state", done => {
24 | const store = Store.create();
25 |
26 | store
27 | .select()
28 | .pipe(take(1))
29 | .subscribe(state => {
30 | expect(state).to.be.an("Object");
31 | expect(Object.getOwnPropertyNames(state)).to.have.lengthOf(0);
32 | done();
33 | });
34 | });
35 |
36 | it("should set the initial state and have it available as currentState immediately", () => {
37 | const initialState = {}
38 | const store = Store.create(initialState);
39 | expect(store.currentState).to.equal(initialState)
40 | })
41 |
42 | it("should accept an initial state of undefined and use undefined as initial state", done => {
43 | const sliceStore = store.createSlice("slice", undefined);
44 |
45 | sliceStore
46 | .select()
47 | .pipe(take(1))
48 | .subscribe(initialState => {
49 | expect(initialState).to.be.undefined;
50 | done();
51 | });
52 | });
53 |
54 | it("should accept an initial state object when creating a slice", () => {
55 | const sliceStore = store.createSlice("slice", { foo: "bar" });
56 |
57 | sliceStore
58 | .select()
59 | .pipe(take(1))
60 | .subscribe(slice => {
61 | expect(slice).to.be.an("Object");
62 | expect(Object.getOwnPropertyNames(slice)).to.deep.equal(["foo"]);
63 | expect(slice!.foo).to.equal("bar");
64 | });
65 | });
66 |
67 | it("should set the initial state for a slice-of-a-slice on the sliced state", done => {
68 | const sliceStore = store.createSlice("slice", { foo: "bar" }) as Store;
69 |
70 | store
71 | .select(s => s)
72 | .pipe(skip(1))
73 | .subscribe(s => {
74 | if (!s.slice || !s.slice.slice) {
75 | done("Error");
76 | return;
77 | }
78 | expect(s.slice.slice.foo).to.equal("baz");
79 | expect(Object.getOwnPropertyNames(s.slice)).to.deep.equal(["foo", "slice"]);
80 | expect(Object.getOwnPropertyNames(s.slice.slice)).to.deep.equal(["foo"]);
81 | done();
82 | });
83 |
84 | sliceStore.createSlice("slice", { foo: "baz" });
85 | });
86 |
87 | it("should not allow non-plain objects for the root store creation as initialState", () => {
88 | expect(() => Store.create(new Foo())).to.throw();
89 | });
90 |
91 | it("should not allow non-plain objects for the slice store as initialState", () => {
92 | expect(() => genericStore.createSlice("value", new Foo())).to.throw();
93 | });
94 |
95 | it("should not allow non-plain objects for the slice store as cleanupState", () => {
96 | // we have to trick TypeScript compiler for this test
97 | expect(() => genericStore.createSlice("value", undefined, new Foo())).to.throw();
98 | });
99 |
100 | it("should allow primitive types, plain object and array as initial state for root store creation", () => {
101 | expect(() => Store.create(null)).not.to.throw();
102 | expect(() => Store.create(undefined)).not.to.throw();
103 | expect(() => Store.create("foobar")).not.to.throw();
104 | expect(() => Store.create(5)).not.to.throw();
105 | expect(() => Store.create(false)).not.to.throw();
106 | expect(() => Store.create({})).not.to.throw();
107 | expect(() => Store.create([])).not.to.throw();
108 | expect(() => Store.create(Symbol())).not.to.throw();
109 | });
110 |
111 | it("should allow primitive types, plain object and array as initial state for slice store creation", () => {
112 | expect(() => genericStore.createSlice("value", null)).not.to.throw();
113 | expect(() => genericStore.createSlice("value", undefined)).not.to.throw();
114 | expect(() => genericStore.createSlice("value", "foobar")).not.to.throw();
115 | expect(() => genericStore.createSlice("value", 5)).not.to.throw();
116 | expect(() => genericStore.createSlice("value", false)).not.to.throw();
117 | expect(() => genericStore.createSlice("value", {})).not.to.throw();
118 | expect(() => genericStore.createSlice("value", [])).not.to.throw();
119 | expect(() => genericStore.createSlice("value", Symbol())).not.to.throw();
120 | });
121 |
122 | it("should allow primitive types, plain object and array as cleanup state for slice store creation", () => {
123 | expect(() => genericStore.createSlice("value", undefined, null)).not.to.throw();
124 | expect(() => genericStore.createSlice("value", undefined, undefined)).not.to.throw();
125 | expect(() => genericStore.createSlice("value", undefined, "foobar")).not.to.throw();
126 | expect(() => genericStore.createSlice("value", undefined, 5)).not.to.throw();
127 | expect(() => genericStore.createSlice("value", undefined, false)).not.to.throw();
128 | expect(() => genericStore.createSlice("value", undefined, {})).not.to.throw();
129 | expect(() => genericStore.createSlice("value", undefined, [])).not.to.throw();
130 | expect(() => genericStore.createSlice("value", undefined, Symbol())).not.to.throw();
131 | });
132 |
133 | it("does not clone the initialState object when creating the root store, so changes to it will be reflected in our root store", done => {
134 | const initialState: ExampleState = {
135 | counter: 0,
136 | };
137 |
138 | const store = Store.create(initialState);
139 | const counterAction = new Subject();
140 | const counterReducer: Reducer = (state, payload = 1) => {
141 | // WARNING this is not immutable and should not be done in production code
142 | // we just do it here for the test...
143 | state.counter++;
144 | return state;
145 | };
146 |
147 | store.addReducer(counterAction, counterReducer);
148 | counterAction.next();
149 |
150 | // verify currentState (=synchronous state access) works, too
151 | expect(store.currentState.counter).to.equal(1)
152 |
153 |
154 | store.select().subscribe(s => {
155 | expect(initialState.counter).to.equal(1);
156 | done();
157 | });
158 | });
159 |
160 | it("should not create an immutable copy of the initialState object when creating a slice store, so changes to it will be noticed outside the slice", done => {
161 | const initialState: ExampleState = {
162 | counter: 0,
163 | };
164 | const store = Store.create(initialState);
165 | const counterAction = new Subject();
166 | const counterReducer: Reducer = (state, payload = 1) => state + payload;
167 |
168 | const slice = store.createSlice("counter");
169 | slice.addReducer(counterAction, counterReducer);
170 | counterAction.next();
171 |
172 | slice
173 | .select()
174 | .pipe(take(2))
175 | .subscribe(s => {
176 | expect(initialState.counter).to.equal(1);
177 | done();
178 | });
179 | });
180 |
181 | it("should be possible to create a lot of nested slices", done => {
182 | const nestingLevel = 100;
183 | const rootStore = Store.create({ foo: "0", slice: undefined });
184 |
185 | let currentStore: Store = rootStore;
186 | range(1, nestingLevel).subscribe(
187 | n => {
188 | const nestedStore = currentStore.createSlice("slice", { foo: n.toString() });
189 | nestedStore
190 | .select()
191 | .pipe(take(1))
192 | .subscribe(state => {
193 | expect(state!.foo).to.equal(n.toString());
194 | });
195 | currentStore = nestedStore as Store;
196 | },
197 | undefined,
198 | done,
199 | );
200 | });
201 |
202 | it("should trigger a state change on the root store when the initial state on the slice is created", done => {
203 | store
204 | .select(s => s)
205 | .pipe(
206 | skip(1),
207 | take(1),
208 | )
209 | .subscribe(state => {
210 | expect(state.slice).not.to.be.undefined;
211 | expect(state.slice).to.have.property("foo");
212 | if (state.slice) {
213 | expect(state.slice.foo).to.equal("bar");
214 | done();
215 | }
216 | });
217 |
218 | store.createSlice("slice", { foo: "bar" });
219 | });
220 |
221 | it("should overwrite an initial state on the slice if the slice key already has a value", done => {
222 | const sliceStore = store.createSlice("slice", { foo: "bar" });
223 | sliceStore.destroy();
224 | const sliceStore2 = store.createSlice("slice", { foo: "different" });
225 | sliceStore2.select().subscribe(state => {
226 | expect(state!.foo).to.equal("different");
227 | done();
228 | });
229 | });
230 |
231 | it("should set the state to the cleanup value undefined but keep the property on the object, when the slice store is destroyed for case 'undefined'", done => {
232 | const sliceStore = store.createSlice("slice", { foo: "bar" }, "undefined");
233 | sliceStore.destroy();
234 |
235 | store.select().subscribe(state => {
236 | expect(state.hasOwnProperty("slice")).to.equal(true);
237 | expect(state.slice).to.be.undefined;
238 | done();
239 | });
240 | });
241 |
242 | it("should remove the slice property on parent state altogether when the slice store is destroyed for case 'delete'", done => {
243 | const sliceStore = store.createSlice("slice", { foo: "bar" }, "delete");
244 | sliceStore.destroy();
245 |
246 | store.select().subscribe(state => {
247 | expect(state.hasOwnProperty("slice")).to.equal(false);
248 | expect(Object.getOwnPropertyNames(state)).to.deep.equal([]);
249 | done();
250 | });
251 | });
252 |
253 | it("should set the state to the cleanup value when the slice store is unsubscribed for case null", done => {
254 | const sliceStore = store.createSlice("slice", { foo: "bar" }, null);
255 | sliceStore.destroy();
256 |
257 | store.select().subscribe(state => {
258 | expect(state.slice).to.be.null;
259 | done();
260 | });
261 | });
262 |
263 | it("should set the state to the cleanup value when the slice store is unsubscribed for case any object", done => {
264 | const sliceStore = store.createSlice("slice", { foo: "bar" }, { foo: "baz" });
265 | sliceStore.destroy();
266 |
267 | store.select().subscribe(state => {
268 | expect(state.slice).to.be.deep.equal({ foo: "baz" });
269 | done();
270 | });
271 | });
272 | });
273 |
--------------------------------------------------------------------------------
/test/test_react_connect.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from "chai";
2 | import * as Enzyme from "enzyme";
3 | import "mocha";
4 | import * as React from "react";
5 | import { Subject, Subscription, Observable, of } from "rxjs";
6 | import { take, skip } from "rxjs/operators";
7 | import { ActionMap, connect, ConnectResult, StoreProvider } from "../react";
8 | import { ExtractProps } from "../react/connect";
9 | import { Store } from "../src/index";
10 | import { setupJSDomEnv } from "./test_enzyme_helper";
11 |
12 | // Utilities for testing typing
13 | // from https://github.com/type-challenges/type-challenges/blob/master/utils/index.d.ts
14 | export type Expect = T;
15 | export type ExpectTrue = T;
16 | export type ExpectFalse = T;
17 | export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false;
18 | export type NotEqual = true extends Equal ? false : true;
19 |
20 | const globalClicked = new Subject();
21 | const nextMessage = new Subject();
22 |
23 | export interface TestState {
24 | message: string;
25 | slice?: SliceState;
26 | }
27 |
28 | export interface SliceState {
29 | sliceMessage: string;
30 | }
31 |
32 | export interface TestComponentProps {
33 | message: string;
34 | onClick: (arg1: any) => void;
35 | }
36 |
37 | export class TestComponent extends React.Component {
38 | render() {
39 | return (
40 |
41 |
{this.props.message}
42 |
43 |
44 | );
45 | }
46 |
47 | componentDidCatch() {}
48 | }
49 |
50 | function getConnectedComponent(connectResultOverride?: ConnectResult | null) {
51 | return connect(
52 | TestComponent,
53 | (store: Store) => {
54 | store.destroyed.subscribe(() => cleanup.unsubscribe());
55 | const props = store.createSlice("message").watch(message => ({ message }));
56 | const actionMap: ActionMap = {
57 | onClick: globalClicked,
58 | };
59 | if (connectResultOverride === null) {
60 | return {};
61 | }
62 | return {
63 | actionMap,
64 | props,
65 | ...connectResultOverride,
66 | };
67 | },
68 | );
69 | }
70 |
71 | let cleanup: Subscription;
72 | describe("react bridge: connect() tests", () => {
73 | let store: Store;
74 | let mountInsideStoreProvider: (elem: JSX.Element) => Enzyme.ReactWrapper;
75 | let ConnectedTestComponent: any;
76 |
77 | const initialState: TestState = {
78 | message: "initialMessage",
79 | slice: {
80 | sliceMessage: "initialSliceMessage",
81 | },
82 | };
83 |
84 | beforeEach(() => {
85 | setupJSDomEnv();
86 | cleanup = new Subscription();
87 | ConnectedTestComponent = getConnectedComponent();
88 | store = Store.create(initialState);
89 | store.addReducer(nextMessage, (state, message) => {
90 | return {
91 | ...state,
92 | message,
93 | };
94 | });
95 | store.destroyed.subscribe(() => cleanup.unsubscribe());
96 | mountInsideStoreProvider = (elem: JSX.Element) =>
97 | Enzyme.mount({elem} );
98 | });
99 |
100 | it("should map a prop from the state to the prop of the component using props observable", () => {
101 | const wrapper = mountInsideStoreProvider( );
102 | const messageText = wrapper.find("h1").text();
103 | expect(messageText).to.equal(initialState.message);
104 | });
105 |
106 | it("should receive prop updates from the store using mapStateToProps", () => {
107 | const wrapper = mountInsideStoreProvider( );
108 | expect(wrapper.find("h1").text()).to.equal(initialState.message);
109 |
110 | nextMessage.next("Message1");
111 | expect(wrapper.find("h1").text()).to.equal("Message1");
112 |
113 | nextMessage.next("Message2");
114 | expect(wrapper.find("h1").text()).to.equal("Message2");
115 | });
116 |
117 | it("should trigger an action on a callback function in the actionMap", done => {
118 | const wrapper = mountInsideStoreProvider( );
119 | globalClicked.pipe(take(1)).subscribe(() => {
120 | expect(true).to.be.true;
121 | done();
122 | });
123 | wrapper.find("button").simulate("click");
124 | });
125 |
126 | it("should allow to override props on the connected component", done => {
127 | const onClick = () => {
128 | done();
129 | };
130 | const wrapper = mountInsideStoreProvider( );
131 |
132 | const messageText = wrapper.find("h1").text();
133 | expect(messageText).to.equal("Barfoos");
134 | wrapper.find("button").simulate("click");
135 | });
136 |
137 | it("should use the provided props if there is no store in context", done => {
138 | const clicked = new Subject();
139 | const onClick = () => setTimeout(() => done(), 50);
140 | clicked.subscribe(() => {
141 | done("Error: called the subject");
142 | });
143 | const wrapper = Enzyme.mount( );
144 | const messageText = wrapper.find("h1").text();
145 | expect(messageText).to.equal("Barfoos");
146 | wrapper.find("button").simulate("click");
147 | wrapper.unmount();
148 | });
149 |
150 | it("should use a props if it updated later on", done => {
151 | const Root: React.SFC<{ message?: string }> = props => {
152 | return (
153 |
154 |
155 |
156 | );
157 | };
158 | const wrapper = Enzyme.mount( );
159 | const textMessage = wrapper.find("h1").text();
160 | // we provided a message props - even though its undefined at first, its mere presence should supersede the
161 | // connected prop of message
162 | expect(textMessage).to.equal("");
163 | setTimeout(() => {
164 | wrapper.setProps({ message: "Bla" });
165 | const textMessage = wrapper.find("h1").text();
166 | expect(textMessage).to.equal("Bla");
167 | done();
168 | }, 50);
169 | });
170 |
171 | it("unsubscribe the cleanup subscription on component unmount", done => {
172 | cleanup.add(() => done());
173 | const wrapper = mountInsideStoreProvider( );
174 | wrapper.unmount();
175 | });
176 |
177 | it("should allow the connect callback to return empty result object and then use the provided props", done => {
178 | ConnectedTestComponent = getConnectedComponent(null);
179 | const onClick = () => done();
180 | const wrapper = mountInsideStoreProvider( );
181 | const textMessage = wrapper.find("h1").text();
182 | expect(textMessage).to.equal("Bla");
183 | wrapper.find("button").simulate("click");
184 | });
185 |
186 | it("should allow an observer in an actionMap", done => {
187 | const onClick = new Subject();
188 | const actionMap: ActionMap = {
189 | onClick,
190 | };
191 | onClick.subscribe(() => done());
192 | ConnectedTestComponent = getConnectedComponent({ actionMap });
193 | const wrapper = mountInsideStoreProvider( );
194 | wrapper.find("button").simulate("click");
195 | });
196 |
197 | it("should allow callback functions in an actionMap", done => {
198 | const actionMap: ActionMap = {
199 | onClick: () => done(),
200 | };
201 | ConnectedTestComponent = getConnectedComponent({ actionMap });
202 | const wrapper = mountInsideStoreProvider( );
203 | wrapper.find("button").simulate("click");
204 | });
205 |
206 | it("should throw an error for invalid entries in the action map", () => {
207 | const actionMap: ActionMap = {
208 | onClick: 5 as any,
209 | };
210 | expect(() => {
211 | ConnectedTestComponent = getConnectedComponent({ actionMap });
212 | const wrapper = mountInsideStoreProvider( );
213 | wrapper.find("button").simulate("click");
214 | }).to.throw();
215 | });
216 |
217 | it("should allow undefined fields in an actionMap to ignore callbacks", done => {
218 | const actionMap: ActionMap = {
219 | onClick: undefined,
220 | };
221 | ConnectedTestComponent = getConnectedComponent({ actionMap });
222 | cleanup.add(() => done());
223 | const wrapper = mountInsideStoreProvider( );
224 | wrapper.find("button").simulate("click");
225 | wrapper.unmount();
226 | });
227 |
228 | // Typing regression
229 | it("should be possible for props to operate on any store/slice", () => {
230 | const ConnectedTestComponent = connect(
231 | TestComponent,
232 | (store: Store) => {
233 | const slice = store.createSlice("message", "Blafoo");
234 | const props = slice.watch(message => ({ message }));
235 |
236 | return {
237 | props,
238 | };
239 | },
240 | );
241 |
242 | const wrapper = mountInsideStoreProvider( {}} />);
243 | const messageText = wrapper.find("h1").text();
244 | expect(messageText).to.equal("Blafoo");
245 | });
246 |
247 | it("should be possible to add additional props to a connected component and subscribe to it in the connect callback", done => {
248 | type ComponentProps = { message: string };
249 | const Component: React.SFC = props => {props.message} ;
250 |
251 | // add another prop field to our component
252 | type InputProps = ComponentProps & { additionalProp: number };
253 | const ConnectedTestComponent = connect(
254 | Component,
255 | (store: Store, inputProps: Observable) => {
256 | inputProps.pipe(take(1)).subscribe(props => {
257 | expect(props).to.deep.equal({ test: 1 });
258 | });
259 |
260 | inputProps
261 | .pipe(
262 | skip(1),
263 | take(1),
264 | )
265 | .subscribe(props => {
266 | expect(props).to.deep.equal({ test: 2 });
267 | setTimeout(() => done(), 100);
268 | });
269 | return {
270 | props: of({ message: "Foobar" }),
271 | };
272 | },
273 | );
274 |
275 | const Root: React.SFC = (props: any) => (
276 |
277 |
278 |
279 | );
280 | const wrapper = Enzyme.mount( );
281 | wrapper.setProps({
282 | test: 2,
283 | });
284 | const messageText = wrapper.find("h1").text();
285 | expect(messageText).to.equal("Foobar");
286 | wrapper.unmount();
287 | });
288 |
289 | // this is a compilation test to assert that the type inference for connect() works when manually specifing type arguments
290 | it("should be possible to infer the inputProps type", done => {
291 | type ComponentProps = { message: string };
292 | const Component: React.SFC = props => {props.message} ;
293 |
294 | // add another prop field to our component
295 | type InputProps = { additionalProp: number };
296 | const ConnectedTestComponent = connect(
297 | Component,
298 | (store, inputProps) => {
299 | const props = of({ message: "Foobar", additionalProp: 5 });
300 | return { props };
301 | },
302 | );
303 | console.info(ConnectedTestComponent);
304 | done();
305 | });
306 |
307 | it("should not be possible to pass props that are already passed from the store", (done) => {
308 | const ConnectedTestComponent = connect(TestComponent, (store: Store) => {
309 | const props = store.watch((state) => ({
310 | message: state.message,
311 | }));
312 | return { props };
313 | });
314 | console.info( {}} />);
315 | type TypeTests = [
316 | Expect, { onClick: (arg1: any) => void }>>,
317 | Expect, TestComponentProps>>,
318 | Expect, Omit>>,
319 | ];
320 | // To avoid error TS6196: 'TypeTests' is declared but never used.
321 | console.log({} as TypeTests);
322 | done();
323 | });
324 | });
325 |
--------------------------------------------------------------------------------
/test/test_react_storeprovider.tsx:
--------------------------------------------------------------------------------
1 | import { expect } from "chai";
2 | import * as Enzyme from "enzyme";
3 | import "mocha";
4 | import * as React from "react";
5 | import { Subject } from "rxjs";
6 | import { take, toArray } from "rxjs/operators";
7 | import { connect, StoreProjection, StoreProvider, StoreSlice, WithStore, useStore } from "../react";
8 | import { Store } from "../src/index";
9 | import { setupJSDomEnv } from "./test_enzyme_helper";
10 | import { SliceState, TestComponent, TestState } from "./test_react_connect";
11 | import { useStoreState, useStoreSlices } from "../react/provider";
12 |
13 | describe("react bridge: StoreProvider and StoreSlice tests", () => {
14 | const nextMessage = new Subject();
15 | let store: Store;
16 | let wrapper: Enzyme.ReactWrapper | null | undefined;
17 | beforeEach(() => {
18 | setupJSDomEnv();
19 | store = Store.create({
20 | message: "initialMessage",
21 | slice: {
22 | sliceMessage: "initialSliceMessage",
23 | },
24 | });
25 | store.addReducer(nextMessage, (state, message) => {
26 | return {
27 | ...state,
28 | message,
29 | };
30 | });
31 | });
32 |
33 | afterEach(() => {
34 | store.destroy();
35 | if (wrapper) {
36 | wrapper.unmount();
37 | wrapper = undefined;
38 | }
39 | });
40 |
41 | // TODO this test exposes a bug in the destroy logic of .clone(), see connect.tsx TODO
42 | it("can use StoreSlice with an object slice and delete slice state after unmount", done => {
43 | const nextSliceMessage = new Subject();
44 |
45 | const ConnectedTestComponent = connect(
46 | TestComponent,
47 | (store: Store) => {
48 | const props = store.select(state => {
49 | return { message: state.sliceMessage };
50 | });
51 |
52 | store.addReducer(nextSliceMessage, (state, newMessage) => {
53 | return {
54 | ...state,
55 | sliceMessage: newMessage,
56 | };
57 | });
58 | return {
59 | props,
60 | };
61 | },
62 | );
63 |
64 | store
65 | .watch(s => s.slice)
66 | .pipe(
67 | take(4),
68 | toArray(),
69 | )
70 | .subscribe(arr => {
71 | expect(arr[0]!.sliceMessage).to.equal("initialSliceMessage");
72 | expect(arr[1]!.sliceMessage).to.equal("1");
73 | expect(arr[2]!.sliceMessage).to.equal("objectslice");
74 | expect(arr[3]).to.be.undefined;
75 | setTimeout(() => {
76 | done();
77 | }, 50);
78 | });
79 |
80 | const initialSliceState: SliceState = {
81 | sliceMessage: "1",
82 | };
83 |
84 | wrapper = Enzyme.mount(
85 |
86 | ) => "slice"}
88 | initialState={initialSliceState}
89 | cleanupState={"delete"}
90 | >
91 | {}} />
92 |
93 | ,
94 | );
95 | nextSliceMessage.next("objectslice");
96 | const messageText = wrapper.find("h1").text();
97 | expect(messageText).to.equal("objectslice");
98 | wrapper.unmount();
99 | wrapper = null;
100 | });
101 |
102 | it("should be possible for two StoreProvider as siblings to offer different stores", done => {
103 | const store1 = Store.create({ foo: "foo" });
104 | const store2 = Store.create({ bar: "bar" });
105 | wrapper = Enzyme.mount(
106 |
107 |
108 |
109 | {store => {
110 | store
111 | .select()
112 | .pipe(take(1))
113 | .subscribe(state => {
114 | expect(state.foo).to.equal("foo");
115 | });
116 | return foo ;
117 | }}
118 |
119 |
120 |
121 |
122 | {store => {
123 | store
124 | .select()
125 | .pipe(take(1))
126 | .subscribe(state => {
127 | expect(state.bar).to.equal("bar");
128 | setTimeout(() => {
129 | done();
130 | }, 50);
131 | });
132 | return bar ;
133 | }}
134 |
135 |
136 |
,
137 | );
138 | });
139 |
140 | it("should allow StoreProvider to be nested and return the correct instances for WithStore", () => {
141 | const store1 = Store.create({ level: "level1" });
142 | const store2 = Store.create({ level: "level2" });
143 |
144 | wrapper = Enzyme.mount(
145 |
146 |
147 |
148 | {level1Store => {
149 | level1Store
150 | .select()
151 | .pipe(take(1))
152 | .subscribe(state => {
153 | expect(state.level).to.equal("level1");
154 | });
155 | return (
156 |
157 |
158 | {level2Store => {
159 | level2Store
160 | .select()
161 | .pipe(take(1))
162 | .subscribe(state => {
163 | expect(state.level).to.equal("level2");
164 | });
165 | return Foobar ;
166 | }}
167 |
168 |
169 | );
170 | }}
171 |
172 |
173 |
,
174 | );
175 | });
176 |
177 | it("should allow StoreProvider to be nested and return the correct instances for connect", () => {
178 | const store1 = Store.create({ level: "level1" });
179 | const store2 = Store.create({ level: "level2" });
180 | const ConnectedTestComponent = connect(
181 | TestComponent,
182 | (store: Store<{ level: string }>) => {
183 | const props = store.select(state => ({ message: state.level }));
184 | return {
185 | props,
186 | };
187 | },
188 | );
189 |
190 | wrapper = Enzyme.mount(
191 |
192 | {}} />
193 |
194 | {}} />
195 |
196 | ,
197 | );
198 |
199 | const text1 = wrapper
200 | .find("h1")
201 | .at(0)
202 | .text();
203 | const text2 = wrapper
204 | .find("h1")
205 | .at(1)
206 | .text();
207 | expect(text1).to.equal("level1");
208 | expect(text2).to.equal("level2");
209 | });
210 |
211 | it("should assert the store slice is destroyed when the StoreSlice component unmounts", done => {
212 | const ConnectedTestComponent = connect(
213 | TestComponent,
214 | (store: Store) => {
215 | store.destroyed.subscribe(() => done());
216 | return {};
217 | },
218 | );
219 |
220 | wrapper = Enzyme.mount(
221 |
222 | ) => "slice"}>
223 | {}} message="Test" />
224 |
225 | ,
226 | );
227 | wrapper.update();
228 | wrapper.update();
229 | wrapper.unmount();
230 | wrapper = null;
231 | });
232 |
233 | it("can use StoreSlice with a string slice", () => {
234 | const ConnectedTestComponent = connect(
235 | TestComponent,
236 | (store: Store) => {
237 | const props = store.select(message => ({ message }));
238 | return {
239 | props,
240 | };
241 | },
242 | );
243 |
244 | wrapper = Enzyme.mount(
245 |
246 | ) => "message"}>
247 | {}} />
248 |
249 | ,
250 | );
251 | nextMessage.next("stringslice");
252 | const messageText = wrapper.find("h1").text();
253 | expect(messageText).to.equal("stringslice");
254 | });
255 |
256 | it("can use StoreProjection", () => {
257 | const ConnectedTestComponent = connect(
258 | TestComponent,
259 | (store: Store) => {
260 | const props = store.select(message => ({ message }));
261 | return {
262 | props,
263 | };
264 | },
265 | );
266 |
267 | const forward = state => state.message;
268 | const backward = (state: string, parent) => ({ ...parent, message: state });
269 |
270 | wrapper = Enzyme.mount(
271 |
272 |
273 | {}} />
274 |
275 | ,
276 | );
277 | nextMessage.next("stringprojection");
278 | const messageText = wrapper.find("h1").text();
279 | expect(messageText).to.equal("stringprojection");
280 | });
281 |
282 | it("should be possible to get a context store instance with the WithStore render prop", done => {
283 | const SampleSFC: React.SFC<{ store: Store }> = props => {
284 | expect(store).to.be.ok;
285 | store.destroy();
286 | return null;
287 | };
288 | store.destroyed.subscribe(() => done());
289 |
290 | wrapper = Enzyme.mount(
291 |
292 |
293 | {theStore => }
294 |
295 |
,
296 | );
297 | });
298 |
299 | it("should throw an error if StoreSlice is used outside of a StoreProvider context", () => {
300 | expect(() => {
301 | Enzyme.mount() => "slice"} />);
302 | }).to.throw();
303 | });
304 |
305 | it("should throw an error if StoreProjection is used outside of a StoreProvider context", () => {
306 | const forward = (state: any) => state;
307 | const backward = forward;
308 |
309 | expect(() => {
310 | Enzyme.mount( );
311 | }).to.throw();
312 | });
313 |
314 | it("should throw an error if WithStore is used outside of a StoreProvider context", () => {
315 | const SampleSFC: React.SFC<{ store: Store }> = props => {
316 | return null;
317 | };
318 | expect(() => {
319 | Enzyme.mount({theStore => } );
320 | }).to.throw();
321 | });
322 |
323 | it("should throw an error if WithStore is used but no function is supplied as child", () => {
324 | expect(() => {
325 | Enzyme.mount(
326 |
327 |
328 | Not a function
329 |
330 | ,
331 | );
332 | }).to.throw();
333 | });
334 |
335 | it("should be possible to get a store using the useStore hook", done => {
336 | const TestComponent = () => {
337 | const storeFromHook = useStore();
338 | expect(storeFromHook).to.equal(store);
339 | done();
340 | return null;
341 | };
342 |
343 | Enzyme.mount(
344 |
345 |
346 | ,
347 | );
348 | });
349 |
350 | it("should throw an error is useStore is used out of context", () => {
351 | const TestComponent = () => {
352 | useStore();
353 | return null;
354 | };
355 | expect(() => Enzyme.mount( )).to.throw();
356 | });
357 |
358 | it("should be possible to get a state slice using useStoreState", done => {
359 | const TestComponent = () => {
360 | const slice = useStoreState(({ message }) => ({ message }));
361 | expect(slice.message).to.equal(store.currentState.message);
362 | done();
363 | return null;
364 | };
365 |
366 | wrapper = Enzyme.mount(
367 |
368 |
369 | ,
370 | );
371 | });
372 |
373 | it("should receive store updates when using useStoreState", done => {
374 | const TestComponent = () => {
375 | const state = useStoreState()
376 | const firstRender = React.useRef(true)
377 | if (!firstRender.current) {
378 | expect(state.message).to.equal("msg2")
379 | done();
380 | }
381 | firstRender.current = false
382 | return null;
383 | };
384 |
385 | wrapper = Enzyme.mount(
386 |
387 |
388 | ,
389 | );
390 |
391 | setTimeout(() => {
392 | nextMessage.next("msg2")
393 | }, 50)
394 | });
395 |
396 |
397 | it("should be possible to get a state slice using useSlicer", done => {
398 | const TestComponent = () => {
399 | const slice = useStoreSlices()(({ message }) => ({ message }));
400 | expect(slice.message).to.equal(store.currentState.message);
401 | done();
402 | return null;
403 | };
404 |
405 | wrapper = Enzyme.mount(
406 |
407 |
408 | ,
409 | );
410 | });
411 |
412 | });
413 |
--------------------------------------------------------------------------------
/src/store.ts:
--------------------------------------------------------------------------------
1 | import { EMPTY, isObservable, Observable, Subject, Subscription, AsyncSubject, BehaviorSubject } from "rxjs";
2 | import { distinctUntilChanged, filter, map, merge, scan, takeUntil } from "rxjs/operators";
3 | import { shallowEqual } from "./shallowEqual";
4 | import { ActionDispatch, CleanupState, Reducer, StateChangeNotification, StateMutation } from "./types";
5 |
6 | // TODO use typings here
7 | declare var require: any;
8 | const isPlainObject = require("lodash.isplainobject");
9 | const isObject = require("lodash.isobject");
10 |
11 | /**
12 | * A function which takes a Payload and return a state mutation function.
13 | */
14 | type RootReducer = (payload: P) => StateMutation;
15 |
16 | /**
17 | * Creates a state based on a stream of StateMutation functions and an initial state. The returned observable
18 | * is hot and caches the last emitted value (will emit the last emitted value immediately upon subscription).
19 | * @param stateMutators
20 | * @param initialState
21 | */
22 | function createState(stateMutators: Observable>, initialState: S): BehaviorSubject {
23 | const state = new BehaviorSubject(initialState);
24 | stateMutators.pipe(scan((state: S, reducer: StateMutation) => reducer(state), initialState)).subscribe(state);
25 |
26 | return state;
27 | }
28 |
29 | export class Store {
30 | /**
31 | * Observable that emits when the store was destroyed using the .destroy() function.
32 | */
33 | public readonly destroyed: Observable;
34 |
35 | // TODO This is truly an BehaviorSubject but due to some typing issues we need to cast it as Observable?
36 | private readonly state: Observable;
37 |
38 | public get currentState(): S {
39 | // TODO see above: this.state is actually a BehaviorSubject but typescript or rxjs typings make trouble
40 | return (this.state as any).value;
41 | }
42 |
43 | /**
44 | * All reducers always produce a state mutation of the original root store type R;
45 | * However, we only now type R for the root store; all other stores may have different type
46 | * so we use any here as the root type.
47 | */
48 | private readonly stateMutators: Subject