├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.json ├── .github └── dependabot.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── demo ├── index.html └── index.tsx ├── jest.config.js ├── package.json ├── setup.js ├── src ├── connect.tsx ├── core │ ├── ayanami.ts │ ├── decorators │ │ ├── action-related.ts │ │ └── index.ts │ ├── ikari.ts │ ├── index.ts │ ├── scope │ │ ├── __test__ │ │ │ └── scope.spec.ts │ │ ├── index.ts │ │ ├── same-scope-decorator.ts │ │ ├── type.ts │ │ └── utils.ts │ ├── symbols.ts │ ├── types.ts │ └── utils │ │ ├── basic-state.ts │ │ ├── get-effect-action-factories.ts │ │ ├── get-original-functions.ts │ │ └── index.ts ├── hooks │ ├── index.ts │ ├── use-ayanami-instance.ts │ ├── use-ayanami.ts │ └── use-subscribe-ayanami-state.ts ├── index.ts ├── redux-devtools-extension.ts ├── ssr │ ├── constants.ts │ ├── express.ts │ ├── flag.ts │ ├── index.ts │ ├── run.ts │ ├── ssr-context.tsx │ ├── ssr-module.ts │ └── terminate.ts └── test-helper │ └── index.ts ├── test └── specs │ ├── __snapshots__ │ └── ssr.spec.tsx.snap │ ├── ayanami.spec.ts │ ├── connect.spec.tsx │ ├── define-action.spec.ts │ ├── effect.spec.ts │ ├── hooks.spec.tsx │ ├── ikari.spec.ts │ ├── immer-reducer.spec.ts │ ├── reducer.spec.ts │ ├── ssr.spec.tsx │ └── utils.spec.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | 2 | defaults: &defaults 3 | working_directory: ~/ayanami 4 | docker: 5 | - image: circleci/node:10-browsers 6 | 7 | version: 2 8 | jobs: 9 | build: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | - run: echo 'export PATH=${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin' >> $BASH_ENV 14 | - run: curl --compressed -o- -L https://yarnpkg.com/install.sh | bash 15 | - run: sudo ln -sf ~/.yarn/bin/yarn /usr/local/bin/yarn 16 | - restore_cache: 17 | key: dependency-cache-{{ checksum "package.json" }} 18 | - run: 19 | name: yarn-with-greenkeeper 20 | command: | 21 | sudo yarn global add greenkeeper-lockfile@1 22 | yarn 23 | - save_cache: 24 | key: dependency-cache-{{ checksum "package.json" }} 25 | paths: 26 | - ~/.cache/yarn 27 | - run: greenkeeper-lockfile-update 28 | - run: yarn build 29 | - run: greenkeeper-lockfile-upload 30 | - persist_to_workspace: 31 | root: ~/ayanami 32 | paths: 33 | - ./* 34 | test: 35 | <<: *defaults 36 | steps: 37 | - attach_workspace: 38 | at: ~/ayanami 39 | - run: yarn lint 40 | - run: yarn check_circular_dependencies 41 | - run: yarn test 42 | - run: 43 | name: report-coverage 44 | command: npx codecov -f coverage/*.json 45 | 46 | deploy: 47 | <<: *defaults 48 | steps: 49 | - attach_workspace: 50 | at: ~/ayanami 51 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 52 | - run: | 53 | if git log -1 --pretty=%B | grep "^[0-9]\+\.[0-9]\+\.[0-9]\+$"; 54 | then 55 | npm publish 56 | else 57 | echo "Not a release, skipping publish" 58 | fi 59 | workflows: 60 | version: 2 61 | build_test_and_deploy: 62 | jobs: 63 | - build 64 | - test: 65 | requires: 66 | - build 67 | - deploy: 68 | requires: 69 | - test 70 | filters: 71 | tags: 72 | only: /.*/ 73 | branches: 74 | only: master -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # from .gitignore 2 | node_modules/ 3 | coverage/ 4 | dist/ 5 | .idea 6 | .cache 7 | *.tgz 8 | .DS_Store 9 | esm 10 | esnext 11 | 12 | # custom ignore pattern 13 | *.html 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@typescript-eslint"], 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "plugin:react/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier" 8 | ], 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "rules": { 14 | "@typescript-eslint/explicit-member-accessibility": [ 15 | "error", 16 | { 17 | "accessibility": "no-public", 18 | "overrides": { 19 | "parameterProperties": "explicit" 20 | } 21 | } 22 | ], 23 | "@typescript-eslint/explicit-function-return-type": "off", 24 | "@typescript-eslint/no-non-null-assertion": "off", 25 | "@typescript-eslint/no-parameter-properties": "off", 26 | "@typescript-eslint/no-use-before-define": ["error", { "functions": false, "classes": false }], 27 | "@typescript-eslint/no-explicit-any": "off", 28 | "@typescript-eslint/no-var-requires": "off", 29 | "@typescript-eslint/ban-ts-comment": "off", 30 | "no-console": ["error", { "allow": ["warn", "error"] }] 31 | }, 32 | "settings": { 33 | "react": { 34 | "version": "detect" 35 | } 36 | }, 37 | "env": { 38 | "browser": true, 39 | "es6": true 40 | }, 41 | "parserOptions": { 42 | "project": "./tsconfig.json", 43 | "extraFileExtensions": [".html"], 44 | "ecmaFeatures": { 45 | "jsx": true 46 | }, 47 | "ecmaVersion": 2018, 48 | "sourceType": "module" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: "@types/node" 11 | versions: 12 | - 14.14.41 13 | - 15.0.0 14 | - dependency-name: eslint 15 | versions: 16 | - 7.24.0 17 | - dependency-name: husky 18 | versions: 19 | - 5.1.3 20 | - 6.0.0 21 | - dependency-name: eslint-plugin-react 22 | versions: 23 | - 7.23.0 24 | - dependency-name: "@types/jest" 25 | versions: 26 | - 26.0.21 27 | - dependency-name: immer 28 | versions: 29 | - 8.0.4 30 | - dependency-name: typescript 31 | versions: 32 | - 4.1.3 33 | - 4.1.4 34 | - 4.1.5 35 | - 4.2.2 36 | - 4.2.3 37 | - dependency-name: madge 38 | versions: 39 | - 4.0.0 40 | - 4.0.1 41 | - dependency-name: "@types/react" 42 | versions: 43 | - 17.0.0 44 | - 17.0.1 45 | - 17.0.2 46 | - dependency-name: rxjs 47 | versions: 48 | - 6.6.3 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | .idea 5 | .cache 6 | *.tgz 7 | .DS_Store 8 | esm 9 | esnext -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .idea 4 | /src 5 | /demo 6 | /test 7 | .prettierrc 8 | .eslintignore 9 | .eslintrc.json 10 | tsconfig.json 11 | tsconfig.build.json 12 | tslint.json 13 | yarn.lock 14 | *.tgz 15 | .cache 16 | jest.config.js 17 | .git/ 18 | .circleci/ 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "singleQuote": true, 6 | "arrowParens": "always", 7 | "jsxBracketSameLine": false, 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 LeetCode Open Source 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Ayanami

2 |

3 | A better way to react with state. Inspired by redux-epics-decorator 4 |

5 | 6 |

7 | 8 | GitHub license 9 | 10 | 11 | PRs Welcome 12 | 13 | 14 | code style: prettier 15 | 16 | 17 | npm version 18 | 19 | 20 | codecov 21 | 22 | 23 | CircleCI 24 | 25 | 26 | minzipped size 27 | 28 |

29 | 30 | ## Highlights 31 | 32 | - No extra configuration, everything is out of the box 33 | - Define state and actions in a **predictable** and **type-safe** way 34 | - Use **[`RxJS`](https://rxjs-dev.firebaseapp.com)** to create side effects and more 35 | - **Debuggable**: Inspect actions and state changes via [`redux-devtools-extension`](https://github.com/zalmoxisus/redux-devtools-extension) 36 | 37 | ## Installation 38 | 39 | ##### Using [yarn](https://yarnpkg.com/en/package/ayanami): 40 | 41 | ```bash 42 | yarn add ayanami @asuka/di reflect-metadata rxjs immer 43 | ``` 44 | 45 | ##### Or via [npm](https://www.npmjs.com/package/ayanami): 46 | 47 | ```bash 48 | npm install ayanami @asuka/di reflect-metadata rxjs immer 49 | ``` 50 | 51 | ## Examples 52 | 53 | - [Reducer example](https://codesandbox.io/s/py5o3ojo7x) 54 | - [ImmerReducer example](https://codesandbox.io/s/ayanamiimmerreducerexample-r07kk) 55 | - [Effect example](https://codesandbox.io/s/nnko0rxjv4) 56 | - [Scope example](https://codesandbox.io/s/jlz44wrymw) 57 | - [Interact with other Ayanami example](https://codesandbox.io/s/ly5ol8xrqz) 58 | - [SameScope example](https://codesandbox.io/s/k97lqxwvn5) 59 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | Demo 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/index.tsx: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@asuka/di' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { Observable, of } from 'rxjs' 5 | import { mergeMap } from 'rxjs/operators' 6 | 7 | import { Ayanami, Effect, EffectAction, Reducer, useAyanami } from '../src' 8 | 9 | interface State { 10 | count: number 11 | input: string 12 | } 13 | 14 | interface TipsState { 15 | tips: string 16 | } 17 | 18 | @Injectable() 19 | class Tips extends Ayanami { 20 | defaultState = { 21 | tips: '', 22 | } 23 | 24 | @Reducer() 25 | showTips(state: TipsState, tips: string): TipsState { 26 | return { ...state, tips } 27 | } 28 | } 29 | 30 | @Injectable() 31 | class Count extends Ayanami { 32 | defaultState = { 33 | count: 0, 34 | input: '', 35 | } 36 | 37 | otherProps = '' 38 | 39 | constructor(private readonly tips: Tips) { 40 | super() 41 | } 42 | 43 | @Reducer() 44 | add(state: State, count: number): State { 45 | return { ...state, count: state.count + count } 46 | } 47 | 48 | @Reducer() 49 | addOne(state: State): State { 50 | return { ...state, count: state.count + 1 } 51 | } 52 | 53 | @Reducer() 54 | reset(): State { 55 | return { count: 0, input: '' } 56 | } 57 | 58 | @Reducer() 59 | changeInput(state: State, value: string): State { 60 | return { ...state, input: value } 61 | } 62 | 63 | @Effect() 64 | minus(count$: Observable): Observable { 65 | return count$.pipe( 66 | mergeMap((subCount) => 67 | of( 68 | this.getActions().add(-subCount), 69 | this.tips.getActions().showTips(`click minus button at ${Date.now()}`), 70 | ), 71 | ), 72 | ) 73 | } 74 | } 75 | 76 | const InputComponent = React.memo(() => { 77 | const [input, actions] = useAyanami(Count, { selector: (state) => state.input }) 78 | 79 | return ( 80 |
81 |

{input}

82 | actions.changeInput(e.target.value)} /> 83 |
84 | ) 85 | }) 86 | InputComponent.displayName = 'InputComponent' 87 | 88 | function CountComponent() { 89 | const [{ count, input }, actions] = useAyanami(Count) 90 | const [{ tips }] = useAyanami(Tips) 91 | 92 | const add = (count: number) => () => actions.add(count) 93 | const minus = (count: number) => () => actions.minus(count) 94 | 95 | return ( 96 |
97 |

count: {count}

98 |

input: {input}

99 |

tips: {tips}

100 | 101 | 102 | 103 | 104 |
105 | ) 106 | } 107 | 108 | ReactDOM.render(, document.querySelector('#app')) 109 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: [ 5 | '**/__tests__/**/?(*.)+(spec|test).ts?(x)', '**/?(*.)+(spec|test).ts?(x)', 6 | ], 7 | globalSetup: './setup.js' 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ayanami", 3 | "version": "0.20.1", 4 | "description": "A better way to react with state", 5 | "main": "./dist/index.js", 6 | "module": "./esm/index.js", 7 | "esnext": "./esnext/index.js", 8 | "types": "./esm/index.d.ts", 9 | "sideEffects": false, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/LeetCode-OpenSource/ayanami.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/LeetCode-OpenSource/ayanami/issues" 16 | }, 17 | "homepage": "https://github.com/LeetCode-OpenSource/ayanami#readme", 18 | "scripts": { 19 | "check_circular_dependencies": "madge esm/index.js --circular --warning", 20 | "demo": "parcel ./demo/index.html", 21 | "build": "npm-run-all -p build:es5 build:esm build:next", 22 | "build:es5": "shx rm -rf ./dist && tsc -p ./tsconfig.build.json", 23 | "build:esm": "shx rm -rf ./esm && tsc -p ./tsconfig.build.json -m esnext --outDir esm", 24 | "build:next": "shx rm -rf ./esnext && tsc -p ./tsconfig.build.json --target esnext --outDir esnext", 25 | "prettier": "prettier '@(src|demo)/**/*.@(ts|tsx|html|less)' --write", 26 | "lint": "yarn lint:eslint && yarn lint:tsc", 27 | "lint:eslint": "eslint . --ext .ts,.tsx --fix --max-warnings 0", 28 | "lint:tsc": "tsc -p ./tsconfig.json --noEmit", 29 | "test": "jest --collectCoverage", 30 | "prepare": "husky install" 31 | }, 32 | "husky": { 33 | "hooks": { 34 | "pre-commit": "lint-staged" 35 | } 36 | }, 37 | "lint-staged": { 38 | "*.{ts,tsx}": [ 39 | "prettier --write", 40 | "yarn lint:eslint", 41 | "git add" 42 | ], 43 | "*.{less,html}": [ 44 | "prettier --write", 45 | "git add" 46 | ] 47 | }, 48 | "keywords": [ 49 | "React", 50 | "hooks", 51 | "Observables", 52 | "Observable", 53 | "model", 54 | "state", 55 | "Rx", 56 | "RxJS", 57 | "ReactiveX" 58 | ], 59 | "author": "LeetCode front-end team", 60 | "license": "MIT", 61 | "dependencies": { 62 | "shallowequal": "^1.1.0" 63 | }, 64 | "devDependencies": { 65 | "@asuka/di": "^0.2.0", 66 | "@types/express": "^4.17.0", 67 | "@types/jest": "^26.0.21", 68 | "@types/lodash": "^4.14.136", 69 | "@types/node": "^15.0.1", 70 | "@types/react": "^17.0.3", 71 | "@types/react-dom": "^17.0.2", 72 | "@types/react-test-renderer": "^17.0.1", 73 | "@types/shallowequal": "^1.1.1", 74 | "@typescript-eslint/eslint-plugin": "^4.18.0", 75 | "@typescript-eslint/parser": "^4.18.0", 76 | "codecov": "^3.5.0", 77 | "eslint": "7.25.0", 78 | "eslint-config-prettier": "^8.1.0", 79 | "eslint-plugin-react": "^7.21.5", 80 | "husky": "^6.0.0", 81 | "immer": "^9.0.1", 82 | "jest": "^26.6.3", 83 | "lint-staged": "^10.5.4", 84 | "lodash": "^4.17.15", 85 | "madge": "^4.0.1", 86 | "npm-run-all": "^4.1.5", 87 | "parcel": "^1.12.3", 88 | "prettier": "^2.2.1", 89 | "react": "^17.0.1", 90 | "react-dom": "^17.0.1", 91 | "react-test-renderer": "^17.0.1", 92 | "reflect-metadata": "^0.1.13", 93 | "rxjs": "^6.5.2", 94 | "shx": "^0.3.2", 95 | "ts-jest": "^26.5.4", 96 | "tslib": "^2.1.0", 97 | "typescript": "^4.2.3" 98 | }, 99 | "peerDependencies": { 100 | "@asuka/di": "^0.2.0", 101 | "immer": "^9.0.1", 102 | "lodash": "^4.17.15", 103 | "react": "^17.0.1", 104 | "reflect-metadata": "^0.1.13", 105 | "rxjs": "^6.5.2" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | module.exports = function setupTestEnv() { 2 | process.env.ENABLE_AYANAMI_SSR = 'false' 3 | } 4 | -------------------------------------------------------------------------------- /src/connect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { Ayanami, ActionMethodOfAyanami, ConstructorOf, Omit } from './core' 4 | import { useAyanami } from './hooks' 5 | 6 | type ConnectedComponent = React.FunctionComponent> 7 | 8 | type ConnectComponent =

(Component: React.ComponentType

) => ConnectedComponent 9 | 10 | export interface ComponentConnectedWithAyanami, S> { 11 | (): ConnectComponent, Record> 12 | 13 | (mapStateToProps: (state: S) => MS): ConnectComponent> 14 | 15 | ( 16 | mapStateToProps: (state: S) => MS, 17 | mapActionsToProps: (actions: ActionMethodOfAyanami) => MA, 18 | ): ConnectComponent 19 | 20 | ( 21 | mapStateToProps: null, 22 | mapActionsToProps: (actions: ActionMethodOfAyanami) => MA, 23 | ): ConnectComponent, MA> 24 | } 25 | 26 | export function connectAyanami, S>( 27 | AyanamiClass: ConstructorOf, 28 | ): M extends Ayanami 29 | ? ComponentConnectedWithAyanami 30 | : ComponentConnectedWithAyanami { 31 | return function connectMap( 32 | mapStateToProps?: (props: S) => SP, 33 | mapActionsToProps?: (actions: ActionMethodOfAyanami) => AP, 34 | ) { 35 | return function connectComponent

(Component: React.ComponentType

) { 36 | return function ConnectAyanami(props: P) { 37 | const [state, action] = useAyanami(AyanamiClass) 38 | const mappedState = mapStateToProps ? mapStateToProps(state) : {} 39 | const mappedAction = mapActionsToProps ? mapActionsToProps(action as any) : {} 40 | 41 | return 42 | } 43 | } 44 | } as any 45 | } 46 | -------------------------------------------------------------------------------- /src/core/ayanami.ts: -------------------------------------------------------------------------------- 1 | import { Observable, noop } from 'rxjs' 2 | 3 | import { ActionOfAyanami } from './types' 4 | import { combineWithIkari, destroyIkariFrom } from './ikari' 5 | import { moduleNameKey, globalKey } from '../ssr/ssr-module' 6 | import { isSSREnabled } from '../ssr/flag' 7 | 8 | const globalScope = 9 | typeof self !== 'undefined' 10 | ? self 11 | : typeof window !== 'undefined' 12 | ? window 13 | : typeof global !== 'undefined' 14 | ? global 15 | : {} 16 | 17 | export abstract class Ayanami { 18 | abstract defaultState: State 19 | 20 | // @internal 21 | ssrLoadKey = Symbol('SSR_LOADED') 22 | 23 | // @internal 24 | scopeName!: string 25 | 26 | constructor() { 27 | if (!isSSREnabled()) { 28 | const name = Object.getPrototypeOf(this)[moduleNameKey] 29 | if (!name) { 30 | return 31 | } 32 | // @ts-ignore 33 | const globalCache = globalScope[globalKey] 34 | 35 | if (globalCache) { 36 | const moduleCache = globalCache[name] 37 | if (moduleCache) { 38 | Reflect.defineMetadata(this.ssrLoadKey, true, this) 39 | Object.defineProperty(this, 'defaultState', { 40 | get: () => moduleCache[this.scopeName], 41 | set: noop, 42 | }) 43 | } 44 | } 45 | } 46 | } 47 | 48 | destroy(): void { 49 | destroyIkariFrom(this) 50 | } 51 | 52 | getState$>( 53 | this: M, 54 | ): M extends Ayanami ? Observable> : Observable> { 55 | return combineWithIkari(this).state.state$ as any 56 | } 57 | 58 | getState>( 59 | this: M, 60 | ): M extends Ayanami ? Readonly : Readonly { 61 | return combineWithIkari(this).state.getState() as any 62 | } 63 | 64 | getActions>( 65 | this: M, 66 | ): M extends Ayanami ? ActionOfAyanami : ActionOfAyanami { 67 | return combineWithIkari(this).effectActionFactories as any 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/core/decorators/action-related.ts: -------------------------------------------------------------------------------- 1 | import { allActionSymbols, ActionSymbols } from '../symbols' 2 | import { Ayanami } from '../ayanami' 3 | import { ConstructorOf } from '../types' 4 | 5 | export function createActionDecorator(symbols: ActionSymbols) { 6 | return () => ( 7 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 8 | target: any, 9 | propertyKey: string, 10 | descriptor: PropertyDescriptor, 11 | ): PropertyDescriptor => { 12 | addActionName(symbols, target.constructor, propertyKey) 13 | return descriptor 14 | } 15 | } 16 | 17 | // eslint-disable-next-line @typescript-eslint/ban-types 18 | function addActionName(symbols: ActionSymbols, constructor: Function, actionName: string) { 19 | const decoratedActionNames = Reflect.getMetadata(symbols.decorator, constructor) || [] 20 | Reflect.defineMetadata(symbols.decorator, [...decoratedActionNames, actionName], constructor) 21 | } 22 | 23 | export function getActionNames>( 24 | symbols: ActionSymbols, 25 | constructor: ConstructorOf, 26 | ): (keyof T)[] { 27 | return Reflect.getMetadata(symbols.decorator, constructor) || [] 28 | } 29 | 30 | export function getAllActionNames>(instance: T): (keyof T)[] { 31 | return allActionSymbols.reduce<(keyof T)[]>( 32 | (result, symbols) => [ 33 | ...result, 34 | ...getActionNames(symbols, instance.constructor as ConstructorOf), 35 | ], 36 | [], 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/core/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs' 2 | import { Draft } from 'immer' 3 | 4 | import { defineActionSymbols, effectSymbols, reducerSymbols, immerReducerSymbols } from '../symbols' 5 | import { EffectAction } from '../types' 6 | import { createActionDecorator } from './action-related' 7 | 8 | export * from './action-related' 9 | 10 | interface DecoratorReturnType { 11 | (target: any, propertyKey: string, descriptor: { value?: V }): PropertyDescriptor 12 | } 13 | 14 | export const ImmerReducer: () => DecoratorReturnType< 15 | (state: Draft, params: any) => undefined | void 16 | > = createActionDecorator(immerReducerSymbols) 17 | 18 | export const Reducer: () => DecoratorReturnType< 19 | (state: S, params: any) => S 20 | > = createActionDecorator(reducerSymbols) 21 | 22 | export const Effect: () => DecoratorReturnType< 23 | (action: Observable, state$: Observable) => Observable 24 | > = createActionDecorator(effectSymbols) 25 | 26 | export const DefineAction: () => any = createActionDecorator(defineActionSymbols) 27 | -------------------------------------------------------------------------------- /src/core/ikari.ts: -------------------------------------------------------------------------------- 1 | import { merge, Observable, Subject, Subscription, NEVER } from 'rxjs' 2 | import { map, catchError, takeUntil, filter } from 'rxjs/operators' 3 | import mapValues from 'lodash/mapValues' 4 | import produce from 'immer' 5 | 6 | import { 7 | EffectAction, 8 | ReducerAction, 9 | OriginalEffectActions, 10 | OriginalReducerActions, 11 | OriginalImmerReducerActions, 12 | OriginalDefineActions, 13 | TriggerActions, 14 | EffectActionFactories, 15 | } from './types' 16 | import { Ayanami } from './ayanami' 17 | import { createState, getEffectActionFactories, getOriginalFunctions } from './utils' 18 | import { logStateAction } from '../redux-devtools-extension' 19 | import { ikariSymbol } from './symbols' 20 | import { TERMINATE_ACTION } from '../ssr/terminate' 21 | import { isSSREnabled } from '../ssr/flag' 22 | 23 | interface Config { 24 | nameForLog: string 25 | defaultState: State 26 | effects: OriginalEffectActions 27 | reducers: OriginalReducerActions 28 | immerReducers: OriginalImmerReducerActions 29 | defineActions: OriginalDefineActions 30 | effectActionFactories: EffectActionFactories 31 | } 32 | 33 | interface Action { 34 | readonly effectAction?: EffectAction 35 | readonly reducerAction?: ReducerAction 36 | readonly originalActionName: string 37 | } 38 | 39 | function catchRxError() { 40 | return catchError((err) => { 41 | console.error(err) 42 | 43 | return NEVER 44 | }) 45 | } 46 | 47 | export function combineWithIkari(ayanami: Ayanami): Ikari { 48 | const ikari = Ikari.getFrom(ayanami) 49 | 50 | if (ikari) { 51 | return ikari 52 | } else { 53 | const { effects, reducers, immerReducers, defineActions } = getOriginalFunctions(ayanami) 54 | 55 | Object.assign( 56 | ayanami, 57 | mapValues(defineActions, ({ observable }) => observable), 58 | ) 59 | 60 | return Ikari.createAndBindAt(ayanami, { 61 | nameForLog: ayanami.constructor.name, 62 | defaultState: ayanami.defaultState, 63 | effects, 64 | reducers, 65 | immerReducers, 66 | defineActions, 67 | effectActionFactories: getEffectActionFactories(ayanami), 68 | }) 69 | } 70 | } 71 | 72 | export function destroyIkariFrom(ayanami: Ayanami): void { 73 | const ikari = Ikari.getFrom(ayanami) 74 | 75 | if (ikari) { 76 | ikari.destroy() 77 | Reflect.deleteMetadata(ikariSymbol, ayanami) 78 | } 79 | } 80 | 81 | export class Ikari { 82 | static createAndBindAt(target: Ayanami, config: Config): Ikari { 83 | const createdIkari = this.getFrom(target) 84 | 85 | if (createdIkari) { 86 | return createdIkari 87 | } else { 88 | const ikari = new Ikari(target, config) 89 | Reflect.defineMetadata(ikariSymbol, ikari, target) 90 | return ikari 91 | } 92 | } 93 | 94 | static getFrom(target: { defaultState: S }): Ikari | undefined { 95 | return Reflect.getMetadata(ikariSymbol, target) 96 | } 97 | 98 | state = createState(this.config.defaultState) 99 | 100 | effectActionFactories = this.config.effectActionFactories 101 | 102 | triggerActions: TriggerActions = {} 103 | 104 | subscription = new Subscription() 105 | 106 | // @internal 107 | terminate$ = new Subject() 108 | 109 | // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility 110 | constructor(readonly ayanami: Ayanami, private readonly config: Readonly>) { 111 | const [effectActions$, effectActions] = setupEffectActions( 112 | this.config.effects, 113 | this.state.state$, 114 | ) 115 | 116 | const [reducerActions$, reducerActions] = setupReducerActions( 117 | this.config.reducers, 118 | this.state.getState, 119 | ) 120 | 121 | const [immerReducerActions$, immerReducerActions] = setupImmerReducerActions( 122 | this.config.immerReducers, 123 | this.state.getState, 124 | ) 125 | 126 | this.triggerActions = { 127 | ...effectActions, 128 | ...reducerActions, 129 | ...immerReducerActions, 130 | ...mapValues(this.config.defineActions, ({ next }) => next), 131 | } 132 | 133 | let effectActionsWithTerminate$: Observable> 134 | 135 | if (!isSSREnabled()) { 136 | effectActionsWithTerminate$ = effectActions$ 137 | } else { 138 | effectActionsWithTerminate$ = effectActions$.pipe( 139 | takeUntil(this.terminate$.pipe(filter((action) => action === null))), 140 | ) 141 | } 142 | 143 | this.subscription.add( 144 | effectActionsWithTerminate$.subscribe((action) => { 145 | this.log(action) 146 | this.handleAction(action) 147 | }), 148 | ) 149 | 150 | this.subscription.add( 151 | reducerActions$.subscribe((action) => { 152 | this.log(action) 153 | this.handleAction(action) 154 | }), 155 | ) 156 | 157 | this.subscription.add( 158 | immerReducerActions$.subscribe((action) => { 159 | this.log(action) 160 | this.handleAction(action) 161 | }), 162 | ) 163 | } 164 | 165 | destroy(): void { 166 | this.subscription.unsubscribe() 167 | this.triggerActions = {} 168 | } 169 | 170 | private log = ({ originalActionName, effectAction, reducerAction }: Action) => { 171 | if (effectAction && effectAction !== TERMINATE_ACTION) { 172 | logStateAction(this.config.nameForLog, { 173 | params: effectAction.params, 174 | actionName: `${originalActionName}/👉${effectAction.ayanami.constructor.name}/️${effectAction.actionName}`, 175 | }) 176 | } 177 | 178 | if (reducerAction) { 179 | logStateAction(this.config.nameForLog, { 180 | params: reducerAction.params, 181 | actionName: originalActionName, 182 | state: reducerAction.nextState, 183 | }) 184 | } 185 | } 186 | 187 | private handleAction = ({ effectAction, reducerAction }: Action) => { 188 | if (effectAction) { 189 | if (effectAction !== TERMINATE_ACTION) { 190 | const { ayanami, actionName, params } = effectAction 191 | combineWithIkari(ayanami).triggerActions[actionName](params) 192 | } else { 193 | this.terminate$.next(effectAction) 194 | } 195 | } 196 | 197 | if (reducerAction) { 198 | this.state.setState(reducerAction.nextState) 199 | } 200 | } 201 | } 202 | 203 | function setupEffectActions( 204 | effectActions: OriginalEffectActions, 205 | state$: Observable, 206 | ): [Observable>, TriggerActions] { 207 | const actions: TriggerActions = {} 208 | const effects: Observable>[] = [] 209 | 210 | Object.keys(effectActions).forEach((actionName) => { 211 | const payload$ = new Subject() 212 | actions[actionName] = (payload: any) => payload$.next(payload) 213 | 214 | const effect$: Observable = effectActions[actionName](payload$, state$) 215 | effects.push( 216 | effect$.pipe( 217 | map( 218 | (effectAction): Action => ({ 219 | effectAction, 220 | originalActionName: actionName, 221 | }), 222 | ), 223 | catchRxError(), 224 | ), 225 | ) 226 | }) 227 | 228 | return [merge(...effects), actions] 229 | } 230 | 231 | function setupReducerActions( 232 | reducerActions: OriginalReducerActions, 233 | getState: () => State, 234 | ): [Observable>, TriggerActions] { 235 | const actions: TriggerActions = {} 236 | const reducers: Observable>[] = [] 237 | 238 | Object.keys(reducerActions).forEach((actionName) => { 239 | const reducer$ = new Subject>() 240 | reducers.push(reducer$) 241 | 242 | const reducer = reducerActions[actionName] 243 | 244 | actions[actionName] = (params: any) => { 245 | const nextState = reducer(getState(), params) 246 | 247 | reducer$.next({ 248 | reducerAction: { params, actionName, nextState }, 249 | originalActionName: actionName, 250 | }) 251 | } 252 | }) 253 | 254 | return [merge(...reducers), actions] 255 | } 256 | 257 | function setupImmerReducerActions( 258 | immerReducerActions: OriginalImmerReducerActions, 259 | getState: () => State, 260 | ): [Observable>, TriggerActions] { 261 | const actions: TriggerActions = {} 262 | const immerReducers: Observable>[] = [] 263 | 264 | Object.keys(immerReducerActions).forEach((actionName) => { 265 | const immerReducer$ = new Subject>() 266 | immerReducers.push(immerReducer$) 267 | 268 | const immerReducer = immerReducerActions[actionName] 269 | 270 | actions[actionName] = (params: any) => { 271 | const nextState = produce(getState(), (draft) => { 272 | immerReducer(draft, params) 273 | }) 274 | 275 | immerReducer$.next({ 276 | reducerAction: { params, actionName, nextState }, 277 | originalActionName: actionName, 278 | }) 279 | } 280 | }) 281 | 282 | return [merge(...immerReducers), actions] 283 | } 284 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ayanami' 2 | export * from './ikari' 3 | export * from './types' 4 | export * from './decorators' 5 | export * from './utils' 6 | export * from './scope' 7 | -------------------------------------------------------------------------------- /src/core/scope/__test__/scope.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { Injectable } from '@asuka/di' 3 | 4 | import { getInstanceWithScope, TransientScope, SameScope } from '../' 5 | import { createOrGetInstanceInScope } from '../utils' 6 | 7 | describe('Scope spec:', () => { 8 | describe('createOrGetInstanceInScope', () => { 9 | class Test {} 10 | const scope = 'Scope' 11 | 12 | it('should create a new instance if not in scope', () => { 13 | expect(createOrGetInstanceInScope(Test, scope)).toBeInstanceOf(Test) 14 | }) 15 | 16 | it('should return a same instance if in scope', () => { 17 | expect(createOrGetInstanceInScope(Test, scope)).toBe(createOrGetInstanceInScope(Test, scope)) 18 | }) 19 | }) 20 | 21 | describe('getInstanceWithScope', () => { 22 | it('should accept any valid variable as scope', () => { 23 | const scopes = ['', 0, Symbol('symbol'), {}, null] 24 | 25 | scopes.forEach((scope) => { 26 | class Test {} 27 | expect(getInstanceWithScope(Test, scope)).toBeInstanceOf(Test) 28 | }) 29 | }) 30 | 31 | it('should always return same instance if same scope', () => { 32 | const scope = 'scope' 33 | class Test {} 34 | 35 | expect(getInstanceWithScope(Test, scope)).toBe(getInstanceWithScope(Test, scope)) 36 | }) 37 | 38 | it('should always return new instance if scope is TransientScope', () => { 39 | class Test {} 40 | 41 | expect(getInstanceWithScope(Test, TransientScope)).toBeInstanceOf(Test) 42 | 43 | expect( 44 | getInstanceWithScope(Test, TransientScope) === getInstanceWithScope(Test, TransientScope), 45 | ).toBeFalsy() 46 | }) 47 | 48 | describe('Injection scope', () => { 49 | describe('default behavior', () => { 50 | @Injectable() 51 | class A {} 52 | 53 | @Injectable() 54 | class B { 55 | constructor(public a: A) {} 56 | } 57 | 58 | @Injectable() 59 | class C { 60 | constructor(public a: A) {} 61 | } 62 | 63 | @Injectable() 64 | class D { 65 | constructor(public a: A) {} 66 | } 67 | 68 | it('should return same instance whether scope is same or not', () => { 69 | const b = getInstanceWithScope(B, 'b') 70 | const c1 = getInstanceWithScope(C, 'c1') 71 | const c2 = getInstanceWithScope(C, 'c2') 72 | const d = getInstanceWithScope(D, 'c2') 73 | 74 | expect(b.a).toBeInstanceOf(A) 75 | expect(b.a).toBe(c1.a) 76 | expect(c1.a).toBe(c2.a) 77 | expect(c2.a).toBe(d.a) 78 | }) 79 | }) 80 | 81 | describe('with SameScope decorator', () => { 82 | @Injectable() 83 | class A {} 84 | 85 | @Injectable() 86 | class B { 87 | constructor(@SameScope() public a: A) {} 88 | } 89 | 90 | @Injectable() 91 | class C { 92 | constructor(@SameScope() public a: A) {} 93 | } 94 | 95 | it('should return same instance if is same scope', () => { 96 | const scope = Symbol('scope') 97 | const b = getInstanceWithScope(B, scope) 98 | const c = getInstanceWithScope(C, scope) 99 | 100 | expect(b.a).toBeInstanceOf(A) 101 | expect(b.a).toBe(c.a) 102 | }) 103 | 104 | it('should return different instance if is different scope', () => { 105 | const b = getInstanceWithScope(B, Symbol('b')) 106 | const c1 = getInstanceWithScope(C, Symbol('c1')) 107 | const c2 = getInstanceWithScope(C, Symbol('c2')) 108 | 109 | expect(b.a).toBeInstanceOf(A) 110 | expect(c1.a).toBeInstanceOf(A) 111 | expect(c2.a).toBeInstanceOf(A) 112 | expect(b.a === c1.a).toBeFalsy() 113 | expect(c1.a === c2.a).toBeFalsy() 114 | }) 115 | }) 116 | }) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /src/core/scope/index.ts: -------------------------------------------------------------------------------- 1 | import { InjectableFactory } from '@asuka/di' 2 | 3 | import { ConstructorOf } from '../types' 4 | import { ScopeConfig } from './type' 5 | import { createOrGetInstanceInScope, createScopeWithRequest } from './utils' 6 | import { SameScope } from './same-scope-decorator' 7 | 8 | export { ScopeConfig, SameScope, createScopeWithRequest } 9 | 10 | export const TransientScope = Symbol('scope:transient') 11 | 12 | export const SingletonScope = Symbol('scope:singleton') 13 | 14 | export function getInstanceWithScope( 15 | constructor: ConstructorOf, 16 | scope: ScopeConfig['scope'] = SingletonScope, 17 | ): T { 18 | switch (scope) { 19 | case SingletonScope: 20 | return InjectableFactory.getInstance(constructor) 21 | case TransientScope: 22 | return InjectableFactory.initialize(constructor) 23 | default: 24 | return createOrGetInstanceInScope(constructor, scope) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/core/scope/same-scope-decorator.ts: -------------------------------------------------------------------------------- 1 | export const SameScopeMetadataKey = Symbol('SameScopeInjectionParams') 2 | 3 | export const SameScope = () => ( 4 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 5 | target: any, 6 | _propertyKey: string, 7 | parameterIndex: number, 8 | ): void => { 9 | let sameScopeInjectionParams: boolean[] = [] 10 | if (Reflect.hasMetadata(SameScopeMetadataKey, target)) { 11 | sameScopeInjectionParams = Reflect.getMetadata(SameScopeMetadataKey, target) 12 | } else { 13 | Reflect.defineMetadata(SameScopeMetadataKey, sameScopeInjectionParams, target) 14 | } 15 | sameScopeInjectionParams[parameterIndex] = true 16 | } 17 | -------------------------------------------------------------------------------- /src/core/scope/type.ts: -------------------------------------------------------------------------------- 1 | export type Scope = any 2 | 3 | export interface ScopeConfig { 4 | scope: Scope 5 | } 6 | -------------------------------------------------------------------------------- /src/core/scope/utils.ts: -------------------------------------------------------------------------------- 1 | import { InjectableFactory, Inject } from '@asuka/di' 2 | import { Request } from 'express' 3 | 4 | import { ConstructorOf } from '../types' 5 | import { Scope } from './type' 6 | import { SameScopeMetadataKey } from './same-scope-decorator' 7 | import { CleanupSymbol } from '../../ssr/constants' 8 | import { isSSREnabled } from '../../ssr/flag' 9 | import { reqMap } from '../../ssr/express' 10 | 11 | type ScopeMap = Map 12 | 13 | type Instance = any 14 | 15 | type Key = ConstructorOf 16 | 17 | const map: Map> = new Map() 18 | 19 | export const ayanamiInstances: Map = new Map() 20 | 21 | export function createOrGetInstanceInScope(constructor: ConstructorOf, scope: Scope): T { 22 | const instanceAtScope = getInstanceFrom(constructor, scope) 23 | 24 | return instanceAtScope ? instanceAtScope : createInstanceInScope(constructor, scope) 25 | } 26 | 27 | function createInstanceInScope(constructor: ConstructorOf, scope: Scope): T { 28 | const paramTypes: ConstructorOf[] = 29 | Reflect.getMetadata('design:paramtypes', constructor) || [] 30 | 31 | // parameters decorated by @Inject 32 | const paramAnnotations = Reflect.getMetadata('parameters', constructor) || [] 33 | 34 | const sameScopeParams: number[] = Reflect.getMetadata(SameScopeMetadataKey, constructor) || [] 35 | 36 | const deps = paramTypes.map((type, index) => { 37 | if (sameScopeParams[index]) { 38 | return createOrGetInstanceInScope(type, scope) 39 | } 40 | 41 | if (paramAnnotations[index] !== null) { 42 | let metadata: any[] = paramAnnotations[index] 43 | metadata = Array.isArray(metadata) ? metadata : [metadata] 44 | const inject: Inject | undefined = metadata.find((factory) => factory instanceof Inject) 45 | if (inject && inject.token) { 46 | return InjectableFactory.getInstanceByToken(inject.token) 47 | } 48 | } 49 | 50 | return InjectableFactory.getInstance(type) 51 | }) 52 | 53 | const newInstance = new constructor(...deps) 54 | 55 | setInstanceInScope(constructor, scope, newInstance) 56 | return newInstance 57 | } 58 | 59 | function setInstanceInScope(constructor: ConstructorOf, scope: Scope, newInstance: Instance) { 60 | const scopeMap: ScopeMap = map.get(constructor) || new Map() 61 | 62 | scopeMap.set(scope, newInstance) 63 | map.set(constructor, scopeMap) 64 | newInstance[CleanupSymbol] = () => { 65 | newInstance.destroy() 66 | scopeMap.delete(scope) 67 | } 68 | 69 | if (isSSREnabled()) { 70 | ayanamiInstances.set(scope, (ayanamiInstances.get(scope) || []).concat(newInstance)) 71 | } 72 | } 73 | 74 | function getInstanceFrom(constructor: ConstructorOf, scope: Scope): T | undefined { 75 | const scopeMap = map.get(constructor) 76 | 77 | return scopeMap && scopeMap.get(scope) 78 | } 79 | 80 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 81 | export function createScopeWithRequest(req: Request, scope: any | undefined) { 82 | if (!scope) { 83 | return req 84 | } 85 | if (reqMap.has(req)) { 86 | const scopes = reqMap.get(req)! 87 | if (scopes.has(scope)) { 88 | return scopes.get(scope)! 89 | } else { 90 | const reqScope = { req, scope } 91 | scopes.set(scope, reqScope) 92 | return reqScope 93 | } 94 | } else { 95 | const reqScopeMap = new Map() 96 | const reqScope = { req, scope } 97 | reqScopeMap.set(scope, reqScope) 98 | reqMap.set(req, reqScopeMap) 99 | return reqScope 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/core/symbols.ts: -------------------------------------------------------------------------------- 1 | export interface ActionSymbols { 2 | decorator: symbol 3 | actions: symbol 4 | } 5 | 6 | export const effectSymbols: ActionSymbols = { 7 | decorator: Symbol('decorator:effect'), 8 | actions: Symbol('actions:effect'), 9 | } 10 | 11 | export const reducerSymbols: ActionSymbols = { 12 | decorator: Symbol('decorator:reducer'), 13 | actions: Symbol('actions:reducer'), 14 | } 15 | 16 | export const immerReducerSymbols: ActionSymbols = { 17 | decorator: Symbol('decorator:immer-reducer'), 18 | actions: Symbol('actions:immer-reducer'), 19 | } 20 | 21 | export const defineActionSymbols: ActionSymbols = { 22 | decorator: Symbol('decorator:defineAction'), 23 | actions: Symbol('actions:defineAction'), 24 | } 25 | 26 | export const allActionSymbols = [ 27 | effectSymbols, 28 | reducerSymbols, 29 | immerReducerSymbols, 30 | defineActionSymbols, 31 | ] 32 | 33 | export const ikariSymbol = Symbol('ikari') 34 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs' 2 | import { Draft } from 'immer' 3 | 4 | import { Ayanami } from './ayanami' 5 | 6 | // https://stackoverflow.com/questions/55541275/typescript-check-for-the-any-type 7 | type IfAny = 0 extends 1 & T ? Y : N 8 | 9 | type IsAny = IfAny 10 | 11 | // https://stackoverflow.com/questions/55542332/typescript-conditional-type-with-discriminated-union 12 | type IsVoid = IsAny extends true ? false : [T] extends [void] ? true : false 13 | 14 | // using class type to avoid conflict with user defined params 15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 16 | class ArgumentsType<_Arguments extends any[]> {} 17 | 18 | export type ActionMethod< 19 | T extends ArgumentsType | never, 20 | R = void 21 | > = T extends ArgumentsType 22 | ? IsVoid extends true 23 | ? () => R 24 | : Extract extends never 25 | ? (params: Arguments[0]) => R 26 | : (params?: Arguments[0]) => R 27 | : (params: T) => R 28 | 29 | export interface ConstructorOf { 30 | new (...args: any[]): T 31 | } 32 | 33 | export type Omit = Pick> 34 | 35 | export interface EffectAction { 36 | readonly ayanami: Ayanami 37 | readonly actionName: string 38 | readonly params: any 39 | } 40 | 41 | export interface ReducerAction { 42 | readonly actionName: string 43 | readonly params: any 44 | readonly nextState: State 45 | } 46 | 47 | // eslint-disable-next-line @typescript-eslint/ban-types 48 | type UnpackEffectFunctionArguments = T extends ( 49 | ...payload: infer Arguments 50 | ) => Observable 51 | ? Arguments[0] extends Observable 52 | ? ArgumentsType<[P]> 53 | : never 54 | : never 55 | 56 | type UnpackEffectPayload = Func extends () => Observable 57 | ? UnpackEffectFunctionArguments extends never 58 | ? ArgumentsType<[void]> 59 | : UnpackEffectFunctionArguments 60 | : Func extends (payload$: Observable) => Observable 61 | ? UnpackEffectFunctionArguments 62 | : Func extends (payload$: Observable, state$: Observable) => Observable 63 | ? UnpackEffectFunctionArguments 64 | : never 65 | 66 | // eslint-disable-next-line @typescript-eslint/ban-types 67 | type UnpackReducerFunctionArguments = T extends ( 68 | state: any, 69 | ...payload: infer Arguments 70 | ) => any 71 | ? ArgumentsType 72 | : never 73 | 74 | type UnpackReducerPayload = Func extends () => State 75 | ? UnpackReducerFunctionArguments extends never 76 | ? ArgumentsType<[void]> 77 | : UnpackReducerFunctionArguments 78 | : Func extends (state: State) => State 79 | ? UnpackReducerFunctionArguments 80 | : Func extends (state: State, payload: any) => State 81 | ? UnpackReducerFunctionArguments 82 | : never 83 | 84 | type UnpackImmerReducerPayload = Func extends (state: Draft) => void 85 | ? UnpackReducerFunctionArguments 86 | : Func extends (state: Draft, payload: any) => void 87 | ? UnpackReducerFunctionArguments 88 | : never 89 | 90 | type UnpackDefineActionPayload = OB extends Observable ? ArgumentsType<[P]> : never 91 | 92 | type UnpackPayload = UnpackEffectPayload extends never 93 | ? UnpackReducerPayload extends never 94 | ? UnpackImmerReducerPayload extends never 95 | ? UnpackDefineActionPayload 96 | : UnpackImmerReducerPayload 97 | : UnpackReducerPayload 98 | : UnpackEffectPayload 99 | 100 | type PayloadMethodKeySet = { 101 | [key in SS]: M[key] extends 102 | | (() => Observable) 103 | | ((payload$: Observable) => Observable) 104 | | ((payload$: Observable, state$: Observable) => Observable) 105 | ? key 106 | : M[key] extends (() => S) | ((state: S) => S) | ((state: S, payload: any) => S) 107 | ? key 108 | : M[key] extends ((state: Draft) => void) | ((state: Draft, payload: any) => void) 109 | ? key 110 | : M[key] extends Observable 111 | ? key 112 | : never 113 | }[SS] 114 | 115 | export type ActionMethodOfAyanami, S> = Pick< 116 | { [key in keyof M]: ActionMethod> }, 117 | PayloadMethodKeySet>> 118 | > 119 | 120 | export type ActionOfAyanami, S> = Pick< 121 | { [key in keyof M]: ActionMethod, EffectAction> }, 122 | PayloadMethodKeySet>> 123 | > 124 | 125 | export interface ObjectOf { 126 | [key: string]: T 127 | } 128 | 129 | export type OriginalEffectActions = ObjectOf< 130 | (payload$: Observable, state: Observable) => Observable> 131 | > 132 | 133 | export type OriginalReducerActions = ObjectOf< 134 | (state: State, payload: any) => Readonly 135 | > 136 | 137 | export type OriginalImmerReducerActions = ObjectOf< 138 | (state: Draft, payload: any) => void 139 | > 140 | 141 | export type OriginalDefineActions = ObjectOf<{ 142 | next(params: any): void 143 | observable: Observable 144 | }> 145 | 146 | export type TriggerActions = ObjectOf> 147 | 148 | export type EffectActionFactories = ObjectOf<(params: any) => EffectAction> 149 | -------------------------------------------------------------------------------- /src/core/utils/basic-state.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, Observable } from 'rxjs' 2 | 3 | export interface State { 4 | getState(): S 5 | setState(state: S): void 6 | state$: Observable 7 | } 8 | 9 | export function createState(defaultState: S): State { 10 | const _state$ = new BehaviorSubject(defaultState) 11 | 12 | return { 13 | getState() { 14 | return _state$.getValue() 15 | }, 16 | setState(state: S) { 17 | _state$.next(state) 18 | }, 19 | state$: _state$.asObservable(), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/core/utils/get-effect-action-factories.ts: -------------------------------------------------------------------------------- 1 | import { Ayanami } from '../ayanami' 2 | import { EffectAction } from '../types' 3 | import { getAllActionNames } from '../decorators' 4 | 5 | export function getEffectActionFactories(target: Ayanami): any { 6 | return getAllActionNames(target).reduce( 7 | (result: any, name: string) => ({ 8 | ...result, 9 | [name]: (params: any): EffectAction => ({ 10 | ayanami: target, 11 | actionName: name, 12 | params, 13 | }), 14 | }), 15 | {}, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/core/utils/get-original-functions.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs' 2 | 3 | import { 4 | OriginalDefineActions, 5 | OriginalEffectActions, 6 | OriginalReducerActions, 7 | OriginalImmerReducerActions, 8 | ConstructorOf, 9 | } from '../types' 10 | import { Ayanami } from '../ayanami' 11 | import { getActionNames } from '../decorators' 12 | import { effectSymbols, reducerSymbols, immerReducerSymbols, defineActionSymbols } from '../symbols' 13 | 14 | const getOriginalFunctionNames = (ayanami: Ayanami) => ({ 15 | effects: getActionNames(effectSymbols, ayanami.constructor as ConstructorOf>), 16 | reducers: getActionNames(reducerSymbols, ayanami.constructor as ConstructorOf>), 17 | defineActions: getActionNames( 18 | defineActionSymbols, 19 | ayanami.constructor as ConstructorOf>, 20 | ), 21 | immerReducers: getActionNames( 22 | immerReducerSymbols, 23 | ayanami.constructor as ConstructorOf>, 24 | ), 25 | }) 26 | 27 | const transformDefineActions = (actionNames: string[]): OriginalDefineActions => { 28 | const result: OriginalDefineActions = {} 29 | 30 | actionNames.forEach((actionName) => { 31 | const actions$ = new Subject() 32 | 33 | result[actionName] = { 34 | observable: actions$.asObservable(), 35 | next: (params: any) => actions$.next(params), 36 | } 37 | }) 38 | 39 | return result 40 | } 41 | 42 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 43 | export const getOriginalFunctions = (ayanami: Ayanami) => { 44 | const { effects, reducers, immerReducers, defineActions } = getOriginalFunctionNames(ayanami) 45 | 46 | return { 47 | effects: effects.reduce>((acc, method) => { 48 | acc[method] = ayanami[method].bind(ayanami) 49 | return acc 50 | }, {}), 51 | reducers: reducers.reduce>((acc, method) => { 52 | acc[method] = ayanami[method].bind(ayanami) 53 | return acc 54 | }, {}), 55 | immerReducers: immerReducers.reduce>((acc, method) => { 56 | acc[method] = ayanami[method].bind(ayanami) 57 | return acc 58 | }, {}), 59 | defineActions: transformDefineActions(defineActions), 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/core/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './basic-state' 2 | export * from './get-original-functions' 3 | export * from './get-effect-action-factories' 4 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-ayanami-instance' 2 | export * from './use-ayanami' 3 | -------------------------------------------------------------------------------- /src/hooks/use-ayanami-instance.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import get from 'lodash/get' 3 | 4 | import { ActionMethodOfAyanami, Ayanami, combineWithIkari } from '../core' 5 | import { useSubscribeAyanamiState } from './use-subscribe-ayanami-state' 6 | 7 | export interface UseAyanamiInstanceConfig { 8 | destroyWhenUnmount?: boolean 9 | selector?: (state: S) => U 10 | } 11 | 12 | export type UseAyanamiInstanceResult, S, U> = [U, ActionMethodOfAyanami] 13 | 14 | export function useAyanamiInstance, S, U>( 15 | ayanami: M, 16 | config?: UseAyanamiInstanceConfig, 17 | ): UseAyanamiInstanceResult { 18 | const ikari = React.useMemo(() => combineWithIkari(ayanami), [ayanami]) 19 | const state = useSubscribeAyanamiState(ayanami, config ? config.selector : undefined) 20 | 21 | React.useEffect( 22 | () => () => { 23 | const isDestroyWhenUnmount = get(config, 'destroyWhenUnmount', false) 24 | 25 | if (isDestroyWhenUnmount) { 26 | ayanami.destroy() 27 | } 28 | }, 29 | [ayanami, config], 30 | ) 31 | 32 | return [state, ikari.triggerActions] as UseAyanamiInstanceResult 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/use-ayanami.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import get from 'lodash/get' 3 | 4 | import { 5 | Ayanami, 6 | ConstructorOf, 7 | getInstanceWithScope, 8 | ScopeConfig, 9 | TransientScope, 10 | createScopeWithRequest, 11 | } from '../core' 12 | import { DEFAULT_SCOPE_NAME } from '../ssr/constants' 13 | import { isSSREnabled } from '../ssr/flag' 14 | import { SSRContext } from '../ssr/ssr-context' 15 | 16 | import { 17 | useAyanamiInstance, 18 | UseAyanamiInstanceResult as Result, 19 | UseAyanamiInstanceConfig, 20 | } from './use-ayanami-instance' 21 | 22 | interface Config extends Partial { 23 | selector?: (state: S) => U 24 | } 25 | 26 | export function useAyanami, S, U = M extends Ayanami ? SS : never>( 27 | A: ConstructorOf, 28 | config?: M extends Ayanami ? Config : never, 29 | ): M extends Ayanami 30 | ? NonNullable extends Config 31 | ? Result 32 | : Result 33 | : never { 34 | const scope = get(config, 'scope') 35 | const selector = get(config, 'selector') 36 | const req = isSSREnabled() ? React.useContext(SSRContext) : null 37 | const reqScope = req ? createScopeWithRequest(req, scope) : scope 38 | const ayanami = React.useMemo(() => getInstanceWithScope(A, reqScope), [reqScope]) 39 | ayanami.scopeName = scope || DEFAULT_SCOPE_NAME 40 | 41 | const useAyanamiInstanceConfig = React.useMemo>(() => { 42 | return { destroyWhenUnmount: scope === TransientScope, selector } 43 | }, [reqScope]) 44 | 45 | return useAyanamiInstance(ayanami, useAyanamiInstanceConfig) as any 46 | } 47 | -------------------------------------------------------------------------------- /src/hooks/use-subscribe-ayanami-state.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Subscription } from 'rxjs' 3 | import identity from 'lodash/identity' 4 | import shallowEqual from 'shallowequal' 5 | import { Ayanami } from '../core' 6 | 7 | export function useSubscribeAyanamiState, S, U = S>( 8 | ayanami: M, 9 | selector: (state: S) => U = identity, 10 | ): unknown { 11 | const [state, setState] = React.useState(() => selector(ayanami.getState())) 12 | 13 | const ayanamiRef = React.useRef | null>(null) 14 | const subscriptionRef = React.useRef(null) 15 | const stateRef = React.useRef(state) 16 | const isFirstRenderRef = React.useRef(true) 17 | 18 | if (ayanamiRef.current !== ayanami) { 19 | ayanamiRef.current = ayanami 20 | 21 | if (subscriptionRef.current) { 22 | subscriptionRef.current.unsubscribe() 23 | subscriptionRef.current = null 24 | } 25 | 26 | if (ayanami) { 27 | subscriptionRef.current = ayanami.getState$().subscribe((moduleState) => { 28 | if (isFirstRenderRef.current) return 29 | if (selector === identity) { 30 | setState(selector(moduleState)) 31 | stateRef.current = selector(moduleState) 32 | } else { 33 | const before = stateRef.current 34 | const after = selector(moduleState) 35 | if (!shallowEqual(before, after)) setState(after) 36 | stateRef.current = after 37 | } 38 | }) 39 | } 40 | } 41 | 42 | React.useEffect( 43 | () => () => { 44 | if (subscriptionRef.current) { 45 | subscriptionRef.current.unsubscribe() 46 | } 47 | }, 48 | [subscriptionRef], 49 | ) 50 | 51 | isFirstRenderRef.current = false 52 | return state 53 | } 54 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | export { 4 | Ayanami, 5 | ImmerReducer, 6 | Reducer, 7 | Effect, 8 | DefineAction, 9 | EffectAction, 10 | ActionMethodOfAyanami, 11 | TransientScope, 12 | SingletonScope, 13 | SameScope, 14 | } from './core' 15 | export { getAllActionsForTest } from './test-helper' 16 | export * from './hooks' 17 | export * from './connect' 18 | export * from './ssr' 19 | export { enableReduxLog, disableReduxLog } from './redux-devtools-extension' 20 | -------------------------------------------------------------------------------- /src/redux-devtools-extension.ts: -------------------------------------------------------------------------------- 1 | import noop from 'lodash/noop' 2 | 3 | interface DevTools { 4 | send(action: { type: string }, state?: Partial): void 5 | init(state: GlobalState): void 6 | } 7 | 8 | interface GlobalState { 9 | [modelName: string]: Record 10 | } 11 | 12 | const FakeReduxDevTools = { 13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 14 | connect: (_config: Record) => ({ send: noop, init: noop }), 15 | } 16 | 17 | const ReduxDevTools = 18 | (typeof window !== 'undefined' && (window as any).__REDUX_DEVTOOLS_EXTENSION__) || 19 | FakeReduxDevTools 20 | 21 | const STATE: GlobalState = {} 22 | 23 | const getDevTools = (() => { 24 | let devTools: DevTools 25 | 26 | return (): DevTools => { 27 | if (!devTools) { 28 | devTools = ReduxDevTools.connect({ name: `Ayanami` }) 29 | devTools.init({}) 30 | } 31 | return devTools 32 | } 33 | })() 34 | 35 | let isEnableLog = false 36 | 37 | export function enableReduxLog(): void { 38 | isEnableLog = true 39 | } 40 | 41 | export function disableReduxLog(): void { 42 | isEnableLog = false 43 | } 44 | 45 | export function logStateAction( 46 | namespace: string, 47 | infos: { actionName: string; params: string; state?: any }, 48 | ): void { 49 | if (isEnableLog) { 50 | const action = { 51 | type: `${namespace}/${infos.actionName}`, 52 | params: filterParams(infos.params), 53 | } 54 | 55 | if (infos.state) { 56 | STATE[namespace] = infos.state 57 | } 58 | 59 | getDevTools().send(action, STATE) 60 | } 61 | } 62 | 63 | function filterParams(params: any): any { 64 | if (params && typeof params === 'object') { 65 | if (params instanceof Event) { 66 | return `<>` 67 | } else if (params.nativeEvent instanceof Event) { 68 | return `<>` 69 | } 70 | } 71 | 72 | return params 73 | } 74 | -------------------------------------------------------------------------------- /src/ssr/constants.ts: -------------------------------------------------------------------------------- 1 | export const SSRSymbol = Symbol('__AyanamiSSR__') 2 | export const CleanupSymbol = Symbol('__Cleanup__') 3 | export const DEFAULT_SCOPE_NAME = '__$$AYANAMI_DEFAULT__SCOPE$$__' 4 | -------------------------------------------------------------------------------- /src/ssr/express.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import { Observable } from 'rxjs' 3 | import { skip } from 'rxjs/operators' 4 | 5 | import { SSRSymbol } from './constants' 6 | import { isSSREnabled } from './flag' 7 | import { Effect } from '../core/decorators' 8 | 9 | export const SKIP_SYMBOL = Symbol('skip') 10 | 11 | export const reqMap = new Map>() 12 | 13 | function addDecorator(target: any, method: any, middleware: any) { 14 | const existedMetas = Reflect.getMetadata(SSRSymbol, target) 15 | const meta = { action: method, middleware } 16 | if (existedMetas) { 17 | existedMetas.push(meta) 18 | } else { 19 | Reflect.defineMetadata(SSRSymbol, [meta], target) 20 | } 21 | } 22 | 23 | interface SSREffectOptions { 24 | /** 25 | * Function used to get effect payload. 26 | * 27 | * if SKIP_SYMBOL returned(`return skip()`), effect won't get dispatched when SSR 28 | * 29 | * @param req express request object 30 | * @param skip get a symbol used to let effect escape from ssr effects dispatching 31 | */ 32 | payloadGetter?: ( 33 | req: Request, 34 | skip: () => typeof SKIP_SYMBOL, 35 | ) => Payload | Promise | typeof SKIP_SYMBOL 36 | 37 | /** 38 | * Whether skip first effect dispatching in client if effect ever got dispatched when SSR 39 | * 40 | * @default true 41 | */ 42 | skipFirstClientDispatch?: boolean 43 | } 44 | 45 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 46 | export function SSREffect(options?: SSREffectOptions) { 47 | const { payloadGetter, skipFirstClientDispatch } = { 48 | payloadGetter: undefined, 49 | skipFirstClientDispatch: true, 50 | ...options, 51 | } 52 | 53 | return (target: T, method: string, descriptor: PropertyDescriptor) => { 54 | addDecorator(target, method, payloadGetter) 55 | if (!isSSREnabled() && skipFirstClientDispatch) { 56 | const originalValue = descriptor.value 57 | descriptor.value = function ( 58 | this: any, 59 | action$: Observable, 60 | state$?: Observable, 61 | ) { 62 | if (Reflect.getMetadata(this.ssrLoadKey, this)) { 63 | return originalValue.call(this, action$.pipe(skip(1)), state$) 64 | } 65 | return originalValue.call(this, action$, state$) 66 | } 67 | } 68 | return Effect()(target, method, descriptor) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ssr/flag.ts: -------------------------------------------------------------------------------- 1 | export const isSSREnabled = (): boolean => { 2 | return typeof process.env.ENABLE_AYANAMI_SSR !== 'undefined' 3 | ? process.env.ENABLE_AYANAMI_SSR === 'true' 4 | : typeof process !== 'undefined' && 5 | process.versions && 6 | typeof process.versions.node === 'string' 7 | } 8 | 9 | export const SSREnabled = isSSREnabled() 10 | -------------------------------------------------------------------------------- /src/ssr/index.ts: -------------------------------------------------------------------------------- 1 | export * from './express' 2 | export * from './constants' 3 | export * from './constants' 4 | export * from './ssr-module' 5 | export * from './terminate' 6 | export { emitSSREffects, ModuleMeta } from './run' 7 | export * from './flag' 8 | export * from './ssr-context' 9 | -------------------------------------------------------------------------------- /src/ssr/run.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import { from, race, timer, throwError } from 'rxjs' 3 | import { flatMap, skip, take, tap } from 'rxjs/operators' 4 | import { InjectableFactory } from '@asuka/di' 5 | 6 | import { combineWithIkari } from '../core/ikari' 7 | import { ConstructorOf } from '../core/types' 8 | import { Ayanami } from '../core/ayanami' 9 | import { 10 | createOrGetInstanceInScope, 11 | ayanamiInstances, 12 | createScopeWithRequest, 13 | } from '../core/scope/utils' 14 | import { SSRSymbol, CleanupSymbol, DEFAULT_SCOPE_NAME } from './constants' 15 | import { moduleNameKey } from './ssr-module' 16 | import { SKIP_SYMBOL, reqMap } from './express' 17 | 18 | export type ModuleMeta = 19 | | ConstructorOf> 20 | | { module: ConstructorOf>; scope: string } 21 | 22 | const skipFn = () => SKIP_SYMBOL 23 | 24 | /** 25 | * Run all @SSREffect decorated effects of given modules and extract latest states. 26 | * `cleanup` function returned must be called before end of responding 27 | * 28 | * @param req express request object 29 | * @param modules used ayanami modules 30 | * @param timeout seconds to wait before all effects stream out TERMINATE_ACTION 31 | * @returns object contains ayanami state and cleanup function 32 | */ 33 | export const emitSSREffects = ( 34 | req: Request, 35 | modules: ModuleMeta[], 36 | timeout = 3, 37 | ): Promise<{ state: any; cleanup: () => void }> => { 38 | const stateToSerialize: any = {} 39 | const cleanup = () => { 40 | // non-scope ayanami 41 | if (ayanamiInstances.has(req)) { 42 | ayanamiInstances.get(req)!.forEach((instance) => { 43 | instance[CleanupSymbol].call() 44 | }) 45 | ayanamiInstances.delete(req) 46 | } 47 | 48 | // scoped ayanami 49 | if (reqMap.has(req)) { 50 | Array.from(reqMap.get(req)!.values()).forEach((s) => { 51 | ayanamiInstances.get(s)!.forEach((instance) => { 52 | instance[CleanupSymbol].call() 53 | }) 54 | ayanamiInstances.delete(s) 55 | }) 56 | reqMap.delete(req) 57 | } 58 | } 59 | 60 | return modules.length === 0 61 | ? Promise.resolve({ state: stateToSerialize, cleanup }) 62 | : race( 63 | from(modules).pipe( 64 | flatMap(async (m) => { 65 | let constructor: ConstructorOf> 66 | let scope = DEFAULT_SCOPE_NAME 67 | if ('scope' in m) { 68 | constructor = m.module 69 | scope = m.scope 70 | } else { 71 | constructor = m 72 | } 73 | const metas = Reflect.getMetadata(SSRSymbol, constructor.prototype) 74 | if (metas) { 75 | const ayanamiInstance: any = InjectableFactory.initialize(constructor) 76 | const moduleName = ayanamiInstance[moduleNameKey] 77 | const ikari = combineWithIkari(ayanamiInstance) 78 | let skipCount = metas.length - 1 79 | for (const meta of metas) { 80 | const dispatcher = ikari.triggerActions[meta.action] 81 | if (meta.middleware) { 82 | const param = await meta.middleware(req, skipFn) 83 | if (param !== SKIP_SYMBOL) { 84 | dispatcher(param) 85 | } else { 86 | skipCount -= 1 87 | } 88 | } else { 89 | dispatcher(void 0) 90 | } 91 | } 92 | 93 | if (skipCount > -1) { 94 | await ikari.terminate$ 95 | .pipe( 96 | skip(skipCount), 97 | take(1), 98 | ) 99 | .toPromise() 100 | 101 | ikari.terminate$.next(null) 102 | const state = ikari.state.getState() 103 | if (stateToSerialize[moduleName]) { 104 | stateToSerialize[moduleName][scope] = state 105 | } else { 106 | stateToSerialize[moduleName] = { 107 | [scope]: state, 108 | } 109 | } 110 | const existedAyanami = createOrGetInstanceInScope( 111 | constructor, 112 | createScopeWithRequest(req, scope === DEFAULT_SCOPE_NAME ? undefined : scope), 113 | ) 114 | const existedIkari = combineWithIkari(existedAyanami) 115 | existedIkari.state.setState(state) 116 | ayanamiInstance.destroy() 117 | } 118 | } 119 | 120 | return { state: stateToSerialize, cleanup } 121 | }), 122 | ), 123 | timer(timeout * 1000).pipe( 124 | tap(cleanup), 125 | flatMap(() => throwError(new Error('Terminate timeout'))), 126 | ), 127 | ).toPromise() 128 | } 129 | -------------------------------------------------------------------------------- /src/ssr/ssr-context.tsx: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import { createContext } from 'react' 3 | 4 | export const SSRContext = createContext(null) 5 | -------------------------------------------------------------------------------- /src/ssr/ssr-module.ts: -------------------------------------------------------------------------------- 1 | import { InjectableConfig, Injectable } from '@asuka/di' 2 | import omit from 'lodash/omit' 3 | 4 | const configSets = new Set() 5 | 6 | export const moduleNameKey = Symbol.for('__MODULE__NAME__') 7 | export const globalKey = Symbol.for('__GLOBAL_MODULE_CACHE__') 8 | 9 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 10 | export const SSRModule = (config: string | (InjectableConfig & { name: string })) => { 11 | const injectableConfig: InjectableConfig = { providers: [] } 12 | let name: string 13 | if (typeof config === 'string') { 14 | if (configSets.has(config)) { 15 | reportDuplicated(config) 16 | } 17 | name = config 18 | configSets.add(config) 19 | } else if (config && typeof config.name === 'string') { 20 | if (configSets.has(config.name)) { 21 | reportDuplicated(config.name) 22 | } 23 | configSets.add(config.name) 24 | name = config.name 25 | Object.assign(injectableConfig, omit(config, 'name')) 26 | } else { 27 | throw new TypeError( 28 | 'config in SSRModule type error, support string or InjectableConfig with name', 29 | ) 30 | } 31 | 32 | return (target: any) => { 33 | target.prototype[moduleNameKey] = name 34 | return Injectable(injectableConfig)(target) 35 | } 36 | } 37 | 38 | function reportDuplicated(moduleName: string) { 39 | if (process.env.NODE_ENV === 'production') { 40 | throw new Error(`Duplicated Module name: ${moduleName}`) 41 | } 42 | // avoid to throw error after HMR 43 | console.warn(`Duplicated Module name: ${moduleName}`) 44 | } 45 | -------------------------------------------------------------------------------- /src/ssr/terminate.ts: -------------------------------------------------------------------------------- 1 | import { EffectAction } from '../core/types' 2 | 3 | export const TERMINATE_ACTION: EffectAction = { 4 | actionName: Symbol('terminate'), 5 | params: null, 6 | } as any 7 | -------------------------------------------------------------------------------- /src/test-helper/index.ts: -------------------------------------------------------------------------------- 1 | import { Ayanami, combineWithIkari, ActionMethodOfAyanami } from '../core' 2 | 3 | export function getAllActionsForTest>( 4 | ayanami: A, 5 | ): A extends Ayanami ? ActionMethodOfAyanami : never { 6 | return combineWithIkari(ayanami).triggerActions as any 7 | } 8 | -------------------------------------------------------------------------------- /test/specs/__snapshots__/ssr.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SSR specs: should return right state in hooks 1`] = `"1"`; 4 | 5 | exports[`SSR specs: should run ssr effects 1`] = ` 6 | Object { 7 | "CountModel": Object { 8 | "__$$AYANAMI_DEFAULT__SCOPE$$__": Object { 9 | "count": 1, 10 | "name": "name", 11 | }, 12 | }, 13 | } 14 | `; 15 | 16 | exports[`SSR specs: should skip effect if it returns SKIP_SYMBOL 1`] = ` 17 | Object { 18 | "CountModel": Object { 19 | "__$$AYANAMI_DEFAULT__SCOPE$$__": Object { 20 | "count": 1, 21 | "name": "", 22 | }, 23 | }, 24 | } 25 | `; 26 | 27 | exports[`SSR specs: should support concurrency 1`] = ` 28 | Object { 29 | "firstRequest": Object { 30 | "CountModel": Object { 31 | "scope1": Object { 32 | "count": 1, 33 | "name": "name1", 34 | }, 35 | }, 36 | "TipModel": Object { 37 | "scope1": Object { 38 | "tip": "tip", 39 | }, 40 | }, 41 | }, 42 | "secondRequest": Object { 43 | "CountModel": Object { 44 | "scope1": Object { 45 | "count": 1, 46 | "name": "name2", 47 | }, 48 | }, 49 | "TipModel": Object { 50 | "scope2": Object { 51 | "tip": "tip", 52 | }, 53 | }, 54 | }, 55 | } 56 | `; 57 | 58 | exports[`SSR specs: should work with scope 1`] = ` 59 | Object { 60 | "CountModel": Object { 61 | "__$$AYANAMI_DEFAULT__SCOPE$$__": Object { 62 | "count": 1, 63 | "name": "", 64 | }, 65 | "scope": Object { 66 | "count": 1, 67 | "name": "", 68 | }, 69 | }, 70 | } 71 | `; 72 | -------------------------------------------------------------------------------- /test/specs/ayanami.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Test } from '@asuka/di' 2 | import { Ayanami, Reducer, getAllActionsForTest, ActionMethodOfAyanami } from '../../src' 3 | 4 | interface CountState { 5 | count: number 6 | } 7 | 8 | @Injectable() 9 | class CountModel extends Ayanami { 10 | defaultState = { count: 0 } 11 | 12 | @Reducer() 13 | setCount(state: CountState, count: number): CountState { 14 | return { ...state, count } 15 | } 16 | } 17 | 18 | describe('Ayanami specs:', () => { 19 | let countModel: CountModel 20 | let actions: ActionMethodOfAyanami 21 | 22 | beforeEach(() => { 23 | const testModule = Test.createTestingModule().compile() 24 | countModel = testModule.getInstance(CountModel) 25 | actions = getAllActionsForTest(countModel) 26 | }) 27 | 28 | it('getState', () => { 29 | expect(countModel.getState()).toEqual({ count: 0 }) 30 | actions.setCount(10) 31 | expect(countModel.getState()).toEqual({ count: 10 }) 32 | }) 33 | 34 | it('getState$', () => { 35 | const count$ = countModel.getState$() 36 | 37 | const callback = jest.fn() 38 | 39 | count$.subscribe(callback) 40 | 41 | actions.setCount(44) 42 | 43 | expect(callback.mock.calls.length).toBe(2) 44 | expect(callback.mock.calls[0][0]).toEqual({ count: 0 }) 45 | expect(callback.mock.calls[1][0]).toEqual({ count: 44 }) 46 | }) 47 | 48 | it('destroy', () => { 49 | countModel.destroy() 50 | actions.setCount(10) 51 | expect(countModel.getState()).toEqual({ count: 0 }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/specs/connect.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Injectable } from '@asuka/di' 3 | import { act, create } from 'react-test-renderer' 4 | import { Observable } from 'rxjs' 5 | import { map, withLatestFrom } from 'rxjs/operators' 6 | 7 | import { 8 | Ayanami, 9 | Effect, 10 | EffectAction, 11 | Reducer, 12 | ActionMethodOfAyanami, 13 | connectAyanami, 14 | } from '../../src' 15 | 16 | interface State { 17 | count: number 18 | } 19 | 20 | enum CountAction { 21 | ADD = 'add', 22 | MINUS = 'minus', 23 | } 24 | 25 | @Injectable() 26 | class Count extends Ayanami { 27 | defaultState = { 28 | count: 0, 29 | } 30 | 31 | @Reducer() 32 | add(state: State, count: number): State { 33 | return { ...state, count: state.count + count } 34 | } 35 | 36 | @Reducer() 37 | setCount(state: State, count: number): State { 38 | return { ...state, count } 39 | } 40 | 41 | @Effect() 42 | minus(count$: Observable, state$: Observable): Observable { 43 | return count$.pipe( 44 | withLatestFrom(state$), 45 | map(([subCount, state]) => this.getActions().setCount(state.count - subCount)), 46 | ) 47 | } 48 | } 49 | 50 | type CountComponentProps = State & Pick, 'add' | 'minus'> 51 | 52 | class CountComponent extends React.Component { 53 | render() { 54 | return ( 55 |

66 | ) 67 | } 68 | 69 | private add = (count: number) => () => this.props.add(count) 70 | 71 | private minus = (count: number) => () => this.props.minus(count) 72 | } 73 | 74 | const ConnectedCountComponent = connectAyanami(Count)( 75 | ({ count }) => ({ count }), 76 | ({ add, minus }) => ({ add, minus }), 77 | )(CountComponent) 78 | 79 | describe('Connect spec:', () => { 80 | const testRenderer = create() 81 | const count = () => testRenderer.root.findByType('span').children[0] 82 | const click = (action: CountAction) => 83 | act(() => testRenderer.root.findByProps({ id: action }).props.onClick()) 84 | 85 | // https://github.com/facebook/react/issues/14050 to trigger useEffect manually 86 | testRenderer.update() 87 | 88 | it('default state work properly', () => { 89 | expect(count()).toBe('0') 90 | }) 91 | 92 | it('Reducer action work properly', () => { 93 | click(CountAction.ADD) 94 | expect(count()).toBe('1') 95 | }) 96 | 97 | it('Effect action work properly', () => { 98 | click(CountAction.MINUS) 99 | expect(count()).toBe('0') 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/specs/define-action.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Test } from '@asuka/di' 2 | import { Observable } from 'rxjs' 3 | import { map } from 'rxjs/operators' 4 | 5 | import { 6 | Ayanami, 7 | Effect, 8 | EffectAction, 9 | Reducer, 10 | DefineAction, 11 | getAllActionsForTest, 12 | } from '../../src' 13 | 14 | interface CountState { 15 | count: number 16 | } 17 | 18 | @Injectable() 19 | class Count extends Ayanami { 20 | defaultState = { 21 | count: 0, 22 | } 23 | 24 | @DefineAction() 25 | resetCountDown$!: Observable 26 | 27 | @Reducer() 28 | setCount(state: CountState, count: number): CountState { 29 | return { ...state, count } 30 | } 31 | 32 | @Effect() 33 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 34 | _(_: Observable): Observable { 35 | return this.resetCountDown$.pipe(map((count) => this.getActions().setCount(count))) 36 | } 37 | } 38 | 39 | describe('DefineAction spec:', () => { 40 | const testModule = Test.createTestingModule().compile() 41 | const count = testModule.getInstance(Count) 42 | const countActions = getAllActionsForTest(count) 43 | 44 | const getCount = () => count.getState().count 45 | 46 | it('should setup properly', () => { 47 | expect(count.resetCountDown$).toBeInstanceOf(Observable) 48 | }) 49 | 50 | it('should trigger action properly', () => { 51 | countActions.resetCountDown$(22) 52 | expect(getCount()).toBe(22) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /test/specs/effect.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Test } from '@asuka/di' 2 | import { Observable, of } from 'rxjs' 3 | import { map, mergeMap, withLatestFrom } from 'rxjs/operators' 4 | 5 | import { Ayanami, Effect, EffectAction, Reducer, getAllActionsForTest } from '../../src' 6 | 7 | interface TipsState { 8 | tips: string 9 | } 10 | 11 | @Injectable() 12 | class Tips extends Ayanami { 13 | defaultState = { 14 | tips: '', 15 | } 16 | 17 | @Reducer() 18 | showTipsWithReducer(state: TipsState, tips: string): TipsState { 19 | return { ...state, tips } 20 | } 21 | 22 | @Effect() 23 | showTipsWithEffectAction(tips$: Observable): Observable { 24 | return tips$.pipe(map((tips) => this.getActions().showTipsWithReducer(tips))) 25 | } 26 | } 27 | 28 | interface CountState { 29 | count: number 30 | } 31 | 32 | @Injectable() 33 | class Count extends Ayanami { 34 | defaultState = { 35 | count: 0, 36 | } 37 | 38 | // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility 39 | constructor(readonly tips: Tips) { 40 | super() 41 | } 42 | 43 | @Reducer() 44 | setCount(state: CountState, count: number): CountState { 45 | return { ...state, count } 46 | } 47 | 48 | @Effect() 49 | add(count$: Observable, state$: Observable): Observable { 50 | return count$.pipe( 51 | withLatestFrom(state$), 52 | mergeMap(([addCount, state]) => 53 | of( 54 | this.getActions().setCount(state.count + addCount), 55 | this.tips.getActions().showTipsWithReducer(`add ${addCount}`), 56 | ), 57 | ), 58 | ) 59 | } 60 | 61 | @Effect() 62 | minus(count$: Observable, state$: Observable): Observable { 63 | return count$.pipe( 64 | withLatestFrom(state$), 65 | mergeMap(([subCount, state]) => 66 | of( 67 | this.getActions().setCount(state.count - subCount), 68 | this.tips.getActions().showTipsWithEffectAction(`minus ${subCount}`), 69 | ), 70 | ), 71 | ) 72 | } 73 | 74 | @Effect() 75 | error(payload$: Observable) { 76 | return payload$.pipe( 77 | map(() => { 78 | throw new Error('error!') 79 | }), 80 | ) 81 | } 82 | } 83 | 84 | describe('Effect spec:', () => { 85 | const testModule = Test.createTestingModule().compile() 86 | const count = testModule.getInstance(Count) 87 | const tips = count.tips 88 | const countActions = getAllActionsForTest(count) 89 | 90 | const getCount = () => count.getState().count 91 | const getTips = () => tips.getState().tips 92 | 93 | describe('Emitted EffectAction will trigger corresponding Action', () => { 94 | it('Reducer Action', () => { 95 | countActions.add(1) 96 | expect(getCount()).toBe(1) 97 | expect(getTips()).toBe('add 1') 98 | }) 99 | 100 | it('Effect Action', () => { 101 | countActions.minus(1) 102 | expect(getCount()).toBe(0) 103 | expect(getTips()).toBe('minus 1') 104 | }) 105 | }) 106 | 107 | describe('Error handles', () => { 108 | it(`Error won't affect the main state$`, () => { 109 | const errorLog = jest.spyOn(console, 'error').mockImplementation(() => void 0) 110 | countActions.error() 111 | expect(errorLog.mock.calls.length).toBe(1) 112 | errorLog.mockRestore() 113 | 114 | countActions.add(1) 115 | countActions.minus(2) 116 | countActions.minus(3) 117 | expect(getCount()).toBe(-4) 118 | }) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /test/specs/hooks.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Injectable, ValueProvider, Inject } from '@asuka/di' 2 | import * as React from 'react' 3 | import { act, create, ReactTestInstance, ReactTestRenderer } from 'react-test-renderer' 4 | import { Observable } from 'rxjs' 5 | import { map, withLatestFrom } from 'rxjs/operators' 6 | 7 | import { Ayanami, Effect, EffectAction, Reducer, useAyanami, TransientScope } from '../../src' 8 | import { useCallback, useEffect } from 'react' 9 | 10 | interface State { 11 | count: number 12 | anotherCount: number 13 | } 14 | 15 | enum CountAction { 16 | ADD = 'add', 17 | MINUS = 'minus', 18 | } 19 | 20 | const numberProvider: ValueProvider = { 21 | provide: 'token', 22 | useValue: 0, 23 | } 24 | 25 | @Injectable({ 26 | providers: [numberProvider], 27 | }) 28 | class Count extends Ayanami { 29 | defaultState = { 30 | count: -1, 31 | anotherCount: 0, 32 | } 33 | 34 | constructor(@Inject(numberProvider.provide) number: number) { 35 | super() 36 | this.defaultState.count = number 37 | } 38 | 39 | @Reducer() 40 | add(state: State, count: number): State { 41 | return { ...state, count: state.count + count } 42 | } 43 | 44 | @Reducer() 45 | setCount(state: State, count: number): State { 46 | return { ...state, count } 47 | } 48 | 49 | @Effect() 50 | minus(count$: Observable, state$: Observable): Observable { 51 | return count$.pipe( 52 | withLatestFrom(state$), 53 | map(([subCount, state]) => this.getActions().setCount(state.count - subCount)), 54 | ) 55 | } 56 | } 57 | 58 | function CountComponent({ scope }: { scope?: any }) { 59 | const [state, actions] = useAyanami(Count, { scope }) 60 | 61 | const add = (count: number) => () => actions.add(count) 62 | const minus = (count: number) => () => actions.minus(count) 63 | 64 | return ( 65 |
66 |

67 | current count is {state.count} 68 |

69 | 72 | 75 |
76 | ) 77 | } 78 | 79 | describe('Hooks spec:', () => { 80 | describe('Default behavior', () => { 81 | const testRenderer = create() 82 | const count = () => testRenderer.root.findByType('span').children[0] 83 | const click = (action: CountAction) => 84 | act(() => testRenderer.root.findByProps({ id: action }).props.onClick()) 85 | 86 | // https://github.com/facebook/react/issues/14050 to trigger useEffect manually 87 | testRenderer.update() 88 | 89 | it('default state work properly', () => { 90 | expect(count()).toBe('0') 91 | }) 92 | 93 | it('Reducer action work properly', () => { 94 | click(CountAction.ADD) 95 | expect(count()).toBe('1') 96 | }) 97 | 98 | it('Effect action work properly', () => { 99 | click(CountAction.MINUS) 100 | expect(count()).toBe('0') 101 | }) 102 | 103 | it('State selector work properly', () => { 104 | const innerRenderSpy = jest.fn() 105 | const outerRenderSpy = jest.fn() 106 | 107 | const InnerComponent = React.memo(({ scope }: { scope?: any }) => { 108 | const [anotherCount] = useAyanami(Count, { selector: (state) => state.anotherCount, scope }) 109 | innerRenderSpy(anotherCount) 110 | return
111 | }) 112 | 113 | const OuterComponent = () => { 114 | const [{ count }, actions] = useAyanami(Count, { scope: TransientScope }) 115 | const addOne = useCallback(() => actions.add(1), []) 116 | outerRenderSpy(count) 117 | return ( 118 |
119 | 120 | 121 |
122 | ) 123 | } 124 | const renderer = create() 125 | act(() => renderer.root.findByType('button').props.onClick()) 126 | expect(innerRenderSpy.mock.calls).toEqual([[0]]) 127 | expect(outerRenderSpy.mock.calls).toEqual([[0], [1]]) 128 | }) 129 | 130 | it('should only render once when update the state right during rendering', () => { 131 | const spy = jest.fn() 132 | const TestComponent = () => { 133 | const [state, actions] = useAyanami(Count, { scope: TransientScope }) 134 | const addOne = useCallback(() => actions.add(1), []) 135 | 136 | if (state.count % 2 === 0) { 137 | actions.add(1) 138 | } 139 | 140 | useEffect(() => { 141 | spy(state.count) 142 | }, [state.count]) 143 | 144 | return ( 145 |
146 |

count: {state.count}

147 | 148 |
149 | ) 150 | } 151 | 152 | const renderer = create() 153 | 154 | // https://github.com/facebook/react/issues/14050 to trigger useEffect manually 155 | renderer.update() 156 | expect(spy.mock.calls).toEqual([[1]]) 157 | 158 | act(() => renderer.root.findByType('button').props.onClick()) 159 | expect(spy.mock.calls).toEqual([[1], [3]]) 160 | }) 161 | }) 162 | 163 | describe('Scope behavior', () => { 164 | describe('Same scope will share state and actions', () => { 165 | const scope = Symbol('scope') 166 | let count: () => string | ReactTestInstance 167 | let click: (action: CountAction) => void 168 | 169 | beforeEach(() => { 170 | const testRenderer = create() 171 | 172 | count = () => testRenderer.root.findByType('span').children[0] 173 | click = (action: CountAction) => 174 | act(() => testRenderer.root.findByProps({ id: action }).props.onClick()) 175 | 176 | // https://github.com/facebook/react/issues/14050 to trigger useEffect manually 177 | testRenderer.update() 178 | }) 179 | 180 | it('default state work properly', () => { 181 | expect(count()).toBe('0') 182 | }) 183 | 184 | it('Reducer action work properly', () => { 185 | click(CountAction.ADD) 186 | expect(count()).toBe('1') 187 | }) 188 | 189 | it('Effect action work properly', () => { 190 | click(CountAction.MINUS) 191 | expect(count()).toBe('0') 192 | }) 193 | }) 194 | 195 | describe('Different scope will isolate state and actions', () => { 196 | let count: () => string | ReactTestInstance 197 | let click: (action: CountAction) => void 198 | 199 | beforeEach(() => { 200 | const scope = Symbol('scope') 201 | const testRenderer = create() 202 | 203 | count = () => testRenderer.root.findByType('span').children[0] 204 | click = (action: CountAction) => 205 | act(() => testRenderer.root.findByProps({ id: action }).props.onClick()) 206 | 207 | // https://github.com/facebook/react/issues/14050 to trigger useEffect manually 208 | testRenderer.update() 209 | }) 210 | 211 | it('Reducer action work properly', () => { 212 | click(CountAction.ADD) 213 | expect(count()).toBe('1') 214 | }) 215 | 216 | it('default state work properly', () => { 217 | expect(count()).toBe('0') 218 | }) 219 | 220 | it('Effect action work properly', () => { 221 | click(CountAction.MINUS) 222 | expect(count()).toBe('-1') 223 | }) 224 | }) 225 | 226 | describe('TransientScope will isolate state and actions', () => { 227 | let count: () => string | ReactTestInstance 228 | let click: (action: CountAction) => void 229 | let testRenderer: ReactTestRenderer 230 | 231 | beforeEach(() => { 232 | testRenderer = create() 233 | 234 | count = () => testRenderer.root.findByType('span').children[0] 235 | click = (action: CountAction) => 236 | act(() => testRenderer.root.findByProps({ id: action }).props.onClick()) 237 | 238 | // https://github.com/facebook/react/issues/14050 to trigger useEffect manually 239 | testRenderer.update() 240 | }) 241 | 242 | it('Reducer action work properly', () => { 243 | click(CountAction.ADD) 244 | expect(count()).toBe('1') 245 | }) 246 | 247 | it('default state work properly', () => { 248 | expect(count()).toBe('0') 249 | }) 250 | 251 | it('Effect action work properly', () => { 252 | click(CountAction.MINUS) 253 | expect(count()).toBe('-1') 254 | }) 255 | 256 | it('should destroy when component unmount', () => { 257 | const spy = jest.spyOn(Ayanami.prototype, 'destroy') 258 | act(() => testRenderer.unmount()) 259 | expect(spy.mock.calls.length).toBe(1) 260 | }) 261 | }) 262 | 263 | describe('Dynamic update scope', () => { 264 | const testRenderer = create() 265 | const count = () => testRenderer.root.findByType('span').children[0] 266 | const click = (action: CountAction) => 267 | act(() => testRenderer.root.findByProps({ id: action }).props.onClick()) 268 | 269 | it(`should use same Ayanami at each update if scope didn't change`, () => { 270 | testRenderer.update() 271 | click(CountAction.ADD) 272 | expect(count()).toBe('1') 273 | }) 274 | 275 | it(`should use new scope's Ayanami if scope changed`, () => { 276 | testRenderer.update() 277 | click(CountAction.MINUS) 278 | expect(count()).toBe('-1') 279 | }) 280 | 281 | it(`should update state to corresponding one`, () => { 282 | testRenderer.update() 283 | expect(count()).toBe('1') 284 | testRenderer.update() 285 | expect(count()).toBe('-1') 286 | testRenderer.update() 287 | expect(count()).toBe('0') 288 | }) 289 | }) 290 | }) 291 | }) 292 | -------------------------------------------------------------------------------- /test/specs/ikari.spec.ts: -------------------------------------------------------------------------------- 1 | import { Subject, NEVER } from 'rxjs' 2 | import { Draft } from 'immer' 3 | 4 | import '../../src' 5 | import { Ikari } from '../../src/core' 6 | 7 | interface State { 8 | count: number 9 | } 10 | 11 | const getDefineAction = () => { 12 | const action$ = new Subject() 13 | 14 | return { 15 | next: (params: any) => action$.next(params), 16 | observable: action$.asObservable(), 17 | } 18 | } 19 | 20 | const createIkariConfig = () => ({ 21 | nameForLog: 'abc', 22 | defaultState: { count: 0 }, 23 | effects: { never: () => NEVER }, 24 | reducers: { 25 | setCount: (state: State, count: number): State => ({ ...state, count }), 26 | }, 27 | immerReducers: { 28 | immerSetCount: (state: Draft, count: number) => { 29 | state.count = count 30 | }, 31 | }, 32 | defineActions: { hmm: getDefineAction() }, 33 | effectActionFactories: {}, 34 | }) 35 | 36 | const createIkari = () => new Ikari(Object.create(null), createIkariConfig()) 37 | 38 | describe('Ikari spec:', () => { 39 | describe('static', () => { 40 | describe('createAndBindAt', () => { 41 | it('only create once if call multiple times', () => { 42 | const target = { defaultState: { count: 0 } } 43 | 44 | const ikari = Ikari.createAndBindAt(target as any, createIkariConfig()) 45 | expect(ikari).toBe(Ikari.createAndBindAt(target as any, createIkariConfig())) 46 | }) 47 | }) 48 | }) 49 | 50 | describe('instance', () => { 51 | const ikari = createIkari() 52 | 53 | it('state is setup properly', () => { 54 | expect(ikari.state.getState()).toEqual({ count: 0 }) 55 | }) 56 | 57 | it('triggerActions is combination of effects, reducers, immerReducers and defineActions', () => { 58 | expect(Object.keys(ikari.triggerActions).length).toBe(4) 59 | expect(typeof ikari.triggerActions.never).toBe('function') 60 | expect(typeof ikari.triggerActions.setCount).toBe('function') 61 | expect(typeof ikari.triggerActions.immerSetCount).toBe('function') 62 | expect(typeof ikari.triggerActions.hmm).toBe('function') 63 | }) 64 | 65 | it('reducers can change state', () => { 66 | ikari.triggerActions.setCount(1) 67 | expect(ikari.state.getState()).toEqual({ count: 1 }) 68 | }) 69 | 70 | it('ImmerReducers can change state', () => { 71 | ikari.triggerActions.immerSetCount(2) 72 | expect(ikari.state.getState()).toEqual({ count: 2 }) 73 | }) 74 | }) 75 | 76 | describe('after destroy', () => { 77 | const ikari = createIkari() 78 | 79 | expect(ikari.subscription.closed).toBe(false) 80 | 81 | beforeEach(() => { 82 | ikari.destroy() 83 | }) 84 | 85 | it('should remove all subscription', () => { 86 | expect(ikari.subscription.closed).toBe(true) 87 | }) 88 | 89 | it('should remove all triggerActions', () => { 90 | expect(ikari.triggerActions).toEqual({}) 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /test/specs/immer-reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Test } from '@asuka/di' 2 | import { Draft } from 'immer' 3 | 4 | import { Ayanami, ImmerReducer, getAllActionsForTest, ActionMethodOfAyanami } from '../../src' 5 | 6 | interface TipsState { 7 | tips: string 8 | } 9 | 10 | @Injectable() 11 | class Tips extends Ayanami { 12 | defaultState = { 13 | tips: '', 14 | } 15 | 16 | @ImmerReducer() 17 | removeTips(state: Draft) { 18 | state.tips = '' 19 | } 20 | 21 | @ImmerReducer() 22 | setTips(state: Draft, tips: string) { 23 | state.tips = tips 24 | } 25 | 26 | @ImmerReducer() 27 | addTips(state: Draft, tips: string) { 28 | state.tips = `${state.tips} ${tips}` 29 | } 30 | } 31 | 32 | describe('ImmerReducer spec:', () => { 33 | let tips: Tips 34 | let actions: ActionMethodOfAyanami 35 | 36 | beforeEach(() => { 37 | const testModule = Test.createTestingModule().compile() 38 | 39 | tips = testModule.getInstance(Tips) 40 | actions = getAllActionsForTest(tips) 41 | }) 42 | 43 | it('with payload', () => { 44 | actions.setTips('one') 45 | expect(tips.getState()).toEqual({ tips: 'one' }) 46 | }) 47 | 48 | it('with payload and state', () => { 49 | actions.setTips('two') 50 | actions.addTips('three') 51 | expect(tips.getState()).toEqual({ tips: 'two three' }) 52 | }) 53 | 54 | it('without payload and state', () => { 55 | actions.setTips('one') 56 | actions.removeTips() 57 | expect(tips.getState()).toEqual({ tips: '' }) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /test/specs/reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Test } from '@asuka/di' 2 | 3 | import { Ayanami, Reducer, getAllActionsForTest, ActionMethodOfAyanami } from '../../src' 4 | 5 | interface TipsState { 6 | tips: string 7 | } 8 | 9 | @Injectable() 10 | class Tips extends Ayanami { 11 | defaultState = { 12 | tips: '', 13 | } 14 | 15 | @Reducer() 16 | removeTips(): TipsState { 17 | return { tips: '' } 18 | } 19 | 20 | @Reducer() 21 | setTips(state: TipsState, tips: string): TipsState { 22 | return { ...state, tips } 23 | } 24 | 25 | @Reducer() 26 | addTips(state: TipsState, tips: string): TipsState { 27 | return { ...state, tips: `${state.tips} ${tips}` } 28 | } 29 | } 30 | 31 | describe('Reducer spec:', () => { 32 | let tips: Tips 33 | let actions: ActionMethodOfAyanami 34 | 35 | beforeEach(() => { 36 | const testModule = Test.createTestingModule().compile() 37 | 38 | tips = testModule.getInstance(Tips) 39 | actions = getAllActionsForTest(tips) 40 | }) 41 | 42 | it('with payload', () => { 43 | actions.setTips('one') 44 | expect(tips.getState()).toEqual({ tips: 'one' }) 45 | }) 46 | 47 | it('with payload and state', () => { 48 | actions.setTips('two') 49 | actions.addTips('three') 50 | expect(tips.getState()).toEqual({ tips: 'two three' }) 51 | }) 52 | 53 | it('without payload and state', () => { 54 | actions.setTips('one') 55 | actions.removeTips() 56 | expect(tips.getState()).toEqual({ tips: '' }) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /test/specs/ssr.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Observable, timer } from 'rxjs' 3 | import { endWith, switchMap, map, mergeMap } from 'rxjs/operators' 4 | import { Draft } from 'immer' 5 | import { renderToString } from 'react-dom/server' 6 | import { create } from 'react-test-renderer' 7 | 8 | import { 9 | SSRModule, 10 | Ayanami, 11 | SSREffect, 12 | EffectAction, 13 | emitSSREffects, 14 | TERMINATE_ACTION, 15 | ImmerReducer, 16 | useAyanami, 17 | SSRContext, 18 | globalKey, 19 | reqMap, 20 | } from '../../src' 21 | import { DEFAULT_SCOPE_NAME } from '../../src/ssr/constants' 22 | 23 | interface CountState { 24 | count: number 25 | name: string 26 | } 27 | 28 | @SSRModule('CountModel') 29 | class CountModel extends Ayanami { 30 | defaultState = { count: 0, name: '' } 31 | 32 | @ImmerReducer() 33 | setCount(state: Draft, count: number) { 34 | state.count = count 35 | } 36 | 37 | @ImmerReducer() 38 | setName(state: Draft, name: string) { 39 | state.name = name 40 | } 41 | 42 | @SSREffect() 43 | getCount(payload$: Observable): Observable { 44 | return payload$.pipe( 45 | switchMap(() => 46 | timer(1).pipe( 47 | map(() => this.getActions().setCount(1)), 48 | endWith(TERMINATE_ACTION), 49 | ), 50 | ), 51 | ) 52 | } 53 | 54 | @SSREffect({ 55 | payloadGetter: (req, skip) => req.url || skip(), 56 | skipFirstClientDispatch: false, 57 | }) 58 | skippedEffect(payload$: Observable): Observable { 59 | return payload$.pipe( 60 | switchMap((name) => 61 | timer(1).pipe( 62 | map(() => this.getActions().setName(name)), 63 | endWith(TERMINATE_ACTION), 64 | ), 65 | ), 66 | ) 67 | } 68 | } 69 | 70 | interface TipState { 71 | tip: string 72 | } 73 | 74 | @SSRModule('TipModel') 75 | class TipModel extends Ayanami { 76 | defaultState = { tip: '' } 77 | 78 | @ImmerReducer() 79 | setTip(state: Draft, tip: string) { 80 | state.tip = tip 81 | } 82 | 83 | @SSREffect() 84 | getTip(payload$: Observable): Observable { 85 | return payload$.pipe( 86 | mergeMap(() => 87 | timer(1).pipe( 88 | map(() => this.getActions().setTip('tip')), 89 | endWith(TERMINATE_ACTION), 90 | ), 91 | ), 92 | ) 93 | } 94 | } 95 | 96 | const Component = () => { 97 | const [state, actions] = useAyanami(CountModel) 98 | 99 | React.useEffect(() => { 100 | actions.setName('new name') 101 | }, []) 102 | 103 | return {state.count} 104 | } 105 | 106 | describe('SSR specs:', () => { 107 | beforeAll(() => { 108 | // @ts-ignore 109 | process.env.ENABLE_AYANAMI_SSR = 'true' 110 | process.env.NODE_ENV = 'production' 111 | }) 112 | 113 | afterAll(() => { 114 | // @ts-ignore 115 | process.env.ENABLE_AYANAMI_SSR = 'false' 116 | delete process.env.NODE_ENV 117 | }) 118 | 119 | it('should throw if module name not given', () => { 120 | function generateException() { 121 | // @ts-ignore 122 | @SSRModule() 123 | class ErrorModel extends Ayanami { 124 | defaultState = {} 125 | } 126 | 127 | return ErrorModel 128 | } 129 | 130 | expect(generateException).toThrow() 131 | }) 132 | 133 | it('should pass valid module name', () => { 134 | @SSRModule('1') 135 | class Model extends Ayanami { 136 | defaultState = {} 137 | } 138 | 139 | @SSRModule({ name: '2', providers: [] }) 140 | class Model2 extends Ayanami { 141 | defaultState = {} 142 | } 143 | 144 | function generateException1() { 145 | @SSRModule('1') 146 | class ErrorModel1 extends Ayanami { 147 | defaultState = {} 148 | } 149 | 150 | return ErrorModel1 151 | } 152 | 153 | function generateException2() { 154 | @SSRModule({ name: '1', providers: [] }) 155 | class ErrorModel2 extends Ayanami { 156 | defaultState = {} 157 | } 158 | 159 | return { ErrorModel2 } 160 | } 161 | 162 | function generateException3() { 163 | // @ts-ignore 164 | @SSRModule() 165 | class ErrorModel extends Ayanami { 166 | defaultState = {} 167 | } 168 | 169 | return ErrorModel 170 | } 171 | 172 | expect(Model).not.toBe(undefined) 173 | expect(Model2).not.toBe(undefined) 174 | expect(generateException1).toThrow() 175 | expect(generateException2).toThrow() 176 | expect(generateException3).toThrow() 177 | process.env.NODE_ENV = 'development' 178 | expect(generateException1).not.toThrow() 179 | expect(generateException2).not.toThrow() 180 | }) 181 | 182 | it('should run ssr effects', async () => { 183 | // @ts-ignore 184 | const { state, cleanup } = await emitSSREffects({ url: 'name' }, [CountModel]) 185 | const moduleState = state['CountModel'][DEFAULT_SCOPE_NAME] 186 | 187 | expect(moduleState).not.toBe(undefined) 188 | expect(moduleState.count).toBe(1) 189 | expect(moduleState.name).toBe('name') 190 | expect(state).toMatchSnapshot() 191 | cleanup() 192 | }) 193 | 194 | it('should skip effect if it returns SKIP_SYMBOL', async () => { 195 | // @ts-ignore 196 | const { state, cleanup } = await emitSSREffects({}, [CountModel]) 197 | const moduleState = state['CountModel'][DEFAULT_SCOPE_NAME] 198 | 199 | expect(moduleState.name).toBe('') 200 | expect(state).toMatchSnapshot() 201 | cleanup() 202 | }) 203 | 204 | it('should return right state in hooks', async () => { 205 | const req = {} 206 | // @ts-ignore 207 | const { cleanup } = await emitSSREffects(req, [CountModel]) 208 | const html = renderToString( 209 | // @ts-ignore 210 | 211 | 212 | , 213 | ) 214 | expect(html).toContain('1') 215 | expect(html).toMatchSnapshot() 216 | cleanup() 217 | }) 218 | 219 | it('should work with scope', async () => { 220 | // @ts-ignore 221 | const { state, cleanup } = await emitSSREffects({}, [ 222 | { module: CountModel, scope: 'scope' }, 223 | CountModel, 224 | ]) 225 | const moduleState = state['CountModel'][DEFAULT_SCOPE_NAME] 226 | const scopedModuleState = state['CountModel']['scope'] 227 | 228 | expect(scopedModuleState).not.toBe(undefined) 229 | expect(scopedModuleState).toEqual(moduleState) 230 | expect(scopedModuleState).not.toBe(moduleState) 231 | expect(state).toMatchSnapshot() 232 | cleanup() 233 | }) 234 | 235 | it('should restore state from global', () => { 236 | process.env.ENABLE_AYANAMI_SSR = 'false' 237 | // @ts-ignore 238 | global[globalKey] = { 239 | CountModel: { 240 | [DEFAULT_SCOPE_NAME]: { 241 | count: 1, 242 | name: '', 243 | }, 244 | }, 245 | } 246 | const testRenderer = create() 247 | expect(testRenderer.root.findByType('span').children[0]).toBe('1') 248 | process.env.ENABLE_AYANAMI_SSR = 'true' 249 | }) 250 | 251 | it('should timeout and clean', async () => { 252 | try { 253 | // @ts-ignore 254 | await emitSSREffects({}, [CountModel], 0) 255 | } catch (e) { 256 | expect(e.message).toContain('Terminate timeout') 257 | } 258 | 259 | expect(reqMap.size).toBe(0) 260 | }) 261 | 262 | it('should support concurrency', async () => { 263 | return Promise.all([ 264 | // @ts-ignore 265 | emitSSREffects({ url: 'name1' }, [ 266 | { module: CountModel, scope: 'scope1' }, 267 | { module: TipModel, scope: 'scope1' }, 268 | ]), 269 | // @ts-ignore 270 | emitSSREffects({ url: 'name2' }, [ 271 | { module: CountModel, scope: 'scope1' }, 272 | { module: TipModel, scope: 'scope2' }, 273 | ]), 274 | ]).then(([result1, result2]) => { 275 | expect(result1.state['CountModel']['scope1'].name).toBe('name1') 276 | expect(result2.state['CountModel']['scope1'].name).toBe('name2') 277 | expect({ firstRequest: result1.state, secondRequest: result2.state }).toMatchSnapshot() 278 | result1.cleanup() 279 | result2.cleanup() 280 | }) 281 | }) 282 | }) 283 | -------------------------------------------------------------------------------- /test/specs/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { createState, State } from '../../src/core' 2 | 3 | describe('utils specs:', () => { 4 | describe('BasicState', () => { 5 | let state: State 6 | 7 | beforeEach(() => { 8 | state = createState({ count: 0 }) 9 | }) 10 | 11 | it('getState should return current state', () => { 12 | expect(state.getState()).toEqual({ count: 0 }) 13 | }) 14 | 15 | it('setState should update current state', () => { 16 | state.setState({ count: 20 }) 17 | expect(state.getState()).toEqual({ count: 20 }) 18 | }) 19 | 20 | describe('state$', () => { 21 | it('should push current state when subscribe', () => { 22 | const spy = jest.fn() 23 | state.state$.subscribe(spy) 24 | expect(spy.mock.calls.length).toBe(1) 25 | expect(spy.mock.calls[0][0]).toEqual({ count: 0 }) 26 | }) 27 | 28 | it('should push state when state changed', () => { 29 | const spy = jest.fn() 30 | state.state$.subscribe(spy) 31 | state.setState({ count: 10 }) 32 | expect(spy.mock.calls.length).toBe(2) 33 | expect(spy.mock.calls[1][0]).toEqual({ count: 10 }) 34 | }) 35 | 36 | it('should push state even when set same state', () => { 37 | const spy = jest.fn() 38 | state.state$.subscribe(spy) 39 | state.setState({ count: 0 }) 40 | expect(spy.mock.calls.length).toBe(2) 41 | }) 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["src/**/__test__"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist/", 4 | "declaration": true, 5 | "removeComments": false, 6 | "preserveConstEnums": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "allowSyntheticDefaultImports": true, 12 | "noUnusedParameters": true, 13 | "noUnusedLocals": true, 14 | "importHelpers": true, 15 | "noEmitHelpers": true, 16 | "noImplicitAny": true, 17 | "esModuleInterop": true, 18 | "noImplicitReturns": true, 19 | "moduleResolution": "node", 20 | "lib": ["dom", "es2015"], 21 | "jsx": "react", 22 | "target": "es5" 23 | }, 24 | "include": ["src", "demo", "test"] 25 | } 26 | --------------------------------------------------------------------------------