├── .editorconfig ├── .gitignore ├── .prettierignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── configs ├── jest.config.js ├── rollup.config.js └── tsconfig.next.json ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── core │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── core.test.ts │ │ ├── core.ts │ │ ├── env.ts │ │ ├── http.ts │ │ ├── index.ts │ │ └── util.ts │ ├── tsconfig.json │ └── tsconfig.next.json ├── hooks │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── hooks.test.ts │ │ └── index.ts │ ├── tsconfig.json │ └── tsconfig.next.json ├── immer │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── immer.test.ts │ │ └── index.ts │ ├── tsconfig.json │ └── tsconfig.next.json ├── isomorphic-unfetch │ ├── browser.js │ ├── index.d.ts │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── readme.md ├── next-ts-example │ ├── .gitignore │ ├── README.md │ ├── components │ │ ├── Alert.tsx │ │ ├── BackToTop.tsx │ │ ├── Header.tsx │ │ ├── Layout.tsx │ │ ├── Loading.tsx │ │ ├── Menu.tsx │ │ └── UserInfo.tsx │ ├── css │ │ └── main.css │ ├── model-contexts │ │ ├── CtrlContext.ts │ │ └── CtrlContextImpl.ts │ ├── model-hooks │ │ └── http.ts │ ├── models │ │ └── LayoutModel.ts │ ├── next-env.d.ts │ ├── package-lock.json │ ├── package.json │ ├── pages │ │ ├── _app.tsx │ │ └── index.tsx │ ├── public │ │ └── image │ │ │ ├── go_icon.png │ │ │ ├── go_next_icon.png │ │ │ ├── index.png │ │ │ ├── loading.gif │ │ │ ├── login_icon.png │ │ │ ├── logo.png │ │ │ ├── nav_icon.png │ │ │ └── user.png │ ├── react-hooks │ │ ├── useScroll.ts │ │ └── useScrollToBottom.ts │ ├── src │ │ └── index │ │ │ ├── Model.ts │ │ │ ├── View.tsx │ │ │ └── index.tsx │ ├── tsconfig.json │ └── utils │ │ └── index.ts ├── next.js │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ └── index.tsx │ ├── tsconfig.json │ └── tsconfig.next.json ├── react │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── react.test.tsx │ │ └── index.tsx │ ├── tsconfig.json │ └── tsconfig.next.json └── test │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ └── index.ts │ ├── tsconfig.json │ └── tsconfig.next.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/macos 3 | # Edit at https://www.gitignore.io/?templates=macos 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | # End of https://www.gitignore.io/api/macos 34 | # Created by https://www.gitignore.io/api/node 35 | # Edit at https://www.gitignore.io/?templates=node 36 | 37 | ### Node ### 38 | # Logs 39 | logs 40 | *.log 41 | npm-debug.log* 42 | yarn-debug.log* 43 | yarn-error.log* 44 | lerna-debug.log* 45 | 46 | # Diagnostic reports (https://nodejs.org/api/report.html) 47 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Directory for instrumented libs generated by jscoverage/JSCover 56 | lib-cov 57 | 58 | # Coverage directory used by tools like istanbul 59 | coverage 60 | *.lcov 61 | 62 | # nyc test coverage 63 | .nyc_output 64 | 65 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 66 | .grunt 67 | 68 | # Bower dependency directory (https://bower.io/) 69 | bower_components 70 | 71 | # node-waf configuration 72 | .lock-wscript 73 | 74 | # Compiled binary addons (https://nodejs.org/api/addons.html) 75 | build/Release 76 | 77 | # Dependency directories 78 | node_modules/ 79 | jspm_packages/ 80 | 81 | # TypeScript v1 declaration files 82 | typings/ 83 | 84 | # TypeScript cache 85 | *.tsbuildinfo 86 | 87 | # Optional npm cache directory 88 | .npm 89 | 90 | # Optional eslint cache 91 | .eslintcache 92 | 93 | # Optional REPL history 94 | .node_repl_history 95 | 96 | # Output of 'npm pack' 97 | *.tgz 98 | 99 | # Yarn Integrity file 100 | .yarn-integrity 101 | 102 | # dotenv environment variables file 103 | .env 104 | .env.test 105 | 106 | # parcel-bundler cache (https://parceljs.org/) 107 | .cache 108 | 109 | # next.js build output 110 | .next 111 | 112 | # nuxt.js build output 113 | .nuxt 114 | 115 | # rollup.js default build output 116 | dist/ 117 | 118 | # Uncomment the public line if your project uses Gatsby 119 | # https://nextjs.org/blog/next-9-1#public-directory-support 120 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 121 | # public 122 | 123 | # Storybook build outputs 124 | .out 125 | .storybook-out 126 | 127 | # vuepress build output 128 | .vuepress/dist 129 | 130 | # Serverless directories 131 | .serverless/ 132 | 133 | # FuseBox cache 134 | .fusebox/ 135 | 136 | # DynamoDB Local files 137 | .dynamodb/ 138 | 139 | # Temporary folders 140 | tmp/ 141 | temp/ 142 | 143 | # End of https://www.gitignore.io/api/node 144 | esm 145 | lib 146 | next -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | examples/.cache 2 | node_modules 3 | dist 4 | esm -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10.14.0' 4 | install: 5 | - npm run bootstrap 6 | script: 7 | - npm run test 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 工业聚 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 | -------------------------------------------------------------------------------- /configs/jest.config.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path') 2 | 3 | module.exports = { 4 | transform: { 5 | '.(ts|tsx|js|jsx)': 'ts-jest', 6 | }, 7 | testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$', 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 9 | coveragePathIgnorePatterns: ['/examples/', '/node_modules/', '/test/'], 10 | coverageThreshold: { 11 | global: { 12 | branches: 90, 13 | functions: 95, 14 | lines: 95, 15 | statements: 95, 16 | }, 17 | }, 18 | collectCoverageFrom: ['src/*.{js,ts}'], 19 | rootDir: join(__dirname, '..'), 20 | testEnvironment: 'jsdom', 21 | moduleNameMapper: { 22 | '@pure-model/isomorphic-unfetch': '/packages/isomorphic-unfetch', 23 | '@pure-model/([^/]+)(.*)$': '/packages/$1/src$2', 24 | }, 25 | testPathIgnorePatterns: ['/node_modules/', '/examples/'], 26 | } 27 | -------------------------------------------------------------------------------- /configs/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { readdirSync, statSync } from 'fs' 2 | import { join } from 'path' 3 | 4 | import sourcemaps from 'rollup-plugin-sourcemaps' 5 | 6 | const excludeDirList = ['isomorphic-unfetch'] 7 | 8 | const pkgs = readdirSync(join(__dirname, '..', 'packages')) 9 | .filter((name) => { 10 | return !excludeDirList.includes(name) 11 | }) 12 | .map((name) => { 13 | return join(__dirname, '..', 'packages', name) 14 | }) 15 | .filter((dir) => { 16 | let stats = statSync(dir) 17 | return stats.isDirectory() && !require(join(dir, 'package.json')).private 18 | }) 19 | 20 | const set = new Set() 21 | 22 | pkgs.forEach((dir) => { 23 | let pkg = require(join(dir, 'package.json')) 24 | Object.keys({ 25 | ...pkg.peerDependencies, 26 | ...pkg.dependencies, 27 | }).forEach((dep) => set.add(dep)) 28 | }) 29 | 30 | const external = [...set] 31 | 32 | export default pkgs.map((dir) => ({ 33 | input: `${dir}/next/index.js`, 34 | external, 35 | plugins: [sourcemaps()], 36 | output: [ 37 | { 38 | file: `${dir}/dist/index.js`, 39 | format: 'cjs', 40 | sourcemap: true, 41 | }, 42 | ], 43 | })) 44 | -------------------------------------------------------------------------------- /configs/tsconfig.next.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2018", 5 | "noEmit": true 6 | }, 7 | "references": [ 8 | { "path": "../packages/core/tsconfig.next.json" }, 9 | { "path": "../packages/hooks/tsconfig.next.json" }, 10 | { "path": "../packages/immer/tsconfig.next.json" }, 11 | { "path": "../packages/react/tsconfig.next.json" }, 12 | { "path": "../packages/test/tsconfig.next.json" }, 13 | { "path": "../packages/next.js/tsconfig.next.json" } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "1.3.2" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pure-model", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "description": "A framework for writing model-oriented programming", 6 | "author": "https://github.com/Lucifier129", 7 | "private": true, 8 | "scripts": { 9 | "build": "run-s clean build:next build:cjs clean:tests", 10 | "build:next": "tsc -b ./configs/tsconfig.next.json", 11 | "build:cjs": "rollup --config ./configs/rollup.config.js", 12 | "clean": "shx rm -rf ./packages/*/*.tsbuildinfo && shx rm -rf ./packages/*/{esm,dist,next}", 13 | "clean:tests": "shx rm -rf ./packages/*/{esm,next}/**/__tests__", 14 | "clean:deps": "shx rm -rf ./node_modules && shx rm -rf ./packages/*/node_modules", 15 | "format": "run-p format:md format:json format:source format:yml", 16 | "format:json": "prettier --parser json --write **/*.json", 17 | "format:md": "prettier --parser markdown --write ./*.md ./{examples,packages}/**/*.md", 18 | "format:source": "prettier --config ./package.json ./{examples,packages}/**/*.{ts,tsx,js} --write", 19 | "format:yml": "prettier --parser yaml --write ./*.{yml,yaml}", 20 | "test": "jest --config ./configs/jest.config.js", 21 | "test:coverage": "jest --config ./configs/jest.config.js --collectCoverage", 22 | "bootstrap": "lerna bootstrap --hoist", 23 | "release": "npm run build && npm run test && lerna publish" 24 | }, 25 | "devDependencies": { 26 | "@types/jest": "^26.0.24", 27 | "@types/node": "^14.18.63", 28 | "@types/react": "^17.0.80", 29 | "@types/react-dom": "^17.0.25", 30 | "@types/react-test-renderer": "^17.0.9", 31 | "@types/sinon": "^9.0.11", 32 | "codecov": "^3.8.3", 33 | "husky": "^4.3.8", 34 | "jest": "^26.6.3", 35 | "lerna": "^3.22.1", 36 | "lint-staged": "^10.5.4", 37 | "npm-run-all": "^4.1.5", 38 | "prettier": "^2.8.8", 39 | "react": "^17.0.2", 40 | "react-dom": "^17.0.2", 41 | "react-test-renderer": "^17.0.2", 42 | "rollup": "^2.79.1", 43 | "rollup-plugin-sourcemaps": "^0.6.3", 44 | "shx": "^0.3.4", 45 | "sinon": "^9.2.4", 46 | "ts-jest": "^26.5.6", 47 | "ts-node": "^9.1.1", 48 | "tslib": "^2.6.3", 49 | "typescript": "^4.9.5" 50 | }, 51 | "lint-staged": { 52 | "*.@(js|ts|tsx)": [ 53 | "prettier --write" 54 | ], 55 | "*.@(yml|yaml)": [ 56 | "prettier --parser yaml --write" 57 | ], 58 | "*.md": [ 59 | "prettier --parser markdown --write" 60 | ], 61 | "*.json": [ 62 | "prettier --parser json --write" 63 | ] 64 | }, 65 | "prettier": { 66 | "printWidth": 120, 67 | "semi": false, 68 | "trailingComma": "all", 69 | "singleQuote": true, 70 | "arrowParens": "always" 71 | }, 72 | "husky": { 73 | "hooks": { 74 | "pre-commit": "lint-staged" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @pure-model/core 2 | 3 | the core library of pure-model 4 | -------------------------------------------------------------------------------- /packages/core/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/core", 3 | "version": "1.3.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/redux-logger": { 8 | "version": "3.0.8", 9 | "resolved": "https://registry.npmjs.org/@types/redux-logger/-/redux-logger-3.0.8.tgz", 10 | "integrity": "sha512-zM+cxiSw6nZtRbxpVp9SE3x/X77Z7e7YAfHD1NkxJyJbAGSXJGF0E9aqajZfPOa/sTYnuwutmlCldveExuCeLw==", 11 | "dev": true, 12 | "requires": { 13 | "redux": "^4.0.0" 14 | } 15 | }, 16 | "decode-uri-component": { 17 | "version": "0.2.0", 18 | "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", 19 | "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==" 20 | }, 21 | "deep-diff": { 22 | "version": "0.3.8", 23 | "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", 24 | "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" 25 | }, 26 | "filter-obj": { 27 | "version": "1.1.0", 28 | "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", 29 | "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==" 30 | }, 31 | "js-tokens": { 32 | "version": "4.0.0", 33 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 34 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 35 | }, 36 | "loose-envify": { 37 | "version": "1.4.0", 38 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 39 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 40 | "requires": { 41 | "js-tokens": "^3.0.0 || ^4.0.0" 42 | } 43 | }, 44 | "query-string": { 45 | "version": "7.1.1", 46 | "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.1.tgz", 47 | "integrity": "sha512-MplouLRDHBZSG9z7fpuAAcI7aAYjDLhtsiVZsevsfaHWDS2IDdORKbSd1kWUA+V4zyva/HZoSfpwnYMMQDhb0w==", 48 | "requires": { 49 | "decode-uri-component": "^0.2.0", 50 | "filter-obj": "^1.1.0", 51 | "split-on-first": "^1.0.0", 52 | "strict-uri-encode": "^2.0.0" 53 | } 54 | }, 55 | "redux": { 56 | "version": "4.0.5", 57 | "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", 58 | "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", 59 | "requires": { 60 | "loose-envify": "^1.4.0", 61 | "symbol-observable": "^1.2.0" 62 | } 63 | }, 64 | "redux-logger": { 65 | "version": "3.0.6", 66 | "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", 67 | "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=", 68 | "requires": { 69 | "deep-diff": "^0.3.5" 70 | } 71 | }, 72 | "split-on-first": { 73 | "version": "1.1.0", 74 | "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", 75 | "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" 76 | }, 77 | "strict-uri-encode": { 78 | "version": "2.0.0", 79 | "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", 80 | "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==" 81 | }, 82 | "symbol-observable": { 83 | "version": "1.2.0", 84 | "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", 85 | "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" 86 | }, 87 | "tslib": { 88 | "version": "2.1.0", 89 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", 90 | "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/core", 3 | "version": "1.3.1", 4 | "description": "A framework for writing model-oriented programming", 5 | "main": "dist/index.js", 6 | "esnext": "next/index.js", 7 | "types": "next/index.d.ts", 8 | "keywords": ["State Management", "Effect Management"], 9 | "author": "Jade Gu", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@types/redux-logger": "^3.0.7" 13 | }, 14 | "dependencies": { 15 | "@pure-model/isomorphic-unfetch": "^1.2.15", 16 | "query-string": "^7.0.0", 17 | "redux": "^4.0.5", 18 | "redux-logger": "^3.0.6", 19 | "tslib": "^2.0.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/__tests__/core.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest' 2 | import { 3 | createPureModel, 4 | createModelContext, 5 | setupStore, 6 | setupContext, 7 | setupStartCallback, 8 | setupFinishCallback, 9 | setupPreloadCallback, 10 | subscribe, 11 | mergeModelContext, 12 | setupModel, 13 | createPureModelContainer, 14 | } from '../core' 15 | 16 | const setupInterval = (f: () => void) => { 17 | let timerId: any 18 | 19 | let enable = (period = 10) => { 20 | disable() 21 | timerId = setInterval(f, period) 22 | } 23 | 24 | let disable = () => { 25 | clearInterval(timerId) 26 | } 27 | 28 | return { 29 | enable, 30 | disable, 31 | } 32 | } 33 | 34 | let setupCounter = (n = 0) => { 35 | let { store, actions } = setupStore({ 36 | initialState: n, 37 | reducers: { 38 | incre: (state: number) => state + 1, 39 | decre: (state: number) => state - 1, 40 | increBy: (state: number, step: number = 1) => state + step, 41 | }, 42 | }) 43 | return { 44 | store, 45 | actions, 46 | } 47 | } 48 | 49 | describe('pure-model', () => { 50 | it('should return well-typed result', async () => { 51 | let counter = createPureModel(setupCounter) 52 | 53 | expect(counter.store.getState()).toEqual(0) 54 | 55 | counter.actions.incre() 56 | 57 | expect(counter.store.getState()).toEqual(1) 58 | 59 | counter.actions.increBy(2) 60 | 61 | expect(counter.store.getState()).toEqual(3) 62 | 63 | counter.actions.decre() 64 | 65 | expect(counter.store.getState()).toEqual(2) 66 | }) 67 | 68 | it('should reconcile life-cycle hooks in the correct order', async () => { 69 | let starts: number[] = [] 70 | let preloads: number[] = [] 71 | let finishes: number[] = [] 72 | let counter = createPureModel(() => { 73 | let counter = setupCounter() 74 | let i = 0 75 | 76 | setupStartCallback(() => { 77 | starts.push(i++) 78 | }) 79 | 80 | setupPreloadCallback(() => { 81 | preloads.push(i++) 82 | }) 83 | 84 | setupPreloadCallback(() => { 85 | preloads.push(i++) 86 | }) 87 | 88 | setupStartCallback(() => { 89 | starts.push(i++) 90 | }) 91 | 92 | setupFinishCallback(() => { 93 | finishes.push(i++) 94 | }) 95 | 96 | setupFinishCallback(() => { 97 | finishes.push(i++) 98 | }) 99 | 100 | return counter 101 | }) 102 | 103 | expect(() => { 104 | counter.start() 105 | }).toThrow('Expected calling .preload() before .start()') 106 | 107 | expect(() => { 108 | counter.finish() 109 | }).toThrow('Expected calling .start() before .finish()') 110 | 111 | await counter.preload() 112 | 113 | expect(() => { 114 | counter.finish() 115 | }).toThrow('Expected calling .start() before .finish()') 116 | 117 | counter.start() 118 | counter.finish() 119 | 120 | expect(preloads).toEqual([0, 1]) 121 | expect(starts).toEqual([2, 3]) 122 | expect(finishes).toEqual([4, 5]) 123 | }) 124 | 125 | it('should not consume state before .start()', async () => { 126 | let counter = createPureModel(setupCounter) 127 | let list: number[] = [] 128 | 129 | subscribe(counter, (state) => { 130 | list.push(state) 131 | }) 132 | 133 | counter.actions.incre() 134 | counter.actions.incre() 135 | 136 | await counter.preload() 137 | 138 | counter.actions.incre() 139 | counter.actions.incre() 140 | 141 | counter.start() 142 | 143 | counter.actions.incre() 144 | counter.actions.incre() 145 | 146 | expect(list).toEqual([5, 6]) 147 | }) 148 | 149 | it('should not consume after unsubscribing', async () => { 150 | let counter = createPureModel(setupCounter) 151 | let list: number[] = [] 152 | 153 | let unsubscribe = subscribe(counter, (state) => { 154 | list.push(state) 155 | }) 156 | 157 | await counter.preload() 158 | counter.start() 159 | 160 | expect(typeof unsubscribe).toEqual('function') 161 | 162 | counter.actions.incre() 163 | counter.actions.incre() 164 | 165 | counter.actions.decre() 166 | counter.actions.incre() 167 | 168 | unsubscribe() 169 | 170 | counter.actions.incre() 171 | counter.actions.incre() 172 | 173 | expect(list).toEqual([1, 2, 1, 2]) 174 | }) 175 | 176 | it('should throw error when calling setupStore more than once', async () => { 177 | expect(() => { 178 | createPureModel(() => { 179 | setupCounter() 180 | return setupCounter() 181 | }) 182 | }).toThrow() 183 | }) 184 | 185 | it('supports calling actions in life-cycle hooks', async () => { 186 | let counter = createPureModel(() => { 187 | let { store, actions } = setupCounter(10) 188 | 189 | setupPreloadCallback(() => { 190 | actions.increBy(20) 191 | }) 192 | 193 | setupStartCallback(() => { 194 | actions.increBy(30) 195 | }) 196 | 197 | setupFinishCallback(() => { 198 | actions.increBy(40) 199 | }) 200 | 201 | return { 202 | store, 203 | actions, 204 | } 205 | }) 206 | 207 | expect(counter.store.getState()).toEqual(10) 208 | 209 | await counter.preload() 210 | 211 | expect(counter.store.getState()).toEqual(30) 212 | 213 | counter.start() 214 | 215 | expect(counter.store.getState()).toEqual(60) 216 | 217 | counter.finish() 218 | 219 | expect(counter.store.getState()).toEqual(100) 220 | }) 221 | 222 | it('supports define action with effects', async (done) => { 223 | let counter = createPureModel(() => { 224 | let { store, actions } = setupCounter() 225 | 226 | let timer = setupInterval(() => { 227 | actions.incre() 228 | }) 229 | 230 | return { 231 | store, 232 | actions: { 233 | ...actions, 234 | timer, 235 | }, 236 | } 237 | }) 238 | 239 | let list: number[] = [] 240 | 241 | subscribe(counter, (state) => { 242 | list.push(state) 243 | }) 244 | 245 | await counter.preload() 246 | counter.start() 247 | 248 | counter.actions.timer.enable(10) 249 | 250 | setTimeout(() => { 251 | counter.actions.timer.disable() 252 | let data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 253 | 254 | for (let i = 0; i < list.length; i++) { 255 | expect(list[i]).toEqual(data[i]) 256 | } 257 | 258 | expect(list.length > 0).toBe(true) 259 | 260 | done() 261 | }, 40) 262 | 263 | expect(list.length).toEqual(0) 264 | }) 265 | 266 | it('supports preloadedState that injecting state to store and ignore calling .preload()', async () => { 267 | let counter = createPureModel( 268 | () => { 269 | let { store, actions } = setupCounter() 270 | 271 | setupPreloadCallback(() => { 272 | throw new Error(`Should not run here`) 273 | }) 274 | 275 | return { 276 | store, 277 | actions, 278 | } 279 | }, 280 | { 281 | preloadedState: 100, 282 | }, 283 | ) 284 | 285 | expect(counter.isPreloaded()).toEqual(true) 286 | 287 | expect(counter.store.getState()).toEqual(100) 288 | 289 | await counter.preload() 290 | 291 | expect(counter.store.getState()).toEqual(100) 292 | }) 293 | 294 | it('supports inject context via createModelContext and setupContext', async () => { 295 | let context = createModelContext(100) 296 | 297 | let counter = createPureModel(() => { 298 | let initialCount = setupContext(context) 299 | return setupCounter(initialCount) 300 | }) 301 | 302 | expect(counter.store.getState()).toEqual(100) 303 | }) 304 | 305 | it('supports pass context to createPureModel', async () => { 306 | let context = createModelContext(100) 307 | 308 | let counter = createPureModel( 309 | () => { 310 | let initialCount = setupContext(context) 311 | return setupCounter(initialCount) 312 | }, 313 | { 314 | context: context.create(200), 315 | }, 316 | ) 317 | 318 | expect(counter.store.getState()).toEqual(200) 319 | }) 320 | 321 | it('supports pass multi contexts to createPureModel', async () => { 322 | let context0 = createModelContext(0) 323 | let context1 = createModelContext(0) 324 | 325 | let counter = createPureModel( 326 | () => { 327 | let initialCount0 = setupContext(context0) 328 | let initialCount1 = setupContext(context1) 329 | 330 | return setupCounter(initialCount0 - initialCount1) 331 | }, 332 | { 333 | context: mergeModelContext(context1.create(99), context0.create(100)), 334 | }, 335 | ) 336 | 337 | expect(counter.store.getState()).toEqual(1) 338 | }) 339 | 340 | it('support setupModel to access another models', async () => { 341 | let delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 342 | let TestContext = createModelContext(0) 343 | 344 | function counter0() { 345 | let { store, actions } = setupCounter() 346 | 347 | let testContext = setupContext(TestContext) 348 | 349 | setupPreloadCallback(async () => { 350 | await delay(20) 351 | actions.increBy(10 + testContext) 352 | }) 353 | 354 | return { 355 | store, 356 | actions, 357 | } 358 | } 359 | 360 | function counter1() { 361 | let { store, actions } = setupCounter() 362 | 363 | let testContext = setupContext(TestContext) 364 | 365 | setupPreloadCallback(async () => { 366 | await delay(10) 367 | actions.increBy(20 + testContext) 368 | }) 369 | 370 | return { 371 | store, 372 | actions, 373 | } 374 | } 375 | 376 | type Counter2State = { 377 | count1: number 378 | count2: number 379 | testContextValue: number 380 | } 381 | 382 | function counter2() { 383 | let { store, actions } = setupStore({ 384 | initialState: { 385 | count1: 0, 386 | count2: 0, 387 | } as Counter2State, 388 | reducers: { 389 | update: (state: Counter2State, newState: Counter2State): Counter2State => { 390 | return { 391 | ...state, 392 | ...newState, 393 | } 394 | }, 395 | }, 396 | }) 397 | 398 | let testContext = setupContext(TestContext) 399 | 400 | let counter0Model = setupModel(counter0) 401 | 402 | let counter1Model = setupModel(counter1) 403 | 404 | setupPreloadCallback(() => { 405 | actions.update({ 406 | count1: counter0Model.store.getState(), 407 | count2: counter1Model.store.getState(), 408 | testContextValue: testContext, 409 | }) 410 | }) 411 | 412 | return { 413 | store, 414 | actions, 415 | } 416 | } 417 | 418 | let container = createPureModelContainer() 419 | let testContext = TestContext.create(3) 420 | 421 | container.set(counter0, { 422 | context: testContext, 423 | }) 424 | 425 | container.set(counter1, { 426 | context: testContext, 427 | }) 428 | 429 | let counter0Model = createPureModel(counter0, { 430 | container: container, 431 | }) 432 | 433 | let counter1Model = createPureModel(counter1, { 434 | container: container, 435 | }) 436 | 437 | let counter2Model = createPureModel(counter2, { 438 | container: container, 439 | context: TestContext.create(4), 440 | }) 441 | 442 | counter0Model.preload() 443 | await counter2Model.preload() 444 | 445 | expect(counter0Model.isPreloaded()).toEqual(true) 446 | expect(counter0Model.store.getState()).toEqual(13) 447 | 448 | expect(counter1Model.isPreloaded()).toEqual(true) 449 | expect(counter1Model.store.getState()).toEqual(23) 450 | 451 | expect(counter2Model.store.getState()).toEqual({ 452 | count1: 13, 453 | count2: 23, 454 | testContextValue: 4, 455 | }) 456 | 457 | // test preloadedState 458 | 459 | container = createPureModelContainer() 460 | testContext = TestContext.create(3) 461 | 462 | container.set(counter0, { 463 | preloadedState: 100, 464 | context: testContext, 465 | }) 466 | 467 | container.set(counter1, { 468 | preloadedState: 200, 469 | context: testContext, 470 | }) 471 | 472 | counter0Model = createPureModel(counter0, { 473 | container: container, 474 | }) 475 | 476 | counter1Model = createPureModel(counter1, { 477 | container: container, 478 | }) 479 | 480 | counter2Model = createPureModel(counter2, { 481 | container: container, 482 | context: TestContext.create(4), 483 | }) 484 | 485 | await counter2Model.preload() 486 | 487 | expect(counter0Model.isPreloaded()).toEqual(true) 488 | expect(counter0Model.store.getState()).toEqual(100) 489 | 490 | expect(counter1Model.isPreloaded()).toEqual(true) 491 | expect(counter1Model.store.getState()).toEqual(200) 492 | 493 | expect(counter2Model.store.getState()).toEqual({ 494 | count1: 100, 495 | count2: 200, 496 | testContextValue: 4, 497 | }) 498 | }) 499 | }) 500 | -------------------------------------------------------------------------------- /packages/core/src/core.ts: -------------------------------------------------------------------------------- 1 | import { createStore as createReduxStore, compose, Store, PreloadedState, applyMiddleware } from 'redux' 2 | 3 | import { createLogger } from 'redux-logger' 4 | 5 | import { isPlainObject, shallowEqual, forcePlainDataCheck, identity, isThenable } from './util' 6 | 7 | export { identity, shallowEqual, isThenable } 8 | 9 | export type { Store, PreloadedState } 10 | 11 | export type ReducerWithoutPayload = (state: S) => S 12 | export type ReducerWithPayload = (state: S, payload: P) => S 13 | export type ReducerWithOptionalPayload = (state: S, payload?: P) => S 14 | 15 | export type Reducer = ReducerWithPayload | ReducerWithoutPayload | ReducerWithOptionalPayload 16 | 17 | export type Reducers = { 18 | [key: string]: Reducer 19 | } 20 | 21 | type Actions = { 22 | [key: string]: AnyFn | Actions 23 | } 24 | 25 | export type Tail = ((...t: T) => any) extends (_: any, ...tail: infer TT) => any ? TT : [] 26 | 27 | export type ReducerToAction = R extends (...args: infer Args) => any 28 | ? (...args: Tail) => void 29 | : never 30 | 31 | export type ReducersToActions = { 32 | [key in keyof RS]: ReducerToAction 33 | } 34 | 35 | export type CreateStoreOptions> = { 36 | name?: string 37 | initialState: S 38 | reducers: RS 39 | devtools?: boolean 40 | logger?: boolean 41 | } 42 | 43 | export type CreateInternalStoreOptions = CreateStoreOptions & { 44 | preloadedState?: PreloadedState 45 | } 46 | 47 | export type ActionObject = { 48 | type: string 49 | payload?: any 50 | } 51 | 52 | type AnyFn = (...args: any) => any 53 | 54 | type Hooks = { 55 | [key: string]: AnyFn 56 | } 57 | 58 | type DefaultHooks = { 59 | [key in keyof HS]: (...args: Parameters) => never 60 | } 61 | 62 | const createHooks = (defaultHooks: DefaultHooks) => { 63 | let currentHooks: Hooks = defaultHooks 64 | 65 | let hooks = {} as HS 66 | 67 | for (let key in defaultHooks) { 68 | let f = ((...args) => { 69 | let handler = currentHooks[key] 70 | // tslint:disable-next-line: strict-type-predicates 71 | if (typeof handler !== 'function') { 72 | handler = defaultHooks[key] 73 | } 74 | // @ts-ignore 75 | return handler(...args) 76 | }) as HS[typeof key] 77 | 78 | hooks[key] = f 79 | } 80 | 81 | let run = (f: F, implementations: HS): ReturnType => { 82 | let previousHooks = currentHooks 83 | try { 84 | currentHooks = implementations ?? currentHooks 85 | return f() 86 | } finally { 87 | currentHooks = previousHooks 88 | } 89 | } 90 | 91 | return { run, hooks } 92 | } 93 | 94 | export const MODEL_CONTEXT = Symbol('@pure-model/context') 95 | 96 | export type ModelContextValue = { 97 | [MODEL_CONTEXT]: { 98 | [key: string]: { value: T } 99 | } 100 | } 101 | 102 | export const mergeModelContext = (...args: ModelContextValue[]): ModelContextValue => { 103 | let mergedResult = { 104 | [MODEL_CONTEXT]: {}, 105 | } as ModelContextValue 106 | 107 | for (let i = 0; i < args.length; i++) { 108 | Object.assign(mergedResult[MODEL_CONTEXT], args[i][MODEL_CONTEXT]) 109 | } 110 | 111 | return mergedResult 112 | } 113 | 114 | export const getModelContextValue = (modelContextValue: ModelContextValue, key: string) => { 115 | let obj = modelContextValue[MODEL_CONTEXT] 116 | 117 | if (obj && obj.hasOwnProperty(key)) { 118 | return obj[key] 119 | } 120 | 121 | return null 122 | } 123 | 124 | export type ModelContext = { 125 | id: string 126 | impl: ( 127 | value: V, 128 | ) => { 129 | [key: string]: { value: V } 130 | } 131 | initialValue: V 132 | create: (value: V) => ModelContextValue 133 | } 134 | 135 | export type ModelContextValueType = T extends ModelContext ? V : never 136 | 137 | let modelContextOffset = 0 138 | const getModelContextId = () => modelContextOffset++ 139 | 140 | export const createModelContext = (initialValue: V): ModelContext => { 141 | let id = `@pure-model/context/${getModelContextId()}` 142 | 143 | let impl = (value: V) => { 144 | return { 145 | [id]: { 146 | value, 147 | }, 148 | } 149 | } 150 | 151 | let create = (value: V) => { 152 | return { [MODEL_CONTEXT]: impl(value) } 153 | } 154 | 155 | return { 156 | id, 157 | initialValue, 158 | impl, 159 | create, 160 | } 161 | } 162 | 163 | export type PresetHooks = { 164 | setupStore: >( 165 | options: CreateStoreOptions, 166 | ) => { 167 | store: Store 168 | actions: ReducersToActions 169 | } 170 | setupContext: (ctx: Ctx) => ModelContextValueType 171 | setupStartCallback: (callback: Callback) => void 172 | setupFinishCallback: (callback: Callback) => void 173 | setupPreloadCallback: (callback: Callback) => void 174 | setupModel: (Model: PureModelContainerKey) => PureModel 175 | } 176 | 177 | let { run, hooks } = createHooks({ 178 | setupStore() { 179 | throw new Error(`setupStore can't not be called after initializing`) 180 | }, 181 | setupContext() { 182 | throw new Error(`setupContext can't not be called after initializing`) 183 | }, 184 | setupStartCallback() { 185 | throw new Error(`setupStartCallback can't not be called after initializing`) 186 | }, 187 | setupFinishCallback() { 188 | throw new Error(`setupFinishCallback can't not be called after initializing`) 189 | }, 190 | setupPreloadCallback() { 191 | throw new Error(`setupPreloadCallback can't not be called after initializing`) 192 | }, 193 | setupModel() { 194 | throw new Error(`setupModel can't not be called after initializing`) 195 | }, 196 | }) 197 | 198 | export const { 199 | setupStore, 200 | setupContext, 201 | setupStartCallback, 202 | setupFinishCallback, 203 | setupPreloadCallback, 204 | setupModel, 205 | } = hooks 206 | 207 | const createInternalStore = >(options: CreateInternalStoreOptions) => { 208 | let { reducers, initialState, preloadedState } = options 209 | 210 | /** 211 | * check initial state in non-production env 212 | */ 213 | if (process.env.NODE_ENV !== 'production') { 214 | forcePlainDataCheck(initialState) 215 | } 216 | 217 | let reducer = (state: S = initialState, action: ActionObject) => { 218 | /** 219 | * check action in non-production env 220 | */ 221 | if (process.env.NODE_ENV !== 'production') { 222 | forcePlainDataCheck(action) 223 | } 224 | 225 | let actionType = action.type 226 | 227 | if (!reducers.hasOwnProperty(actionType)) { 228 | return state 229 | } 230 | 231 | let update = reducers[actionType] 232 | 233 | let nextState = update(state, action.payload) 234 | 235 | /** 236 | * check next state in non-production env 237 | */ 238 | if (process.env.NODE_ENV !== 'production') { 239 | forcePlainDataCheck(nextState) 240 | } 241 | 242 | return nextState 243 | } 244 | 245 | let enhancer = createReduxDevtoolsEnhancer(options.devtools, options.name, options.logger) 246 | 247 | let store = createReduxStore(reducer, preloadedState, enhancer) 248 | 249 | let actions = createActions(reducers, store.dispatch) 250 | 251 | return { 252 | store, 253 | actions, 254 | } 255 | } 256 | 257 | const createReduxDevtoolsEnhancer = (devtools: boolean = true, name?: string, enableLogger = false) => { 258 | let composeEnhancers = 259 | // tslint:disable-next-line: strict-type-predicates 260 | devtools && typeof window === 'object' && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 261 | ? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ 262 | name, 263 | }) 264 | : compose 265 | 266 | // @ts-ignore 267 | let enhancer = enableLogger ? composeEnhancers(applyMiddleware(createLogger())) : composeEnhancers() 268 | 269 | return enhancer 270 | } 271 | 272 | type Dispatch = (action: ActionObject) => ActionObject 273 | 274 | const createActions = (reducers: RS, dispatch: Dispatch): ReducersToActions => { 275 | let actions = {} as ReducersToActions 276 | 277 | for (let actionType in reducers) { 278 | let reducer = reducers[actionType] 279 | let action = ((payload: any) => { 280 | dispatch({ 281 | type: actionType, 282 | payload: payload, 283 | }) 284 | }) as ReducerToAction 285 | 286 | actions[actionType] = action 287 | } 288 | 289 | return actions 290 | } 291 | export type Initializer = ( 292 | ...args: any 293 | ) => { 294 | store: Store 295 | actions: Actions 296 | } 297 | 298 | export type InitializerState = I extends ( 299 | ...args: any 300 | ) => { 301 | store: Store 302 | actions: Actions 303 | } 304 | ? S 305 | : never 306 | 307 | export type InitializerActions = I extends ( 308 | ...args: any 309 | ) => { 310 | store: Store 311 | actions: infer A 312 | } 313 | ? A 314 | : never 315 | 316 | type Callback = () => any 317 | 318 | type CallbackList = Callback[] 319 | 320 | const publish = (callbackList: CallbackList) => { 321 | let resultList = [] 322 | 323 | for (let i = 0; i < callbackList.length; i++) { 324 | let callback = callbackList[i] 325 | resultList.push(callback()) 326 | } 327 | 328 | return resultList 329 | } 330 | 331 | const createCallbackManager = () => { 332 | let isPreloaded = false 333 | let isStarted = false 334 | let isFinished = false 335 | 336 | let startCallbackList: CallbackList = [] 337 | 338 | let addStartCallback = (startCallback: Callback) => { 339 | if (isPreloaded) { 340 | throw new Error(`Can't add start callback after preloading`) 341 | } 342 | 343 | if (!startCallbackList.includes(startCallback)) { 344 | startCallbackList.push(startCallback) 345 | } 346 | } 347 | 348 | let start = () => { 349 | if (!isPreloaded) { 350 | throw new Error(`Expected calling .preload() before .start()`) 351 | } 352 | 353 | if (isStarted) { 354 | return 355 | } 356 | 357 | isStarted = true 358 | 359 | let list = startCallbackList 360 | startCallbackList = [] 361 | publish(list) 362 | } 363 | 364 | let finishCallbackList: CallbackList = [] 365 | 366 | let addFinishCallback = (finishCallback: Callback) => { 367 | if (isPreloaded) { 368 | throw new Error(`Can't add finish callback after preloading`) 369 | } 370 | 371 | if (!finishCallbackList.includes(finishCallback)) { 372 | finishCallbackList.push(finishCallback) 373 | } 374 | } 375 | 376 | let finish = () => { 377 | if (!isStarted) { 378 | throw new Error(`Expected calling .start() before .finish()`) 379 | } 380 | 381 | if (isFinished) { 382 | return 383 | } 384 | 385 | isFinished = true 386 | 387 | let list = finishCallbackList 388 | finishCallbackList = [] 389 | publish(list) 390 | } 391 | 392 | let preloadCallbackList: CallbackList = [] 393 | 394 | let addPreloadCallback = (preloadCallback: Callback) => { 395 | if (isPreloaded) { 396 | throw new Error(`Can't add preload callback after preloading`) 397 | } 398 | 399 | if (!preloadCallbackList.includes(preloadCallback)) { 400 | preloadCallbackList.push(preloadCallback) 401 | } 402 | } 403 | 404 | let preloadingPromise: Promise | null = null 405 | 406 | let preload = (): Promise => { 407 | if (preloadingPromise) { 408 | return preloadingPromise 409 | } 410 | 411 | if (isPreloaded || !preloadCallbackList.length) { 412 | isPreloaded = true 413 | return Promise.resolve() 414 | } 415 | 416 | let list = preloadCallbackList 417 | preloadCallbackList = [] 418 | 419 | preloadingPromise = Promise.all(publish(list)).then(() => { }) 420 | isPreloaded = true 421 | 422 | return preloadingPromise 423 | } 424 | 425 | let clearPreloadCallbackList = () => { 426 | isPreloaded = true 427 | preloadCallbackList = [] 428 | } 429 | 430 | return { 431 | preload, 432 | addPreloadCallback, 433 | isPreloaded() { 434 | return isPreloaded 435 | }, 436 | start, 437 | addStartCallback, 438 | isStarted() { 439 | return isStarted 440 | }, 441 | finish, 442 | addFinishCallback, 443 | isFinished() { 444 | return isFinished 445 | }, 446 | clearPreloadCallbackList, 447 | } 448 | } 449 | 450 | export type CallbackManager = ReturnType 451 | 452 | export type PureModel = ReturnType & { 453 | initializer: I 454 | preload: () => Promise 455 | start: () => void 456 | finish: () => void 457 | addPreloadCallback: (preloadCallback: Callback) => void 458 | addStartCallback: (startCallback: Callback) => void 459 | addFinishCallback: (finishCallback: Callback) => void 460 | isPreloaded: () => boolean 461 | isStarted: () => boolean 462 | isFinished: () => boolean 463 | } 464 | 465 | export type AnyPureModel = PureModel 466 | 467 | export type PureModelContainerKey = 468 | | I 469 | | { 470 | initializer: I 471 | } 472 | 473 | export type PureModelContainerValue = { 474 | preloadedState?: PreloadedState> 475 | context?: ModelContextValue 476 | model?: AnyPureModel 477 | } 478 | 479 | export type PureModelContainerStore = WeakMap 480 | 481 | export type PureModelContainer = { 482 | get: (key: PureModelContainerKey) => PureModelContainerValue | undefined 483 | set: (key: PureModelContainerKey, value: PureModelContainerValue) => void 484 | getModel: (key: PureModelContainerKey) => PureModel 485 | } 486 | 487 | export type PureModelContainerOptions = { 488 | context?: ModelContextValue 489 | } 490 | 491 | export const createPureModelContainer = () => { 492 | let store: PureModelContainerStore = new WeakMap() 493 | 494 | let getInitializer = (key: PureModelContainerKey): I => { 495 | return 'initializer' in key ? key.initializer : key 496 | } 497 | 498 | let get: PureModelContainer['get'] = (key) => { 499 | return store.get(getInitializer(key)) as any 500 | } 501 | 502 | let set: PureModelContainer['set'] = (key, value) => { 503 | store.set(getInitializer(key), value) 504 | } 505 | 506 | let getModel = (key: PureModelContainerKey): PureModel => { 507 | let initializer = getInitializer(key) 508 | let containerValue = get(initializer) 509 | 510 | if (containerValue) { 511 | if (containerValue.model) { 512 | return containerValue.model as PureModel 513 | } 514 | 515 | const model = createPureModel(initializer, { 516 | // @ts-ignore 517 | preloadedState: containerValue.preloadedState, 518 | context: containerValue.context, 519 | container: container, 520 | }) 521 | 522 | containerValue.model = model 523 | 524 | return model 525 | } 526 | 527 | let model = createPureModel(initializer, { 528 | container: container, 529 | }) 530 | 531 | store.set(initializer, { 532 | model, 533 | }) 534 | 535 | return model 536 | } 537 | 538 | const container: PureModelContainer = { 539 | get, 540 | set, 541 | getModel, 542 | } 543 | 544 | return container 545 | } 546 | 547 | export type CreatePureModelOptions = { 548 | preloadedState?: PreloadedState> 549 | context?: ModelContextValue 550 | container?: PureModelContainer 551 | } 552 | 553 | export const createPureModel = ( 554 | initializer: I, 555 | options: CreatePureModelOptions = {}, 556 | ): PureModel => { 557 | let container: PureModelContainer = options.container ?? createPureModelContainer() 558 | 559 | let selfContainerValue = container.get(initializer) 560 | 561 | if (selfContainerValue) { 562 | if (selfContainerValue.model) { 563 | return selfContainerValue.model as PureModel 564 | } 565 | options = { 566 | ...options, 567 | context: selfContainerValue.context ?? options.context, 568 | preloadedState: selfContainerValue.preloadedState ?? options.preloadedState, 569 | } 570 | } 571 | 572 | let callbackManager = createCallbackManager() 573 | 574 | let upstreamModelSet = new Set() 575 | 576 | let setupModel = >(Model: PureModelContainerKey): PureModel => { 577 | let model = container.getModel(Model) 578 | 579 | upstreamModelSet.add(model) 580 | 581 | return model 582 | } 583 | 584 | let setupContext = ((ctx: ModelContext) => { 585 | if (options.context) { 586 | let target = getModelContextValue(options.context, ctx.id) 587 | 588 | if (target) { 589 | return target.value 590 | } 591 | } 592 | 593 | return ctx.initialValue 594 | }) as AnyFn 595 | 596 | let hasStore = false 597 | 598 | let setupStore = >(storeOptions: CreateStoreOptions) => { 599 | if (hasStore) { 600 | throw new Error(`Expected calling setupStore only once in initializer: ${initializer.toString()}`) 601 | } 602 | 603 | hasStore = true 604 | 605 | return createInternalStore({ 606 | devtools: true, 607 | ...storeOptions, 608 | preloadedState: options.preloadedState, 609 | }) 610 | } 611 | 612 | let implementations = { 613 | setupStore: setupStore, 614 | setupContext: setupContext, 615 | setupPreloadCallback: callbackManager.addPreloadCallback, 616 | setupStartCallback: callbackManager.addStartCallback, 617 | setupFinishCallback: callbackManager.addFinishCallback, 618 | setupModel: setupModel, 619 | } 620 | 621 | let result = run(() => { 622 | let result = initializer() 623 | 624 | if (!result) { 625 | throw new Error(`Expected initializer returning { store, actions }, but got ${result}`) 626 | } 627 | 628 | let { store, actions } = result 629 | 630 | if (!store) { 631 | throw new Error(`Expected initializer returning { store, actions }, but got a invalid store: ${store}`) 632 | } 633 | 634 | if (!actions) { 635 | throw new Error(`Expected initializer returning { store, actions }, but got a invalid actions: ${actions}`) 636 | } 637 | 638 | return { store, actions } as ReturnType 639 | }, implementations) 640 | 641 | // ignore preload callbacks if preloadedState was received 642 | if (options.preloadedState !== undefined) { 643 | callbackManager.clearPreloadCallbackList() 644 | } 645 | 646 | const preload = async () => { 647 | let models = [] as AnyPureModel[] 648 | 649 | for (let model of upstreamModelSet) { 650 | models.push(model) 651 | } 652 | 653 | await Promise.all(models.map((model) => model.preload())) 654 | 655 | return callbackManager.preload() 656 | } 657 | 658 | const model = { 659 | ...result, 660 | initializer, 661 | preload, 662 | start: callbackManager.start, 663 | finish: callbackManager.finish, 664 | addPreloadCallback: callbackManager.addPreloadCallback, 665 | addStartCallback: callbackManager.addStartCallback, 666 | addFinishCallback: callbackManager.addFinishCallback, 667 | isPreloaded: callbackManager.isPreloaded, 668 | isStarted: callbackManager.isStarted, 669 | isFinished: callbackManager.isFinished, 670 | } 671 | 672 | if (selfContainerValue) { 673 | selfContainerValue.model = model 674 | } else { 675 | container.set(initializer, { 676 | model, 677 | }) 678 | } 679 | 680 | return model 681 | } 682 | 683 | export type Model = { 684 | store: Store 685 | actions: Actions 686 | } & Omit, 'clearPreloadCallbackList'> 687 | 688 | export function subscribe(model: Model, listener: (state: S) => void) { 689 | let unsubscribe = model.store.subscribe(() => { 690 | if (!model.isStarted()) { 691 | return 692 | } 693 | 694 | let state = model.store.getState() 695 | listener(state) 696 | }) 697 | 698 | return unsubscribe 699 | } 700 | 701 | export function select(options: { 702 | model: Model 703 | selector: (state: S) => TSelected 704 | listener: (state: TSelected) => void 705 | compare?: (curr: TSelected, prev: TSelected) => boolean 706 | }) { 707 | if (!isPlainObject(options)) { 708 | throw new Error( 709 | `Expected subscribe(options) received { store, listener, selector?, compare? }, instead of ${options}`, 710 | ) 711 | } 712 | 713 | let { model, selector, listener, compare = shallowEqual } = options 714 | 715 | let prevState = selector(model.store.getState()) 716 | 717 | let unsubscribe = model.store.subscribe(() => { 718 | if (!model.isStarted()) { 719 | return 720 | } 721 | 722 | let state = model.store.getState() 723 | let currState = selector(state) 724 | 725 | if (!compare(currState, prevState)) { 726 | prevState = currState 727 | listener(currState) 728 | } else { 729 | prevState = currState 730 | } 731 | }) 732 | 733 | return unsubscribe 734 | } 735 | 736 | type Stores = { 737 | [key: string]: Store 738 | } 739 | 740 | type StoreStateType = T extends Store ? S : never 741 | 742 | type CombinedState = { 743 | [key in keyof T]: StoreStateType 744 | } 745 | 746 | type CombinedStore = Store> 747 | 748 | export function combineStore(stores: T): CombinedStore { 749 | type State = CombinedState 750 | let initialState = {} as State 751 | 752 | for (let key in stores) { 753 | initialState[key] = stores[key].getState() 754 | } 755 | 756 | let { store, actions } = setupStore({ 757 | devtools: false, 758 | initialState, 759 | reducers: { 760 | update: (state: State, [key, value]: [keyof T, T[keyof T]]) => { 761 | return { 762 | ...state, 763 | [key]: value, 764 | } 765 | }, 766 | }, 767 | }) 768 | 769 | for (let key in stores) { 770 | stores[key].subscribe(() => { 771 | actions.update([key, stores[key].getState()]) 772 | }) 773 | } 774 | 775 | return store 776 | } 777 | -------------------------------------------------------------------------------- /packages/core/src/env.ts: -------------------------------------------------------------------------------- 1 | import { createModelContext, setupContext } from './core' 2 | import type { IncomingMessage, ServerResponse } from 'http' 3 | 4 | export const platforms = ['NodeJS', 'Browser', 'ReactNative'] as const 5 | 6 | export type Platform = typeof platforms[number] 7 | 8 | export type Env = { 9 | platform?: Platform 10 | fetch?: typeof fetch 11 | req?: IncomingMessage 12 | res?: ServerResponse 13 | } 14 | 15 | const getUserAgent = (env: Env) => { 16 | if (env.req) { 17 | return env.req.headers['user-agent'] ? env.req.headers['user-agent'] : '' 18 | // tslint:disable-next-line: strict-type-predicates 19 | } else if (typeof window !== 'undefined') { 20 | return window.navigator.userAgent 21 | } 22 | 23 | return '' 24 | } 25 | 26 | export const setupUserAgent = () => { 27 | let env = setupEnv() 28 | return getUserAgent(env) 29 | } 30 | 31 | export const getPlatform = (env: Env): Platform => { 32 | if (env.platform) { 33 | return env.platform 34 | } 35 | 36 | // tslint:disable-next-line: strict-type-predicates 37 | if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') { 38 | return 'ReactNative' 39 | } 40 | 41 | // tslint:disable-next-line: strict-type-predicates 42 | if (typeof window === 'undefined' || typeof window.document === 'undefined') { 43 | return 'NodeJS' 44 | } 45 | 46 | return 'Browser' 47 | } 48 | 49 | export const EnvContext = createModelContext({}) 50 | 51 | export const setupEnv = () => { 52 | let env = setupContext(EnvContext) 53 | return env 54 | } 55 | 56 | export const setupPlatform = (): Platform => { 57 | let env = setupContext(EnvContext) 58 | let platform = getPlatform(env) 59 | 60 | return platform 61 | } 62 | 63 | export type PlatformInfo = { 64 | [key in Platform]: boolean 65 | } 66 | 67 | export const setupPlatformInfo = (): PlatformInfo => { 68 | let platform = setupPlatform() 69 | 70 | let info = {} as PlatformInfo 71 | 72 | for (let i = 0; i < platforms.length; i++) { 73 | let name = platforms[i] 74 | info[name] = name === platform 75 | } 76 | 77 | return info 78 | } 79 | -------------------------------------------------------------------------------- /packages/core/src/http.ts: -------------------------------------------------------------------------------- 1 | import querystring from 'query-string' 2 | import fetch from '@pure-model/isomorphic-unfetch' 3 | import { setupEnv, setupPlatformInfo } from './env' 4 | 5 | function isAbsoluteUrl(url: string) { 6 | return url.startsWith('http') || url.startsWith('//') 7 | } 8 | 9 | export const setupFetch = () => { 10 | let env = setupEnv() 11 | let platformInfo = setupPlatformInfo() 12 | 13 | let $fetch = env.fetch ?? fetch 14 | 15 | let resolveOptions = (options?: RequestInit) => { 16 | let result: RequestInit = { 17 | method: 'GET', 18 | credentials: 'include', 19 | ...options, 20 | } 21 | 22 | if (env.req) { 23 | // @ts-ignore 24 | result.headers = { 25 | ...result.headers, 26 | cookie: env.req.headers.cookie || '', 27 | } 28 | } 29 | 30 | return result 31 | } 32 | 33 | let resolveUrl = (url: string) => { 34 | if (!isAbsoluteUrl(url) && !url.startsWith('/')) { 35 | url = '/' + url 36 | } 37 | 38 | if (url.startsWith('//')) { 39 | if (platformInfo.NodeJS) { 40 | url = 'http:' + url 41 | // tslint:disable-next-line: strict-type-predicates 42 | } else if (typeof location !== 'undefined' && typeof location.protocol === 'string') { 43 | url = location.protocol + url 44 | } else { 45 | url = 'https:' + url 46 | } 47 | } 48 | 49 | return url 50 | } 51 | 52 | let fetcher: typeof fetch = (url, options) => { 53 | if (typeof url === 'string') { 54 | url = resolveUrl(url) 55 | } 56 | 57 | options = resolveOptions(options) 58 | 59 | return $fetch(url, options) 60 | } 61 | 62 | return fetcher 63 | } 64 | 65 | export const setupGetJSON = () => { 66 | let fetch = setupFetch() 67 | 68 | let getJSON = async (url: string, params?: object, options?: RequestInit) => { 69 | if (params) { 70 | let prefix = url.includes('?') ? '&' : '?' 71 | url += prefix + querystring.stringify(params) 72 | } 73 | 74 | let response = await fetch(url, { 75 | ...options, 76 | method: 'GET', 77 | }) 78 | 79 | let text = await response.text() 80 | 81 | return JSON.parse(text) 82 | } 83 | 84 | return getJSON 85 | } 86 | 87 | export const setupPostJSON = () => { 88 | let fetch = setupFetch() 89 | 90 | let postJSON = async (url: string, data?: object, options?: RequestInit) => { 91 | let response = await fetch(url, { 92 | ...options, 93 | method: 'POST', 94 | body: data ? JSON.stringify(data) : null, 95 | headers: { 96 | ...(options && options.headers), 97 | 'Content-Type': 'application/json', 98 | }, 99 | }) 100 | 101 | let text = await response.text() 102 | 103 | return JSON.parse(text) 104 | } 105 | 106 | return postJSON 107 | } 108 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core' 2 | 3 | export * from './env' 4 | 5 | export * from './http' 6 | -------------------------------------------------------------------------------- /packages/core/src/util.ts: -------------------------------------------------------------------------------- 1 | export const identity = (x: X): X => x 2 | 3 | export const isPlainObject = (obj: any) => { 4 | if (typeof obj !== 'object' || obj === null) return false 5 | 6 | let proto = obj 7 | while (Object.getPrototypeOf(proto) !== null) { 8 | proto = Object.getPrototypeOf(proto) 9 | } 10 | 11 | return Object.getPrototypeOf(obj) === proto 12 | } 13 | 14 | export const shallowEqual = (objA: any, objB: any) => { 15 | if (objA === objB) { 16 | return true 17 | } 18 | 19 | if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { 20 | return false 21 | } 22 | 23 | let keysA = Object.keys(objA) 24 | let keysB = Object.keys(objB) 25 | 26 | if (keysA.length !== keysB.length) { 27 | return false 28 | } 29 | 30 | // Test for A's keys different from B. 31 | for (let i = 0; i < keysA.length; i++) { 32 | if (!objB.hasOwnProperty(keysA[i]) || objA[keysA[i]] !== objB[keysA[i]]) { 33 | return false 34 | } 35 | } 36 | 37 | return true 38 | } 39 | 40 | export const isThenable = (input: any): boolean => !!(input && typeof input.then === 'function') 41 | 42 | export const forcePlainDataCheck = (input: any, path = ''): void => { 43 | if (input === null) return 44 | 45 | let type = typeof input 46 | 47 | if (type === 'function') { 48 | throw new Error(`Expected plain data, but found a function in ${path}: ${input}`) 49 | } 50 | 51 | if (Array.isArray(input)) { 52 | for (let i = 0; i < input.length; i++) { 53 | forcePlainDataCheck(input[i], `${path}[${i}]`) 54 | } 55 | return 56 | } 57 | 58 | if (type === 'object') { 59 | if (!isPlainObject(input)) { 60 | throw new Error(`Expected plain object, but found an instance of ${input.constructor} in path ${path}: ${input}`) 61 | } 62 | for (let key in input) { 63 | forcePlainDataCheck(input[key], `${path}.${key}`) 64 | } 65 | return 66 | } 67 | } 68 | 69 | const wrapFunctionForChecking = (f: (...args: any) => any) => (...args: any) => { 70 | forcePlainDataCheck(args) 71 | return f(...args) 72 | } 73 | 74 | export const forceCheckValue = (value: any): any => { 75 | if (typeof value === 'function') { 76 | return wrapFunctionForChecking(value) 77 | } 78 | 79 | if (Array.isArray(value)) { 80 | return forceCheckArray(value) 81 | } 82 | 83 | if (isPlainObject(value)) { 84 | return forceCheckObject(value) 85 | } 86 | 87 | return value 88 | } 89 | 90 | const forceCheckObject = (object: T): T => { 91 | let result = {} as any 92 | for (let key in object) { 93 | result[key] = forceCheckValue(object[key]) 94 | } 95 | return result as T 96 | } 97 | 98 | const forceCheckArray = (array: T): T => { 99 | let result = [] as any 100 | for (let i = 0; i < array.length; i++) { 101 | result[i] = forceCheckValue(array[i]) 102 | } 103 | return result as T 104 | } 105 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./esm", 6 | "composite": true 7 | }, 8 | "references": [], 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/core/tsconfig.next.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2018", 5 | "outDir": "./next" 6 | }, 7 | "references": [], 8 | "include": ["src"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/hooks/README.md: -------------------------------------------------------------------------------- 1 | # @pure-model/hooks 2 | -------------------------------------------------------------------------------- /packages/hooks/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/hooks", 3 | "version": "1.3.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "tslib": { 8 | "version": "2.1.0", 9 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", 10 | "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/hooks", 3 | "version": "1.3.1", 4 | "description": "Some useful hooks for pure-model", 5 | "main": "dist/index.js", 6 | "esnext": "next/index.js", 7 | "types": "next/index.d.ts", 8 | "author": "Jade Gu", 9 | "license": "MIT", 10 | "peerDependencies": { 11 | "@pure-model/core": "^1.0.0" 12 | }, 13 | "devDependencies": { 14 | "@pure-model/core": "^1.3.1" 15 | }, 16 | "dependencies": { 17 | "tslib": "^2.0.3" 18 | }, 19 | "gitHead": "46f33fbc6a53f32851d35963ddd764c6140a6c99" 20 | } 21 | -------------------------------------------------------------------------------- /packages/hooks/src/__tests__/hooks.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest' 2 | import { createPureModel, setupStore } from '@pure-model/core' 3 | import { setupCancel, setupSequence, setupInterval } from '../' 4 | 5 | let setupCounter = (n = 0) => { 6 | let { store, actions } = setupStore({ 7 | initialState: n, 8 | reducers: { 9 | incre: (state: number) => state + 1, 10 | decre: (state: number) => state - 1, 11 | increBy: (state: number, step: number = 1) => state + step, 12 | }, 13 | }) 14 | return { 15 | store, 16 | actions, 17 | } 18 | } 19 | 20 | const createDeferred = () => { 21 | let resolve 22 | let reject 23 | let promise = new Promise((a, b) => { 24 | resolve = a 25 | reject = b 26 | }) 27 | return { resolve, reject, promise } 28 | } 29 | 30 | let delay = (duration: number) => 31 | new Promise((resolve) => { 32 | setTimeout(resolve, duration) 33 | }) 34 | 35 | describe('preset', () => { 36 | let deferred: any 37 | 38 | beforeEach(() => { 39 | deferred = createDeferred() 40 | }) 41 | 42 | afterEach(() => { 43 | deferred = null 44 | }) 45 | 46 | it('should support setupCancel', async () => { 47 | let info = { 48 | data: 0, 49 | error: 0, 50 | cancel: 0, 51 | start: 0, 52 | finish: 0, 53 | } 54 | 55 | let model = createPureModel(() => { 56 | let { store, actions } = setupCounter() 57 | 58 | let task = async (n: number) => { 59 | await deferred.promise 60 | if (n === 0) { 61 | throw new Error('n is zero') 62 | } 63 | return 1 + n 64 | } 65 | 66 | let { start, cancel } = setupCancel(task, { 67 | onData: (n) => { 68 | info.data += 1 69 | actions.increBy(n) 70 | }, 71 | onError: (error) => { 72 | expect(error.message).toBe('n is zero') 73 | info.error += 1 74 | }, 75 | onCancel: () => { 76 | info.cancel += 1 77 | }, 78 | onStart: () => { 79 | info.start += 1 80 | }, 81 | onFinish: () => { 82 | info.finish += 1 83 | }, 84 | }) 85 | 86 | return { 87 | store, 88 | actions: { 89 | ...actions, 90 | start, 91 | cancel, 92 | }, 93 | } 94 | }) 95 | 96 | expect(model.store.getState()).toEqual(0) 97 | 98 | expect(info).toEqual({ 99 | data: 0, 100 | error: 0, 101 | cancel: 0, 102 | start: 0, 103 | finish: 0, 104 | }) 105 | 106 | model.actions.start(1) 107 | 108 | expect(info).toEqual({ 109 | data: 0, 110 | error: 0, 111 | cancel: 0, 112 | start: 1, 113 | finish: 0, 114 | }) 115 | 116 | model.actions.cancel() 117 | 118 | deferred.resolve() 119 | 120 | await delay(1) 121 | 122 | // cancel() includes finish() 123 | expect(info).toEqual({ 124 | data: 0, 125 | error: 0, 126 | cancel: 1, 127 | start: 1, 128 | finish: 1, 129 | }) 130 | 131 | expect(model.store.getState()).toEqual(0) 132 | 133 | deferred = createDeferred() 134 | 135 | model.actions.start(1) 136 | 137 | expect(info).toEqual({ 138 | data: 0, 139 | error: 0, 140 | cancel: 1, 141 | start: 2, 142 | finish: 1, 143 | }) 144 | 145 | expect(model.store.getState()).toEqual(0) 146 | 147 | // call start before previous will cause calling cancel() 148 | model.actions.start(2) 149 | 150 | expect(info).toEqual({ 151 | data: 0, 152 | error: 0, 153 | cancel: 2, 154 | start: 3, 155 | finish: 2, 156 | }) 157 | 158 | deferred.resolve() 159 | 160 | await delay(1) 161 | 162 | expect(model.store.getState()).toEqual(3) 163 | 164 | // only trigger data from last call if calling start multiple time 165 | expect(info).toEqual({ 166 | data: 1, 167 | error: 0, 168 | cancel: 2, 169 | start: 3, 170 | finish: 3, 171 | }) 172 | 173 | deferred = createDeferred() 174 | 175 | model.actions.start(0) 176 | 177 | deferred.resolve() 178 | 179 | await delay(1) 180 | 181 | expect(info).toEqual({ 182 | data: 1, 183 | error: 1, 184 | cancel: 2, 185 | start: 4, 186 | finish: 4, 187 | }) 188 | }) 189 | 190 | it('should support setupSequence', async () => { 191 | let list = [createDeferred(), createDeferred(), createDeferred()] 192 | 193 | let info = { 194 | data: 0, 195 | error: 0, 196 | } 197 | 198 | let model = createPureModel(() => { 199 | let { store, actions } = setupCounter() 200 | 201 | let sequenceIncreBy = setupSequence( 202 | async (index: number, step: number) => { 203 | if (index >= list.length) { 204 | throw new Error(`index is out of range`) 205 | } 206 | await list[index].promise 207 | return step 208 | }, 209 | { 210 | onData: (step) => { 211 | info.data += 1 212 | actions.increBy(step) 213 | }, 214 | onError: (error) => { 215 | info.error += 1 216 | expect(error.message).toEqual(`index is out of range`) 217 | }, 218 | }, 219 | ) 220 | 221 | return { 222 | store, 223 | actions: { 224 | ...actions, 225 | sequenceIncreBy, 226 | }, 227 | } 228 | }) 229 | 230 | expect(model.store.getState()).toEqual(0) 231 | 232 | let result0 = model.actions.sequenceIncreBy(0, 1) 233 | let result1 = model.actions.sequenceIncreBy(1, 2) 234 | let result2 = model.actions.sequenceIncreBy(2, 3) 235 | 236 | expect(model.store.getState()).toEqual(0) 237 | expect(info).toEqual({ 238 | data: 0, 239 | error: 0, 240 | }) 241 | 242 | // @ts-ignore 243 | list[2].resolve() 244 | 245 | await delay(1) 246 | 247 | expect(model.store.getState()).toEqual(0) 248 | expect(info).toEqual({ 249 | data: 0, 250 | error: 0, 251 | }) 252 | 253 | // @ts-ignore 254 | list[0].resolve() 255 | 256 | await delay(1) 257 | 258 | expect(info).toEqual({ 259 | data: 1, 260 | error: 0, 261 | }) 262 | expect(model.store.getState()).toEqual(1) 263 | 264 | // @ts-ignore 265 | list[1].resolve() 266 | await delay(1) 267 | 268 | expect(model.store.getState()).toEqual(6) 269 | expect(info).toEqual({ 270 | data: 3, 271 | error: 0, 272 | }) 273 | 274 | // tslint:disable-next-line: no-floating-promises 275 | model.actions.sequenceIncreBy(3, 4) 276 | 277 | await delay(1) 278 | 279 | expect(model.store.getState()).toEqual(6) 280 | expect(info).toEqual({ 281 | data: 3, 282 | error: 1, 283 | }) 284 | 285 | expect(await result0).toEqual(1) 286 | expect(await result1).toEqual(2) 287 | expect(await result2).toEqual(3) 288 | }) 289 | 290 | // it('should support setupInterval', async () => { 291 | // let list: number[] = [] 292 | // let info = { 293 | // data: 0, 294 | // start: 0, 295 | // stop: 0, 296 | // reset: 0, 297 | // } 298 | // let model = createPureModel(() => { 299 | // let { store, actions } = setupCounter() 300 | 301 | // let { start, stop, reset } = setupInterval({ 302 | // onData: (n) => { 303 | // info.data += 1 304 | // list.push(n) 305 | // }, 306 | // onStart: () => { 307 | // info.start += 1 308 | // }, 309 | // onStop: () => { 310 | // info.stop += 1 311 | // }, 312 | // onReset: () => { 313 | // info.reset += 1 314 | // }, 315 | // }) 316 | 317 | // return { 318 | // store, 319 | // actions: { 320 | // ...actions, 321 | // start, 322 | // stop, 323 | // reset, 324 | // }, 325 | // } 326 | // }) 327 | 328 | // expect(info).toEqual({ 329 | // data: 0, 330 | // start: 0, 331 | // stop: 0, 332 | // reset: 0, 333 | // }) 334 | 335 | // model.actions.start(10) 336 | 337 | // expect(info).toEqual({ 338 | // data: 0, 339 | // start: 1, 340 | // stop: 0, 341 | // reset: 0, 342 | // }) 343 | 344 | // expect(list).toEqual([]) 345 | 346 | // await delay(112) 347 | 348 | // model.actions.start(1) 349 | 350 | // expect(info).toEqual({ 351 | // data: 10, 352 | // start: 1, 353 | // stop: 0, 354 | // reset: 0, 355 | // }) 356 | 357 | // expect(list).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 358 | 359 | // model.actions.stop() 360 | 361 | // expect(info).toEqual({ 362 | // data: 10, 363 | // start: 1, 364 | // stop: 1, 365 | // reset: 0, 366 | // }) 367 | 368 | // await delay(100) 369 | 370 | // expect(list).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 371 | 372 | // expect(info).toEqual({ 373 | // data: 10, 374 | // start: 1, 375 | // stop: 1, 376 | // reset: 0, 377 | // }) 378 | 379 | // model.actions.start(10) 380 | 381 | // expect(info).toEqual({ 382 | // data: 10, 383 | // start: 2, 384 | // stop: 1, 385 | // reset: 0, 386 | // }) 387 | 388 | // expect(list).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 389 | 390 | // model.actions.reset() 391 | 392 | // expect(info).toEqual({ 393 | // data: 10, 394 | // start: 2, 395 | // stop: 1, 396 | // reset: 1, 397 | // }) 398 | 399 | // expect(list).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 400 | 401 | // await delay(112) 402 | 403 | // model.actions.stop() 404 | 405 | // expect(info).toEqual({ 406 | // data: 20, 407 | // start: 2, 408 | // stop: 2, 409 | // reset: 1, 410 | // }) 411 | 412 | // expect(list).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 413 | // }) 414 | }) 415 | -------------------------------------------------------------------------------- /packages/hooks/src/index.ts: -------------------------------------------------------------------------------- 1 | type AsyncFunction = (...args: any) => Promise 2 | type ArgsType any> = T extends (...args: infer A) => any ? A : never 3 | 4 | type PromiseValueType> = T extends Promise ? V : never 5 | 6 | type CancelCallbacks = { 7 | onData: (data: PromiseValueType>) => any 8 | onError: (error: Error) => any 9 | onCancel: () => any 10 | onStart: () => any 11 | onFinish: () => any 12 | } 13 | 14 | // tslint:disable-next-line: no-empty 15 | const noop = () => {} 16 | 17 | const defaultCancelCallbacks: CancelCallbacks = { 18 | onData: noop, 19 | onError: noop, 20 | onCancel: noop, 21 | onStart: noop, 22 | onFinish: noop, 23 | } 24 | 25 | export const setupCancel = (task: T, options: Partial> = {}) => { 26 | let consumers = Object.assign({}, defaultCancelCallbacks, options) 27 | 28 | let uid = 0 29 | 30 | let isStart = false 31 | 32 | let start = (...args: ArgsType) => { 33 | if (isStart) { 34 | cancel() 35 | } 36 | 37 | let current = ++uid 38 | 39 | let handleStart = () => { 40 | isStart = true 41 | consumers.onStart() 42 | } 43 | 44 | let handleFinish = () => { 45 | if (isStart && current === uid) { 46 | isStart = false 47 | consumers.onFinish() 48 | } 49 | } 50 | 51 | let handleData = (data: PromiseValueType>) => { 52 | if (isStart && current === uid) { 53 | consumers.onData(data) 54 | } 55 | } 56 | 57 | let handleError = (error: Error) => { 58 | if (isStart && current === uid) { 59 | consumers.onError(error) 60 | } 61 | } 62 | 63 | let result: any 64 | let hasError = false 65 | 66 | handleStart() 67 | 68 | try { 69 | result = task(...(args as any)) 70 | } catch (error: unknown) { 71 | hasError = true 72 | handleError(error instanceof Error ? error : new Error('Unknown error')) 73 | } 74 | 75 | if (hasError) { 76 | handleFinish() 77 | } else { 78 | if (result instanceof Promise) { 79 | result.then(handleData, handleError) 80 | result.then(handleFinish, handleFinish) 81 | } else { 82 | throw new Error(`Expected task returning promise, instead of ${result}`) 83 | } 84 | } 85 | } 86 | 87 | let cancel = () => { 88 | if (isStart) { 89 | isStart = false 90 | consumers.onFinish() 91 | consumers.onCancel() 92 | } 93 | } 94 | 95 | return { 96 | start, 97 | cancel, 98 | } 99 | } 100 | 101 | type SequenceCallbacks = { 102 | onData: (data: PromiseValueType>) => any 103 | onError: (error: Error) => any 104 | } 105 | 106 | const defaultSequenceCallbacks: SequenceCallbacks = { 107 | onData: noop, 108 | onError: noop, 109 | } 110 | 111 | export const setupSequence = (task: T, options: Partial> = {}) => { 112 | type Data = PromiseValueType> 113 | type Item = 114 | | { 115 | kind: 'data' 116 | data: Data 117 | } 118 | | { 119 | kind: 'error' 120 | error: Error 121 | } 122 | | null 123 | 124 | let finalOptions = Object.assign({}, defaultSequenceCallbacks, options) 125 | 126 | let uid = 0 127 | let items: Item[] = [] 128 | 129 | let consume = (index: number) => { 130 | let item = items[index] 131 | 132 | if (item) { 133 | items[index] = null 134 | 135 | if (item.kind === 'data') { 136 | finalOptions.onData(item.data) 137 | } 138 | 139 | if (item.kind === 'error') { 140 | finalOptions.onError(item.error) 141 | } 142 | return true 143 | } else { 144 | return false 145 | } 146 | } 147 | 148 | let trigger = (index: number) => { 149 | let count = index 150 | 151 | while (count) { 152 | let item = items[count] 153 | // tslint:disable-next-line: strict-type-predicates 154 | if (item === undefined) { 155 | return 156 | } 157 | count -= 1 158 | } 159 | 160 | // consume previous 161 | for (let i = 0; i <= index; i++) { 162 | consume(i) 163 | } 164 | 165 | // consume next 166 | let offset = 1 167 | 168 | while (consume(index + offset)) { 169 | offset += 1 170 | } 171 | } 172 | 173 | let start = (...args: ArgsType): ReturnType => { 174 | let index = uid++ 175 | let handleData = (data: Data) => { 176 | items[index] = { 177 | kind: 'data', 178 | data, 179 | } 180 | trigger(index) 181 | } 182 | let handleError = (error: Error) => { 183 | items[index] = { 184 | kind: 'error', 185 | error, 186 | } 187 | trigger(index) 188 | } 189 | 190 | let result = task(...(args as any)) 191 | 192 | if (result instanceof Promise) { 193 | result.then(handleData, handleError) 194 | } else { 195 | throw new Error(`Expected task returning promise, instead of ${result}`) 196 | } 197 | 198 | return result as ReturnType 199 | } 200 | 201 | return start 202 | } 203 | 204 | type IntervalCallbacks = { 205 | onData: (data: number) => any 206 | onStart: () => any 207 | onStop: () => any 208 | onReset: () => any 209 | } 210 | 211 | const defaultIntervalCallbacks: IntervalCallbacks = { 212 | onData: noop, 213 | onStart: noop, 214 | onStop: noop, 215 | onReset: noop, 216 | } 217 | 218 | export const setupInterval = (options: Partial = {}) => { 219 | let finalOptions = Object.assign({}, defaultIntervalCallbacks, options) 220 | 221 | let count = 0 222 | let isStart = false 223 | let tid: any 224 | 225 | let start = (period: number = 0) => { 226 | if (period === 0) { 227 | stop() 228 | return 229 | } 230 | 231 | if (isStart) { 232 | clearInterval(tid) 233 | } else { 234 | finalOptions.onStart() 235 | } 236 | 237 | isStart = true 238 | tid = setInterval(() => { 239 | finalOptions.onData(count++) 240 | }, period) 241 | } 242 | 243 | let stop = () => { 244 | if (!isStart) return 245 | isStart = false 246 | clearInterval(tid) 247 | finalOptions.onStop() 248 | } 249 | 250 | let reset = () => { 251 | count = 0 252 | finalOptions.onReset() 253 | } 254 | 255 | return { 256 | start, 257 | stop, 258 | reset, 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /packages/hooks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./esm", 6 | "composite": true 7 | }, 8 | "references": [ 9 | { 10 | "path": "../core" 11 | } 12 | ], 13 | "include": ["./src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/hooks/tsconfig.next.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2018", 5 | "outDir": "./next" 6 | }, 7 | "references": [ 8 | { 9 | "path": "../core/tsconfig.next.json" 10 | } 11 | ], 12 | "include": ["./src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/immer/README.md: -------------------------------------------------------------------------------- 1 | # @pure-model/immer 2 | -------------------------------------------------------------------------------- /packages/immer/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/immer", 3 | "version": "1.2.15", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "immer": { 8 | "version": "9.0.1", 9 | "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.1.tgz", 10 | "integrity": "sha512-7CCw1DSgr8kKYXTYOI1qMM/f5qxT5vIVMeGLDCDX8CSxsggr1Sjdoha4OhsP0AZ1UvWbyZlILHvLjaynuu02Mg==", 11 | "dev": true 12 | }, 13 | "tslib": { 14 | "version": "2.1.0", 15 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", 16 | "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/immer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/immer", 3 | "version": "1.2.15", 4 | "description": "Immer adapter for pure-model", 5 | "main": "dist/index.js", 6 | "esnext": "next/index.js", 7 | "types": "next/index.d.ts", 8 | "author": "Jade Gu", 9 | "license": "MIT", 10 | "peerDependencies": { 11 | "immer": "^9.0.0" 12 | }, 13 | "devDependencies": { 14 | "immer": "^9.0.0" 15 | }, 16 | "dependencies": { 17 | "tslib": "^2.0.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/immer/src/__tests__/immer.test.ts: -------------------------------------------------------------------------------- 1 | import { createPureModel, setupStore, select } from '@pure-model/core' 2 | 3 | import { Draft, toReducer, toReducers } from '../index' 4 | 5 | let setupCounter = (n = 0) => { 6 | let { store, actions } = setupStore({ 7 | initialState: n, 8 | reducers: { 9 | incre: (state: number) => state + 1, 10 | decre: (state: number) => state - 1, 11 | increBy: (state: number, step: number = 1) => state + step, 12 | }, 13 | }) 14 | return { 15 | store, 16 | actions, 17 | } 18 | } 19 | 20 | describe('immer', () => { 21 | it('should support immer reducer to normal reducer', () => { 22 | type State = { count: number } 23 | 24 | let immerReducer0 = (state: Draft) => { 25 | state.count += 1 26 | } 27 | 28 | let immerReducer1 = (state: Draft, step: number) => { 29 | state.count += step 30 | } 31 | 32 | let reducer0 = toReducer(immerReducer0) 33 | let reducer1 = toReducer(immerReducer1) 34 | 35 | let reducers = toReducers({ 36 | incre: immerReducer0, 37 | increBy: immerReducer1, 38 | }) 39 | 40 | let state0: State = { 41 | count: 0, 42 | } 43 | 44 | let state1 = reducer0(state0) 45 | let state2 = reducer1(state1, 2) 46 | let state3 = reducers.incre(state2) 47 | let state4 = reducers.increBy(state3, -2) 48 | 49 | expect(state0).toEqual({ 50 | count: 0, 51 | }) 52 | 53 | expect(state1).toEqual({ 54 | count: 1, 55 | }) 56 | 57 | expect(state2).toEqual({ 58 | count: 3, 59 | }) 60 | 61 | expect(state3).toEqual({ 62 | count: 4, 63 | }) 64 | 65 | expect(state4).toEqual({ 66 | count: 2, 67 | }) 68 | }) 69 | 70 | it('should support using for setupStore', async () => { 71 | type State = { 72 | count: number 73 | } 74 | 75 | let initialState: State = { 76 | count: 10, 77 | } 78 | 79 | let model = createPureModel(() => { 80 | let reducers = toReducers({ 81 | incre: (state: Draft) => { 82 | state.count++ 83 | }, 84 | increBy: (state: Draft, step: number = 1) => { 85 | state.count += step 86 | }, 87 | }) 88 | 89 | let store = setupStore({ 90 | initialState, 91 | reducers, 92 | }) 93 | 94 | return store 95 | }) 96 | 97 | await model.preload() 98 | 99 | model.start() 100 | 101 | expect(model.store.getState()).toEqual({ 102 | count: 10, 103 | }) 104 | 105 | model.actions.incre() 106 | 107 | expect(model.store.getState()).toEqual({ 108 | count: 11, 109 | }) 110 | 111 | model.actions.increBy(-10) 112 | 113 | expect(model.store.getState()).toEqual({ 114 | count: 1, 115 | }) 116 | 117 | model.actions.increBy() 118 | 119 | expect(model.store.getState()).toEqual({ 120 | count: 2, 121 | }) 122 | }) 123 | 124 | it('should forbidden non-plain-data in redux store in non-production env', async () => { 125 | expect(() => { 126 | createPureModel(() => { 127 | return setupStore({ 128 | initialState: { 129 | a() { 130 | return 1 131 | }, 132 | b: new MouseEvent('click'), 133 | }, 134 | reducers: {}, 135 | }) 136 | }) 137 | }).toThrow(/^Expected plain (data|object)/i) 138 | 139 | let model = createPureModel(() => { 140 | return setupStore({ 141 | initialState: {}, 142 | reducers: { 143 | update: () => { 144 | return { 145 | a() { 146 | return 1 147 | }, 148 | b: new MouseEvent('click'), 149 | } 150 | }, 151 | }, 152 | }) 153 | }) 154 | 155 | expect(() => { 156 | model.actions.update() 157 | }).toThrow(/^Expected plain (data|object)/i) 158 | }) 159 | 160 | it('should forbidden non-plain-data pass to action in non-production env', async () => { 161 | let model = createPureModel(setupCounter) 162 | 163 | expect((process as any).env.NODE_ENV !== 'production').toBe(true) 164 | 165 | expect(() => { 166 | model.actions.increBy(new Error('test') as any) 167 | }).toThrow(/^Expected plain (data|object)/i) 168 | 169 | expect(() => { 170 | model.actions.increBy((() => 0) as any) 171 | }).toThrow(/^Expected plain (data|object)/i) 172 | 173 | expect(() => { 174 | model.actions.increBy(new MouseEvent('click') as any) 175 | }).toThrow(/^Expected plain (data|object)/i) 176 | 177 | expect(() => { 178 | model.actions.increBy(new Set() as any) 179 | }).toThrow(/^Expected plain (data|object)/i) 180 | 181 | expect(() => { 182 | model.actions.increBy(new Map() as any) 183 | }).toThrow(/^Expected plain (data|object)/i) 184 | 185 | expect(() => { 186 | class Test {} 187 | model.actions.increBy(new Test() as any) 188 | }).toThrow(/^Expected plain (data|object)/i) 189 | }) 190 | 191 | it('support subscribe model via select function', async () => { 192 | type State = { 193 | a: number 194 | b: number 195 | } 196 | let model = createPureModel(() => { 197 | let initialState: State = { 198 | a: 0, 199 | b: 1, 200 | } 201 | 202 | let increA = (state: State) => { 203 | return { 204 | ...state, 205 | a: state.a + 1, 206 | } 207 | } 208 | let increB = (state: State) => { 209 | return { 210 | ...state, 211 | b: state.b + 1, 212 | } 213 | } 214 | 215 | let swap = (state: State) => { 216 | return { 217 | ...state, 218 | a: state.b, 219 | b: state.a, 220 | } 221 | } 222 | 223 | let { store, actions } = setupStore({ 224 | initialState, 225 | reducers: { 226 | increA, 227 | increB, 228 | swap, 229 | }, 230 | }) 231 | 232 | return { store, actions } 233 | }) 234 | 235 | let list: number[] = [] 236 | 237 | select({ 238 | model, 239 | selector: (state: State) => state.a + state.b, 240 | listener: (value) => { 241 | list.push(value) 242 | }, 243 | }) 244 | 245 | await model.preload() 246 | 247 | model.start() 248 | 249 | model.actions.increA() 250 | model.actions.swap() 251 | model.actions.increB() 252 | 253 | expect(list).toEqual([2, 3]) 254 | }) 255 | }) 256 | -------------------------------------------------------------------------------- /packages/immer/src/index.ts: -------------------------------------------------------------------------------- 1 | import produce, { Draft, enableES5 } from 'immer' 2 | import type { WritableDraft } from 'immer/dist/types/types-external' 3 | 4 | if (typeof Proxy !== 'function') enableES5() 5 | 6 | type Tail = ((...t: T) => any) extends (_: any, ...tail: infer TT) => any ? TT : [] 7 | 8 | export type { Draft } 9 | 10 | export type ImmerReducerWithoutPayload = (state: WritableDraft) => Return 11 | 12 | export type ImmerReducerWithPayload = (state: WritableDraft, payload: P) => Return 13 | 14 | export type ImmerReducerWithOptionalPayload = ( 15 | state: WritableDraft, 16 | payload?: P, 17 | ) => Return 18 | 19 | export type ImmerReducer = 20 | | ImmerReducerWithoutPayload 21 | | ImmerReducerWithPayload 22 | | ImmerReducerWithOptionalPayload 23 | 24 | export type ImmerReducerStateType = R extends ImmerReducer> ? S : never 25 | 26 | export type ImmerReducerArgs = R extends (...args: infer Args) => any ? Args : never 27 | 28 | export type ImmerReducers = { 29 | [key: string]: ImmerReducer 30 | } 31 | 32 | export type ImmerReducerToReducer = ( 33 | state: ImmerReducerStateType, 34 | ...args: Tail> 35 | ) => ImmerReducerStateType 36 | 37 | export type ImmerReducersToReducers = { 38 | [key in keyof IRS]: ImmerReducerToReducer 39 | } 40 | 41 | export const toReducer = (immerReducer: IR) => { 42 | let reducer = (((state: any, action: any) => { 43 | return produce(state, (draft: any) => immerReducer(draft, action)) 44 | }) as unknown) as ImmerReducerToReducer 45 | 46 | return reducer 47 | } 48 | 49 | export const toReducers = (immerReducers: IRS) => { 50 | let reducers = {} as ImmerReducersToReducers 51 | 52 | for (let key in immerReducers) { 53 | reducers[key] = toReducer(immerReducers[key]) 54 | } 55 | 56 | return reducers 57 | } 58 | -------------------------------------------------------------------------------- /packages/immer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./esm", 6 | "composite": true 7 | }, 8 | "references": [ 9 | { 10 | "path": "../core" 11 | } 12 | ], 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/immer/tsconfig.next.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2018", 5 | "outDir": "./next" 6 | }, 7 | "references": [ 8 | { 9 | "path": "../core/tsconfig.next.json" 10 | } 11 | ], 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/isomorphic-unfetch/browser.js: -------------------------------------------------------------------------------- 1 | const fetch = require('unfetch').default || require('unfetch') 2 | 3 | if (typeof window !== 'undefined' && !window.fetch) { 4 | window.fetch = fetch; 5 | } 6 | 7 | module.exports = fetch 8 | -------------------------------------------------------------------------------- /packages/isomorphic-unfetch/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body as NodeBody, 3 | Headers as NodeHeaders, 4 | Request as NodeRequest, 5 | Response as NodeResponse, 6 | RequestInit as NodeRequestInit, 7 | } from 'node-fetch' 8 | 9 | declare namespace unfetch { 10 | export type IsomorphicHeaders = Headers | NodeHeaders 11 | export type IsomorphicBody = Body | NodeBody 12 | export type IsomorphicResponse = Response | NodeResponse 13 | export type IsomorphicRequest = Request | NodeRequest 14 | export type IsomorphicRequestInit = RequestInit | NodeRequestInit 15 | } 16 | 17 | declare const unfetch: typeof fetch 18 | 19 | export default unfetch 20 | -------------------------------------------------------------------------------- /packages/isomorphic-unfetch/index.js: -------------------------------------------------------------------------------- 1 | function r(m) { 2 | return (m && m.default) || m 3 | } 4 | module.exports = global.fetch = 5 | global.fetch || 6 | (typeof process == 'undefined' 7 | ? r(require('unfetch')) 8 | : function (url, opts) { 9 | return r(require('node-fetch'))(String(url).replace(/^\/\//g, 'https://'), opts) 10 | }) 11 | -------------------------------------------------------------------------------- /packages/isomorphic-unfetch/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/isomorphic-unfetch", 3 | "version": "1.2.15", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "node-fetch": { 8 | "version": "2.6.7", 9 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", 10 | "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", 11 | "requires": { 12 | "whatwg-url": "^5.0.0" 13 | } 14 | }, 15 | "tr46": { 16 | "version": "0.0.3", 17 | "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 18 | "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" 19 | }, 20 | "unfetch": { 21 | "version": "4.2.0", 22 | "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", 23 | "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==" 24 | }, 25 | "webidl-conversions": { 26 | "version": "3.0.1", 27 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 28 | "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" 29 | }, 30 | "whatwg-url": { 31 | "version": "5.0.0", 32 | "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 33 | "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 34 | "requires": { 35 | "tr46": "~0.0.3", 36 | "webidl-conversions": "^3.0.0" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/isomorphic-unfetch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/isomorphic-unfetch", 3 | "version": "1.2.16", 4 | "description": "Switches between unfetch & node-fetch for client & server.", 5 | "files": ["index.js", "index.d.ts", "browser.js"], 6 | "license": "MIT", 7 | "browser": "browser.js", 8 | "main": "index.js", 9 | "types": "index.d.ts", 10 | "dependencies": { 11 | "node-fetch": "^2.6.1", 12 | "unfetch": "^4.2.0" 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "gitHead": "46f33fbc6a53f32851d35963ddd764c6140a6c99" 18 | } 19 | -------------------------------------------------------------------------------- /packages/isomorphic-unfetch/readme.md: -------------------------------------------------------------------------------- 1 | # Isomorphic Unfetch 2 | 3 | **fix: use window instead of self in browser.js** 4 | 5 | Switches between [unfetch](https://github.com/developit/unfetch) & [node-fetch](https://github.com/bitinn/node-fetch) for client & server. 6 | 7 | ## Install 8 | 9 | This project uses [node](http://nodejs.org) and [npm](https://npmjs.com). Go check them out if you don't have them locally installed. 10 | 11 | ```sh 12 | $ npm i isomorphic-unfetch 13 | ``` 14 | 15 | Then with a module bundler like [rollup](http://rollupjs.org/) or [webpack](https://webpack.js.org/), use as you would anything else: 16 | 17 | ```javascript 18 | // using ES6 modules 19 | import fetch from 'isomorphic-unfetch' 20 | 21 | // using CommonJS modules 22 | const fetch = require('isomorphic-unfetch') 23 | ``` 24 | 25 | ## Usage 26 | 27 | As a [**ponyfill**](https://ponyfill.com): 28 | 29 | ```js 30 | import fetch from 'isomorphic-unfetch' 31 | 32 | fetch('/foo.json') 33 | .then((r) => r.json()) 34 | .then((data) => { 35 | console.log(data) 36 | }) 37 | ``` 38 | 39 | Globally, as a [**polyfill**](https://ponyfill.com/#polyfill): 40 | 41 | ```js 42 | import 'isomorphic-unfetch' 43 | 44 | // "fetch" is now installed globally if it wasn't already available 45 | 46 | fetch('/foo.json') 47 | .then((r) => r.json()) 48 | .then((data) => { 49 | console.log(data) 50 | }) 51 | ``` 52 | 53 | ## License 54 | 55 | [MIT License](LICENSE.md) © [Jason Miller](https://jasonformat.com/) 56 | -------------------------------------------------------------------------------- /packages/next-ts-example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /packages/next-ts-example/README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Next.js example 2 | 3 | This is a really simple project that shows the usage of Next.js with TypeScript. 4 | 5 | ## Deploy your own 6 | 7 | Deploy the example using [Vercel](https://vercel.com): 8 | 9 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/vercel/next.js/tree/canary/examples/with-typescript) 10 | 11 | ## How to use it? 12 | 13 | Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: 14 | 15 | ```bash 16 | npx create-next-app --example with-typescript with-typescript-app 17 | # or 18 | yarn create next-app --example with-typescript with-typescript-app 19 | ``` 20 | 21 | Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). 22 | 23 | ## Notes 24 | 25 | This example shows how to integrate the TypeScript type system into Next.js. Since TypeScript is supported out of the box with Next.js, all we have to do is to install TypeScript. 26 | 27 | ``` 28 | npm install --save-dev typescript 29 | ``` 30 | 31 | To enable TypeScript's features, we install the type declarations for React and Node. 32 | 33 | ``` 34 | npm install --save-dev @types/react @types/react-dom @types/node 35 | ``` 36 | 37 | When we run `next dev` the next time, Next.js will start looking for any `.ts` or `.tsx` files in our project and builds it. It even automatically creates a `tsconfig.json` file for our project with the recommended settings. 38 | 39 | Next.js has built-in TypeScript declarations, so we'll get autocompletion for Next.js' modules straight away. 40 | 41 | A `type-check` script is also added to `package.json`, which runs TypeScript's `tsc` CLI in `noEmit` mode to run type-checking separately. You can then include this, for example, in your `test` scripts. 42 | -------------------------------------------------------------------------------- /packages/next-ts-example/components/Alert.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LayoutModel from '../models/LayoutModel' 3 | 4 | export default function Alert() { 5 | let { alertText } = LayoutModel.useState() 6 | 7 | if (!alertText) return null 8 | 9 | return ( 10 |
11 |
12 | {alertText} 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/next-ts-example/components/BackToTop.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useScroll } from '../react-hooks/useScroll' 3 | 4 | export default function BackToTop() { 5 | let [shouldShow, setStatus] = useState(false) 6 | 7 | let handleGoToTop = () => { 8 | window.scrollTo(0, 0) 9 | } 10 | 11 | useScroll((scrollInfo) => { 12 | if (scrollInfo.scrollY > 100) { 13 | if (!shouldShow) { 14 | setStatus(true) 15 | } 16 | } else if (shouldShow) { 17 | setStatus(false) 18 | } 19 | }) 20 | 21 | if (!shouldShow) return null 22 | 23 | return ( 24 |
25 |  26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /packages/next-ts-example/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | import Link from 'next/link' 4 | import LayoutModel from '../models/LayoutModel' 5 | import Menu from './Menu' 6 | 7 | export default function Header() { 8 | let { showMenu, fixedHeader, pageTitle } = LayoutModel.useState((state) => { 9 | let { showMenu, fixedHeader, pageTitle } = state 10 | return { showMenu, fixedHeader, pageTitle } 11 | }) 12 | 13 | let headClassName = classnames({ 14 | show: showMenu && fixedHeader, 15 | 'fix-header': fixedHeader, 16 | 'no-fix': !fixedHeader, 17 | }) 18 | 19 | return ( 20 |
21 | {showMenu && fixedHeader && } 22 |
23 |
24 | {fixedHeader && } 25 | {pageTitle} 26 | 27 |
28 |
29 | 30 |
31 | ) 32 | } 33 | 34 | function PageCover() { 35 | let { closeMenu } = LayoutModel.useActions() 36 | return
closeMenu()} /> 37 | } 38 | 39 | function Toolbar() { 40 | let { openMenu } = LayoutModel.useActions() 41 | return
openMenu()} /> 42 | } 43 | 44 | function Message() { 45 | let { messageCount, showAddButton } = LayoutModel.useState() 46 | 47 | if (messageCount > 0) { 48 | return {messageCount} 49 | } 50 | 51 | if (showAddButton) { 52 | return ( 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | return null 60 | } 61 | -------------------------------------------------------------------------------- /packages/next-ts-example/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import Header from './Header' 3 | import BackToTop from './BackToTop' 4 | import Alert from './Alert' 5 | import Loading from './Loading' 6 | 7 | export default function Layout({ children }: { children: ReactNode }) { 8 | return ( 9 |
10 |
11 | {children} 12 | 13 | 14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /packages/next-ts-example/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LayoutModel from '../models/LayoutModel' 3 | 4 | export default function Loading() { 5 | let { loadingText } = LayoutModel.useState() 6 | if (!loadingText) return null 7 | return ( 8 |
9 |
10 | 11 | {loadingText} 12 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /packages/next-ts-example/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/router' 5 | import LayoutModel from '../models/LayoutModel' 6 | import UserInfoUI from './UserInfo' 7 | 8 | export default function Menu() { 9 | let { showMenu } = LayoutModel.useState() 10 | let actions = LayoutModel.useActions() 11 | 12 | let className = classnames({ 13 | 'nav-list': true, 14 | show: showMenu, 15 | }) 16 | 17 | return ( 18 | 44 | ) 45 | } 46 | 47 | function MenuItem(props: { className: string; to: string; children: any }) { 48 | let router = useRouter() 49 | 50 | if (props.to === router.asPath) { 51 | let { to, ...rest } = props 52 | return
  • 53 | } 54 | 55 | let { to, children, ...rest } = props 56 | 57 | return ( 58 |
  • 59 | {children} 60 |
  • 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /packages/next-ts-example/components/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | import { useRouter } from 'next/router' 4 | import LayoutModel, { UserInfo } from '../models/LayoutModel' 5 | 6 | export default function UserInfoUI() { 7 | let router = useRouter() 8 | let { userInfo } = LayoutModel.useState() 9 | 10 | return ( 11 |
    12 | {!!userInfo && } 13 | {!userInfo && } 14 | {!!userInfo && ( 15 | { 17 | console.log('logout') 18 | }} 19 | /> 20 | )} 21 |
    22 | ) 23 | } 24 | 25 | function Login(props: { redirect: string }) { 26 | return ( 27 |
      28 |
    • 29 | 登录 30 |
    • 31 |
    32 | ) 33 | } 34 | 35 | function Logout(props: { onLogout: () => any }) { 36 | return ( 37 |
      38 |
    • 39 | 退出 40 |
    • 41 |
    42 | ) 43 | } 44 | 45 | function User(props: { userInfo: UserInfo }) { 46 | let { loginname, avatar_url } = props.userInfo 47 | return ( 48 |
    49 | 50 |
    51 |
    {avatar_url && }
    52 |
    {loginname &&

    {loginname}

    }
    53 |
    54 | 55 |
    56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /packages/next-ts-example/model-contexts/CtrlContext.ts: -------------------------------------------------------------------------------- 1 | import { createModelContext, setupContext } from '@pure-model/core' 2 | 3 | export type CtrlContext = { 4 | redirect(url: string): void 5 | } 6 | 7 | export const CtrlContext = createModelContext({ 8 | redirect() {}, 9 | }) 10 | 11 | export const setupCtrl = () => { 12 | let ctrl = setupContext(CtrlContext) 13 | return ctrl 14 | } 15 | -------------------------------------------------------------------------------- /packages/next-ts-example/model-contexts/CtrlContextImpl.ts: -------------------------------------------------------------------------------- 1 | import { GetContextsOptions } from '@pure-model/next.js' 2 | import { CtrlContext } from '../model-contexts/CtrlContext' 3 | export const implCtrlContext = (options: GetContextsOptions) => { 4 | return CtrlContext.create({ 5 | redirect: (url) => { 6 | if (options.isServer) { 7 | let res = options.ctx?.res 8 | if (!res) return 9 | 10 | res.statusCode = 302 11 | res.setHeader('Content-Type', 'text/plain') 12 | res.setHeader('Location', url) 13 | res.end('Redirecting to ' + url) 14 | } else { 15 | window.location.replace(url) 16 | } 17 | }, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /packages/next-ts-example/model-hooks/http.ts: -------------------------------------------------------------------------------- 1 | import * as Core from '@pure-model/core' 2 | 3 | const restapi = 'https://cnodejs.org/api/v1' 4 | 5 | export const setupGetJSON: typeof Core.setupGetJSON = () => { 6 | let getJSON = Core.setupGetJSON() 7 | 8 | return (url, params, options) => { 9 | return getJSON(restapi + url, params, { 10 | credentials: 'same-origin', 11 | ...options, 12 | }) 13 | } 14 | } 15 | 16 | export const setupPostJSON: typeof Core.setupPostJSON = () => { 17 | let postJSON = Core.setupPostJSON() 18 | 19 | return (url, body, options) => { 20 | return postJSON(restapi + url, body, { 21 | credentials: 'same-origin', 22 | ...options, 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/next-ts-example/models/LayoutModel.ts: -------------------------------------------------------------------------------- 1 | import { setupStore } from '@pure-model/core' 2 | import { createReactModel } from '@pure-model/react' 3 | 4 | export type UserInfo = { 5 | loginname: string 6 | avatar_url: string 7 | } 8 | 9 | export type LayoutState = { 10 | pageTitle: '' 11 | fixedHeader: boolean 12 | showAddButton: boolean 13 | showMenu: boolean 14 | messageCount: number 15 | loadingText: string 16 | alertText: string 17 | userInfo?: UserInfo 18 | } 19 | 20 | export const initialState: LayoutState = { 21 | showMenu: false, 22 | messageCount: 0, 23 | pageTitle: '', 24 | fixedHeader: true, 25 | showAddButton: false, 26 | loadingText: '', 27 | alertText: '', 28 | } 29 | 30 | export default createReactModel(() => { 31 | let { store, actions } = setupStore({ 32 | name: 'LayoutModel', 33 | initialState, 34 | logger: typeof window !== 'undefined', 35 | reducers: { 36 | openMenu: (state: LayoutState) => { 37 | return { 38 | ...state, 39 | showMenu: true, 40 | } 41 | }, 42 | closeMenu: (state: LayoutState) => { 43 | return { 44 | ...state, 45 | showMenu: false, 46 | } 47 | }, 48 | setMessageCount: (state: LayoutState, messageCount: number) => { 49 | return { 50 | ...state, 51 | messageCount, 52 | } 53 | }, 54 | setUserInfo: (state: LayoutState, userInfo: UserInfo) => { 55 | return { 56 | ...state, 57 | userInfo: userInfo, 58 | } 59 | }, 60 | showLoading: (state: LayoutState, loadingText: string) => { 61 | return { 62 | ...state, 63 | loadingText, 64 | } 65 | }, 66 | hideLoading: (state: LayoutState) => { 67 | return { 68 | ...state, 69 | loadingText: '', 70 | } 71 | }, 72 | showAlert: (state: LayoutState, alertText: string) => { 73 | return { 74 | ...state, 75 | alertText, 76 | } 77 | }, 78 | hideAlert: (state: LayoutState) => { 79 | return { 80 | ...state, 81 | alertText: '', 82 | } 83 | }, 84 | }, 85 | }) 86 | 87 | return { 88 | store, 89 | actions, 90 | } 91 | }) 92 | -------------------------------------------------------------------------------- /packages/next-ts-example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/next-ts-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pure-model-nextjs-typescript", 3 | "version": "1.3.2", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build", 8 | "start": "next start", 9 | "type-check": "tsc" 10 | }, 11 | "dependencies": { 12 | "@pure-model/core": "^1.3.1", 13 | "@pure-model/next.js": "^1.3.2", 14 | "@pure-model/react": "^1.3.2", 15 | "classnames": "^2.2.6", 16 | "markdown-it": "^12.0.1", 17 | "next": "10.0.4", 18 | "react": "^17.0.0", 19 | "react-dom": "^17.0.0" 20 | }, 21 | "devDependencies": { 22 | "@types/classnames": "^2.2.10", 23 | "@types/markdown-it": "^10.0.2", 24 | "@types/node": "^14.11.10", 25 | "@types/react": "^17.0.3", 26 | "@types/react-dom": "^17.0.3", 27 | "typescript": "^4.0.3" 28 | }, 29 | "license": "MIT" 30 | } 31 | -------------------------------------------------------------------------------- /packages/next-ts-example/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { AppProps } from 'next/app' 3 | import '../css/main.css' 4 | 5 | export default function App({ Component, pageProps }: AppProps) { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /packages/next-ts-example/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import IndexPage from '../src/index' 2 | 3 | export default IndexPage 4 | -------------------------------------------------------------------------------- /packages/next-ts-example/public/image/go_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucifier129/pure-model/6a8662770ecf51975d347786c3a4ed7a476bc6ad/packages/next-ts-example/public/image/go_icon.png -------------------------------------------------------------------------------- /packages/next-ts-example/public/image/go_next_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucifier129/pure-model/6a8662770ecf51975d347786c3a4ed7a476bc6ad/packages/next-ts-example/public/image/go_next_icon.png -------------------------------------------------------------------------------- /packages/next-ts-example/public/image/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucifier129/pure-model/6a8662770ecf51975d347786c3a4ed7a476bc6ad/packages/next-ts-example/public/image/index.png -------------------------------------------------------------------------------- /packages/next-ts-example/public/image/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucifier129/pure-model/6a8662770ecf51975d347786c3a4ed7a476bc6ad/packages/next-ts-example/public/image/loading.gif -------------------------------------------------------------------------------- /packages/next-ts-example/public/image/login_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucifier129/pure-model/6a8662770ecf51975d347786c3a4ed7a476bc6ad/packages/next-ts-example/public/image/login_icon.png -------------------------------------------------------------------------------- /packages/next-ts-example/public/image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucifier129/pure-model/6a8662770ecf51975d347786c3a4ed7a476bc6ad/packages/next-ts-example/public/image/logo.png -------------------------------------------------------------------------------- /packages/next-ts-example/public/image/nav_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucifier129/pure-model/6a8662770ecf51975d347786c3a4ed7a476bc6ad/packages/next-ts-example/public/image/nav_icon.png -------------------------------------------------------------------------------- /packages/next-ts-example/public/image/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lucifier129/pure-model/6a8662770ecf51975d347786c3a4ed7a476bc6ad/packages/next-ts-example/public/image/user.png -------------------------------------------------------------------------------- /packages/next-ts-example/react-hooks/useScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export type ScrollHandler = (info: { scrollX: number; scrollY: number }) => any 4 | 5 | export const useScroll = (scrollHandler: ScrollHandler) => { 6 | let scrollHandlerRef = useRef(scrollHandler) 7 | 8 | useEffect(() => { 9 | let handleScroll = () => { 10 | let { scrollX, scrollY } = window 11 | scrollHandlerRef.current({ 12 | scrollX, 13 | scrollY, 14 | }) 15 | } 16 | 17 | window.addEventListener('scroll', handleScroll, false) 18 | 19 | return () => { 20 | window.removeEventListener('scroll', handleScroll, false) 21 | } 22 | }, [scrollHandlerRef]) 23 | 24 | useEffect(() => { 25 | scrollHandlerRef.current = scrollHandler 26 | }, [scrollHandler]) 27 | } 28 | -------------------------------------------------------------------------------- /packages/next-ts-example/react-hooks/useScrollToBottom.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | import { useScroll } from './useScroll' 3 | 4 | export const useScrollToBottom = (f: Function) => { 5 | let ref = useRef(false) 6 | 7 | useScroll(async () => { 8 | if (ref.current) return 9 | let scrollHeight = window.innerHeight + window.scrollY 10 | let pageHeight = document.body.scrollHeight || document.documentElement.scrollHeight 11 | 12 | if (pageHeight - scrollHeight <= 400) { 13 | ref.current = true 14 | try { 15 | await f() 16 | } finally { 17 | ref.current = false 18 | } 19 | } 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /packages/next-ts-example/src/index/Model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Model 3 | */ 4 | import { setupStore, setupPreloadCallback, setupStartCallback, setupModel } from '@pure-model/core' 5 | import { createReactModel } from '@pure-model/react' 6 | import { setupPageContext } from '@pure-model/next.js' 7 | 8 | import { setupGetJSON } from '../../model-hooks/http' 9 | import { setupCtrl } from '../../model-contexts/CtrlContext' 10 | import LayoutModel from '../../models/LayoutModel' 11 | 12 | export type Topic = { 13 | id: string 14 | author_id: string 15 | tab: string 16 | content: string 17 | title: string 18 | last_reply_at: string 19 | good: boolean 20 | top: boolean 21 | reply_count: number 22 | visit_count: number 23 | create_at: string 24 | author: { 25 | loginname: string 26 | avatar_url: string 27 | } 28 | } 29 | 30 | export type SearchParams = { 31 | page: number 32 | limit: number 33 | tab: string 34 | mdrender: boolean 35 | } 36 | 37 | export type State = { 38 | pageTitle: string 39 | topics: Topic[] 40 | searchParams: SearchParams 41 | } 42 | 43 | export const initialState: State = { 44 | pageTitle: '首页', 45 | // 主题列表 46 | topics: [], 47 | // 请求参数 48 | searchParams: { 49 | page: 1, 50 | limit: 20, 51 | tab: 'all', 52 | mdrender: true, 53 | }, 54 | } 55 | 56 | export const CommonModel = createReactModel(() => { 57 | let { store, actions } = setupStore({ 58 | name: 'CommonModel', 59 | initialState: 0, 60 | logger: typeof window !== 'undefined', 61 | reducers: { 62 | incre: (state: number) => { 63 | return state + 1 64 | }, 65 | }, 66 | }) 67 | 68 | setupPreloadCallback(() => { 69 | actions.incre() 70 | }) 71 | 72 | return { 73 | store, 74 | actions, 75 | } 76 | }) 77 | 78 | export default createReactModel(() => { 79 | let { store, actions } = setupStore({ 80 | name: 'IndexModel', 81 | initialState: initialState, 82 | logger: typeof window !== 'undefined', 83 | reducers: { 84 | /** 85 | * 更新查询参数 86 | */ 87 | setSearchParams: (state: State, searchParams: Partial) => { 88 | return { 89 | ...state, 90 | searchParams: { 91 | ...state.searchParams, 92 | ...searchParams, 93 | }, 94 | } 95 | }, 96 | setTopics: (state: State, topics: Topic[]) => { 97 | return { 98 | ...state, 99 | topics, 100 | } 101 | }, 102 | /** 103 | * 添加主题列表 104 | */ 105 | addTopics: (state: State, topics: Topic[]) => { 106 | let newTopics = state.topics.concat(topics) 107 | return { 108 | ...state, 109 | topics: newTopics.filter((topic, index) => { 110 | return newTopics.indexOf(topic) === index 111 | }), 112 | } 113 | }, 114 | }, 115 | }) 116 | 117 | let ctrl = setupCtrl() 118 | let getJSON = setupGetJSON() 119 | 120 | let commonModel = setupModel(CommonModel) 121 | 122 | setupPreloadCallback(() => { 123 | commonModel.actions.incre() 124 | }) 125 | 126 | setupStartCallback(() => { 127 | console.log('commonModel state', commonModel.store.getState()) 128 | }) 129 | 130 | let layoutModel = setupModel(LayoutModel) 131 | 132 | setupStartCallback(() => { 133 | layoutModel.actions.openMenu() 134 | }) 135 | 136 | let getTopics = async (searchParams: SearchParams) => { 137 | let json = (await getJSON('/topics', searchParams)) as { data: Topic[] } 138 | return json.data 139 | } 140 | 141 | let getCurrentTopics = async () => { 142 | let { searchParams } = store.getState() 143 | 144 | let topics = await getTopics(searchParams) 145 | actions.setTopics(topics) 146 | } 147 | 148 | let getNextTopics = async () => { 149 | let { searchParams } = store.getState() 150 | let nextSearchParams = { 151 | ...searchParams, 152 | page: searchParams.page + 1, 153 | } 154 | 155 | let topics = await getTopics(nextSearchParams) 156 | 157 | actions.setSearchParams(nextSearchParams) 158 | actions.addTopics(topics) 159 | } 160 | 161 | let ctx = setupPageContext() 162 | 163 | let initSearchParams = () => { 164 | if (!ctx) return 165 | 166 | let tab = 'all' 167 | if (Array.isArray(ctx.query.tab)) { 168 | tab = ctx.query.tab.join('') 169 | } else if (ctx.query.tab) { 170 | tab = ctx.query.tab 171 | } 172 | 173 | actions.setSearchParams({ 174 | tab: tab, 175 | }) 176 | } 177 | 178 | setupPreloadCallback(async () => { 179 | initSearchParams() 180 | await getCurrentTopics() 181 | }) 182 | 183 | return { 184 | store, 185 | actions: { 186 | ...actions, 187 | ctrl, 188 | getCurrentTopics, 189 | getNextTopics, 190 | }, 191 | } 192 | }) 193 | -------------------------------------------------------------------------------- /packages/next-ts-example/src/index/View.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | 4 | import { useScrollToBottom } from '../../react-hooks/useScrollToBottom' 5 | import Layout from '../../components/Layout' 6 | 7 | import LayoutModel from '../../models/LayoutModel' 8 | import IndexModel, { Topic } from './Model' 9 | import * as utils from '../../utils' 10 | 11 | export default function View() { 12 | let topics = IndexModel.useState((state) => state.topics) 13 | let showMenu = LayoutModel.useState((state) => state.showMenu) 14 | 15 | let actions = IndexModel.useActions() 16 | 17 | useScrollToBottom(async () => { 18 | if (showMenu) return 19 | await actions.getNextTopics() 20 | }) 21 | 22 | return ( 23 | 24 |
    25 |
      26 | {topics.map((topic) => ( 27 | 28 | ))} 29 |
    30 |
    31 |
    32 | ) 33 | } 34 | 35 | function TopicUI(props: { topic: Topic }) { 36 | let { id, title, good, top, tab, author, reply_count, create_at, last_reply_at, visit_count } = props.topic 37 | return ( 38 |
  • 39 | 40 |
    41 |

    42 | {title} 43 |

    44 |
    45 | 46 |
    47 |

    48 | {author.loginname} 49 | {reply_count > 0 && ( 50 | 51 | {reply_count}/{visit_count} 52 | 53 | )} 54 |

    55 |

    56 | 57 | 58 |

    59 |
    60 |
    61 |
    62 | 63 |
  • 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /packages/next-ts-example/src/index/index.tsx: -------------------------------------------------------------------------------- 1 | import { page } from '@pure-model/next.js' 2 | 3 | import LayoutModel from '../../models/LayoutModel' 4 | import IndexModel, { CommonModel } from './Model' 5 | import View from './View' 6 | 7 | import { implCtrlContext } from '../../model-contexts/CtrlContextImpl' 8 | 9 | const Page = page({ 10 | contexts: (options) => { 11 | return [implCtrlContext(options)] 12 | }, 13 | Models: { 14 | LayoutModel, 15 | IndexModel, 16 | CommonModel, 17 | }, 18 | }) 19 | 20 | export default Page(View) 21 | -------------------------------------------------------------------------------- /packages/next-ts-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "allowJs": true, 5 | "alwaysStrict": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "jsx": "preserve", 10 | "lib": ["dom", "es2017"], 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "noEmit": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "esnext" 21 | }, 22 | "references": [ 23 | { 24 | "path": "../core" 25 | }, 26 | { 27 | "path": "../react" 28 | }, 29 | { 30 | "path": "../next.js" 31 | } 32 | ], 33 | "exclude": ["node_modules"], 34 | "include": ["**/*.ts", "**/*.tsx"] 35 | } 36 | -------------------------------------------------------------------------------- /packages/next-ts-example/utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 获取标签样式 2 | * @param {string} tab Tab分类 3 | * @param {bool} good 是否是精华帖 4 | * @param {bool} top 是否是置顶帖 5 | */ 6 | export function getTabClassName(tab: string, good: boolean, top: boolean): string { 7 | let className = '' 8 | 9 | if (top) { 10 | className = 'top' 11 | } else if (good) { 12 | className = 'good' 13 | } else { 14 | switch (tab) { 15 | case 'share': 16 | className = 'share' 17 | break 18 | case 'ask': 19 | className = 'ask' 20 | break 21 | case 'job': 22 | className = 'job' 23 | break 24 | default: 25 | className = 'default' 26 | } 27 | } 28 | 29 | return className 30 | } 31 | 32 | /** 33 | * 获取title文字 34 | */ 35 | export function getTitleByTab(tab: string): string { 36 | let title = '' 37 | switch (tab) { 38 | case 'share': 39 | title = '分享' 40 | break 41 | case 'ask': 42 | title = '问答' 43 | break 44 | case 'job': 45 | title = '招聘' 46 | break 47 | case 'good': 48 | title = '精华' 49 | break 50 | default: 51 | title = '全部' 52 | } 53 | return title 54 | } 55 | 56 | /** 57 | * 获取文字标签 58 | */ 59 | export function getTabStr(tab: string, good: boolean, top: boolean): string { 60 | let str = '' 61 | 62 | if (top) { 63 | str = '置顶' 64 | } else if (good) { 65 | str = '精华' 66 | } else { 67 | switch (tab) { 68 | case 'share': 69 | str = '分享' 70 | break 71 | case 'ask': 72 | str = '问答' 73 | break 74 | case 'job': 75 | str = '招聘' 76 | break 77 | default: 78 | str = '暂无' 79 | } 80 | } 81 | 82 | return str 83 | } 84 | 85 | /** 86 | * 格式化时间 87 | */ 88 | export function getLastTimeStr(time: string, friendly: boolean): string { 89 | if (friendly) { 90 | return MillisecondToDate(new Date().getTime() - new Date(time).getTime()) 91 | } else { 92 | return fmtDate(new Date(time), 'yyyy-MM-dd hh:mm') 93 | } 94 | } 95 | 96 | /** 97 | * 从文本中提取出@username 标记的用户名数组 98 | */ 99 | export function fetchUsers(text: string): string[] { 100 | if (!text) { 101 | return [] 102 | } 103 | 104 | let ignoreRegexs = [ 105 | /```.+?```/g, // 去除单行的 ``` 106 | /^```[\s\S]+?^```/gm, // ``` 里面的是 pre 标签内容 107 | /`[\s\S]+?`/g, // 同一行中,`some code` 中内容也不该被解析 108 | /^ .*/gm, // 4个空格也是 pre 标签,在这里 . 不会匹配换行 109 | /\b\S*?@[^\s]*?\..+?\b/g, // somebody@gmail.com 会被去除 110 | /\[@.+?\]\(\/.+?\)/g, // 已经被 link 的 username 111 | ] 112 | 113 | ignoreRegexs.forEach(function (ignore_regex) { 114 | text = text.replace(ignore_regex, '') 115 | }) 116 | 117 | let results = text.match(/@[a-z0-9\-_]+\b/gim) 118 | let names = [] 119 | let map: { [key: string]: boolean } = {} 120 | if (results) { 121 | for (let i = 0, l = results.length; i < l; i++) { 122 | let s: string = results[i] 123 | //remove leading char @ 124 | s = s.slice(1) 125 | if (!map.hasOwnProperty(s)) { 126 | names.push(s) 127 | map[s] = true 128 | } 129 | } 130 | } 131 | return names 132 | } 133 | 134 | /** 135 | * 根据文本内容,替换为数据库中的数据 136 | */ 137 | export function linkUsers(text: string): string { 138 | let users = fetchUsers(text) 139 | for (let i = 0, l = users.length; i < l; i++) { 140 | let name = users[i] 141 | text = text.replace(new RegExp('@' + name + '\\b(?!\\])', 'g'), '[@' + name + '](/user/' + name + ')') 142 | } 143 | return text 144 | } 145 | 146 | /** 147 | * 对Date的扩展,将 Date 转化为指定格式的String 148 | * 月(M)、日(d)、小时(h)、分(m)、秒(s)、季度(q) 可以用 1-2 个占位符, 149 | * 年(y)可以用 1-4 个占位符,毫秒(S)只能用 1 个占位符(是 1-3 位的数字) 150 | * 例子: 151 | * (new Date()).Format("yyyy-MM-dd hh:mm:ss.S") ==> 2006-07-02 08:09:04.423 152 | * (new Date()).Format("yyyy-M-d h:m:s.S") ==> 2006-7-2 8:9:4.18 153 | */ 154 | export function fmtDate(date: Date, fmt: string): string { 155 | //author: meizz 156 | let o: { [key: string]: number } = { 157 | 'M+': date.getMonth() + 1, //月份 158 | 'd+': date.getDate(), //日 159 | 'h+': date.getHours(), //小时 160 | 'm+': date.getMinutes(), //分 161 | 's+': date.getSeconds(), //秒 162 | 'q+': Math.floor((date.getMonth() + 3) / 3), //季度 163 | S: date.getMilliseconds(), //毫秒 164 | } 165 | if (/(y+)/.test(fmt)) { 166 | fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length)) 167 | } 168 | 169 | for (let k in o) { 170 | if (new RegExp('(' + k + ')').test(fmt)) { 171 | let replaceValue = RegExp.$1.length == 1 ? o[k].toString() : ('00' + o[k]).substr(('' + o[k]).length) 172 | fmt = fmt.replace(RegExp.$1, replaceValue) 173 | } 174 | } 175 | 176 | return fmt 177 | } 178 | 179 | /** 180 | * 由于moment库加进来太大了,自定义了formnow函数,待完善阶段 181 | */ 182 | export function MillisecondToDate(msd: string | number): string { 183 | msd = typeof msd === 'string' ? parseFloat(msd) : msd 184 | let time = msd / 1000 185 | let str = '' 186 | if (null != time) { 187 | if (time > 60 && time < 3600) { 188 | str = Math.ceil(time / 60.0) + ' 分钟前' 189 | } else if (time >= 3600 && time < 86400) { 190 | str = Math.ceil(time / 3600.0) + ' 小时前' 191 | } else if (time >= 86400 && time < 86400 * 30) { 192 | str = Math.ceil(time / 86400.0) + ' 天前' 193 | } else if (time >= 86400 * 30 && time < 86400 * 365) { 194 | str = Math.ceil(time / (86400.0 * 30)) + ' 个月前' 195 | } else if (time >= 86400 * 365) { 196 | str = Math.ceil(time / (86400.0 * 365)) + ' 年前' 197 | } else { 198 | str = Math.ceil(time) + ' 秒前' 199 | } 200 | } 201 | return str 202 | } 203 | -------------------------------------------------------------------------------- /packages/next.js/README.md: -------------------------------------------------------------------------------- 1 | # @pure-model/next.js 2 | -------------------------------------------------------------------------------- /packages/next.js/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/next.js", 3 | "version": "1.3.2", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@next/env": { 8 | "version": "12.3.1", 9 | "resolved": "https://registry.npmjs.org/@next/env/-/env-12.3.1.tgz", 10 | "integrity": "sha512-9P9THmRFVKGKt9DYqeC2aKIxm8rlvkK38V1P1sRE7qyoPBIs8l9oo79QoSdPtOWfzkbDAVUqvbQGgTMsb8BtJg==", 11 | "dev": true 12 | }, 13 | "@next/swc-android-arm-eabi": { 14 | "version": "12.3.1", 15 | "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.1.tgz", 16 | "integrity": "sha512-i+BvKA8tB//srVPPQxIQN5lvfROcfv4OB23/L1nXznP+N/TyKL8lql3l7oo2LNhnH66zWhfoemg3Q4VJZSruzQ==", 17 | "dev": true, 18 | "optional": true 19 | }, 20 | "@next/swc-android-arm64": { 21 | "version": "12.3.1", 22 | "resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-12.3.1.tgz", 23 | "integrity": "sha512-CmgU2ZNyBP0rkugOOqLnjl3+eRpXBzB/I2sjwcGZ7/Z6RcUJXK5Evz+N0ucOxqE4cZ3gkTeXtSzRrMK2mGYV8Q==", 24 | "dev": true, 25 | "optional": true 26 | }, 27 | "@next/swc-darwin-arm64": { 28 | "version": "12.3.1", 29 | "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.3.1.tgz", 30 | "integrity": "sha512-hT/EBGNcu0ITiuWDYU9ur57Oa4LybD5DOQp4f22T6zLfpoBMfBibPtR8XktXmOyFHrL/6FC2p9ojdLZhWhvBHg==", 31 | "dev": true, 32 | "optional": true 33 | }, 34 | "@next/swc-darwin-x64": { 35 | "version": "12.3.1", 36 | "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.1.tgz", 37 | "integrity": "sha512-9S6EVueCVCyGf2vuiLiGEHZCJcPAxglyckTZcEwLdJwozLqN0gtS0Eq0bQlGS3dH49Py/rQYpZ3KVWZ9BUf/WA==", 38 | "dev": true, 39 | "optional": true 40 | }, 41 | "@next/swc-freebsd-x64": { 42 | "version": "12.3.1", 43 | "resolved": "https://registry.npmjs.org/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.1.tgz", 44 | "integrity": "sha512-qcuUQkaBZWqzM0F1N4AkAh88lLzzpfE6ImOcI1P6YeyJSsBmpBIV8o70zV+Wxpc26yV9vpzb+e5gCyxNjKJg5Q==", 45 | "dev": true, 46 | "optional": true 47 | }, 48 | "@next/swc-linux-arm-gnueabihf": { 49 | "version": "12.3.1", 50 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.1.tgz", 51 | "integrity": "sha512-diL9MSYrEI5nY2wc/h/DBewEDUzr/DqBjIgHJ3RUNtETAOB3spMNHvJk2XKUDjnQuluLmFMloet9tpEqU2TT9w==", 52 | "dev": true, 53 | "optional": true 54 | }, 55 | "@next/swc-linux-arm64-gnu": { 56 | "version": "12.3.1", 57 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.1.tgz", 58 | "integrity": "sha512-o/xB2nztoaC7jnXU3Q36vGgOolJpsGG8ETNjxM1VAPxRwM7FyGCPHOMk1XavG88QZSQf+1r+POBW0tLxQOJ9DQ==", 59 | "dev": true, 60 | "optional": true 61 | }, 62 | "@next/swc-linux-arm64-musl": { 63 | "version": "12.3.1", 64 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.1.tgz", 65 | "integrity": "sha512-2WEasRxJzgAmP43glFNhADpe8zB7kJofhEAVNbDJZANp+H4+wq+/cW1CdDi8DqjkShPEA6/ejJw+xnEyDID2jg==", 66 | "dev": true, 67 | "optional": true 68 | }, 69 | "@next/swc-linux-x64-gnu": { 70 | "version": "12.3.1", 71 | "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.1.tgz", 72 | "integrity": "sha512-JWEaMyvNrXuM3dyy9Pp5cFPuSSvG82+yABqsWugjWlvfmnlnx9HOQZY23bFq3cNghy5V/t0iPb6cffzRWylgsA==", 73 | "dev": true, 74 | "optional": true 75 | }, 76 | "@next/swc-linux-x64-musl": { 77 | "version": "12.3.1", 78 | "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.1.tgz", 79 | "integrity": "sha512-xoEWQQ71waWc4BZcOjmatuvPUXKTv6MbIFzpm4LFeCHsg2iwai0ILmNXf81rJR+L1Wb9ifEke2sQpZSPNz1Iyg==", 80 | "dev": true, 81 | "optional": true 82 | }, 83 | "@next/swc-win32-arm64-msvc": { 84 | "version": "12.3.1", 85 | "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.1.tgz", 86 | "integrity": "sha512-hswVFYQYIeGHE2JYaBVtvqmBQ1CppplQbZJS/JgrVI3x2CurNhEkmds/yqvDONfwfbttTtH4+q9Dzf/WVl3Opw==", 87 | "dev": true, 88 | "optional": true 89 | }, 90 | "@next/swc-win32-ia32-msvc": { 91 | "version": "12.3.1", 92 | "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.1.tgz", 93 | "integrity": "sha512-Kny5JBehkTbKPmqulr5i+iKntO5YMP+bVM8Hf8UAmjSMVo3wehyLVc9IZkNmcbxi+vwETnQvJaT5ynYBkJ9dWA==", 94 | "dev": true, 95 | "optional": true 96 | }, 97 | "@next/swc-win32-x64-msvc": { 98 | "version": "12.3.1", 99 | "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.1.tgz", 100 | "integrity": "sha512-W1ijvzzg+kPEX6LAc+50EYYSEo0FVu7dmTE+t+DM4iOLqgGHoW9uYSz9wCVdkXOEEMP9xhXfGpcSxsfDucyPkA==", 101 | "dev": true, 102 | "optional": true 103 | }, 104 | "@swc/helpers": { 105 | "version": "0.4.11", 106 | "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.11.tgz", 107 | "integrity": "sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw==", 108 | "dev": true, 109 | "requires": { 110 | "tslib": "^2.4.0" 111 | }, 112 | "dependencies": { 113 | "tslib": { 114 | "version": "2.4.0", 115 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", 116 | "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", 117 | "dev": true 118 | } 119 | } 120 | }, 121 | "caniuse-lite": { 122 | "version": "1.0.30001419", 123 | "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001419.tgz", 124 | "integrity": "sha512-aFO1r+g6R7TW+PNQxKzjITwLOyDhVRLjW0LcwS/HCZGUUKTGNp9+IwLC4xyDSZBygVL/mxaFR3HIV6wEKQuSzw==", 125 | "dev": true 126 | }, 127 | "js-tokens": { 128 | "version": "4.0.0", 129 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 130 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 131 | "dev": true 132 | }, 133 | "loose-envify": { 134 | "version": "1.4.0", 135 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 136 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 137 | "dev": true, 138 | "requires": { 139 | "js-tokens": "^3.0.0 || ^4.0.0" 140 | } 141 | }, 142 | "nanoid": { 143 | "version": "3.3.4", 144 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", 145 | "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", 146 | "dev": true 147 | }, 148 | "next": { 149 | "version": "12.3.1", 150 | "resolved": "https://registry.npmjs.org/next/-/next-12.3.1.tgz", 151 | "integrity": "sha512-l7bvmSeIwX5lp07WtIiP9u2ytZMv7jIeB8iacR28PuUEFG5j0HGAPnMqyG5kbZNBG2H7tRsrQ4HCjuMOPnANZw==", 152 | "dev": true, 153 | "requires": { 154 | "@next/env": "12.3.1", 155 | "@next/swc-android-arm-eabi": "12.3.1", 156 | "@next/swc-android-arm64": "12.3.1", 157 | "@next/swc-darwin-arm64": "12.3.1", 158 | "@next/swc-darwin-x64": "12.3.1", 159 | "@next/swc-freebsd-x64": "12.3.1", 160 | "@next/swc-linux-arm-gnueabihf": "12.3.1", 161 | "@next/swc-linux-arm64-gnu": "12.3.1", 162 | "@next/swc-linux-arm64-musl": "12.3.1", 163 | "@next/swc-linux-x64-gnu": "12.3.1", 164 | "@next/swc-linux-x64-musl": "12.3.1", 165 | "@next/swc-win32-arm64-msvc": "12.3.1", 166 | "@next/swc-win32-ia32-msvc": "12.3.1", 167 | "@next/swc-win32-x64-msvc": "12.3.1", 168 | "@swc/helpers": "0.4.11", 169 | "caniuse-lite": "^1.0.30001406", 170 | "postcss": "8.4.14", 171 | "styled-jsx": "5.0.7", 172 | "use-sync-external-store": "1.2.0" 173 | } 174 | }, 175 | "object-assign": { 176 | "version": "4.1.1", 177 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 178 | "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", 179 | "dev": true 180 | }, 181 | "picocolors": { 182 | "version": "1.0.0", 183 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 184 | "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", 185 | "dev": true 186 | }, 187 | "postcss": { 188 | "version": "8.4.14", 189 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", 190 | "integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==", 191 | "dev": true, 192 | "requires": { 193 | "nanoid": "^3.3.4", 194 | "picocolors": "^1.0.0", 195 | "source-map-js": "^1.0.2" 196 | } 197 | }, 198 | "react": { 199 | "version": "17.0.2", 200 | "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", 201 | "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", 202 | "dev": true, 203 | "requires": { 204 | "loose-envify": "^1.1.0", 205 | "object-assign": "^4.1.1" 206 | } 207 | }, 208 | "react-dom": { 209 | "version": "17.0.2", 210 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", 211 | "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", 212 | "dev": true, 213 | "requires": { 214 | "loose-envify": "^1.1.0", 215 | "object-assign": "^4.1.1", 216 | "scheduler": "^0.20.2" 217 | } 218 | }, 219 | "scheduler": { 220 | "version": "0.20.2", 221 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", 222 | "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", 223 | "dev": true, 224 | "requires": { 225 | "loose-envify": "^1.1.0", 226 | "object-assign": "^4.1.1" 227 | } 228 | }, 229 | "source-map-js": { 230 | "version": "1.0.2", 231 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", 232 | "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", 233 | "dev": true 234 | }, 235 | "styled-jsx": { 236 | "version": "5.0.7", 237 | "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.7.tgz", 238 | "integrity": "sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==", 239 | "dev": true 240 | }, 241 | "tslib": { 242 | "version": "2.1.0", 243 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", 244 | "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" 245 | }, 246 | "use-sync-external-store": { 247 | "version": "1.2.0", 248 | "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", 249 | "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", 250 | "dev": true 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /packages/next.js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/next.js", 3 | "version": "1.3.2", 4 | "description": "Next.js adapter for pure-model", 5 | "main": "dist/index.js", 6 | "esnext": "next/index.js", 7 | "types": "next/index.d.ts", 8 | "author": "Jade Gu", 9 | "license": "MIT", 10 | "peerDependencies": { 11 | "@pure-model/core": "^1.0.0", 12 | "@pure-model/react": "^1.0.0", 13 | "next": ">=10.0.0", 14 | "react": "^16.14.0 || ^17.0.0 || ^18.0.0", 15 | "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" 16 | }, 17 | "devDependencies": { 18 | "@pure-model/core": "^1.3.1", 19 | "@pure-model/react": "^1.3.2", 20 | "next": ">=10.0.0", 21 | "react": "^17.0.0", 22 | "react-dom": "^17.0.0" 23 | }, 24 | "dependencies": { 25 | "tslib": "^2.0.3" 26 | }, 27 | "gitHead": "46f33fbc6a53f32851d35963ddd764c6140a6c99" 28 | } 29 | -------------------------------------------------------------------------------- /packages/next.js/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | 3 | import { 4 | ModelContextValue, 5 | EnvContext, 6 | mergeModelContext, 7 | createPureModel, 8 | Initializer, 9 | createModelContext, 10 | setupContext, 11 | createPureModelContainer, 12 | } from '@pure-model/core' 13 | 14 | import { HydrateProvider, ReactModels, ReactModelInitializer } from '@pure-model/react' 15 | 16 | import type { NextPage, NextPageContext } from 'next' 17 | 18 | export type PageModels = { 19 | [key in keyof T]: ReturnType> 20 | } 21 | 22 | const isServer = typeof window === 'undefined' 23 | 24 | export type GetContextsOptions = { 25 | ctx?: NextPageContext 26 | isServer: boolean 27 | getInitialProps: boolean 28 | } 29 | 30 | export type PageOptions = { 31 | Models: T 32 | preload?: (models: PageModels, ctx: NextPageContext) => Promise 33 | contexts?: ModelContextValue[] | ((options: GetContextsOptions) => ModelContextValue[]) 34 | } 35 | 36 | export const PageContext = createModelContext(null) 37 | 38 | export const setupPageContext = () => { 39 | let ctx = setupContext(PageContext) 40 | return ctx 41 | } 42 | 43 | export const page = (options: PageOptions) => { 44 | let uid = 0 45 | 46 | let getOptionContexts = (contextsOptions: GetContextsOptions) => { 47 | if (!options.contexts) return mergeModelContext() 48 | 49 | if (Array.isArray(options.contexts)) { 50 | // @ts-ignore 51 | return mergeModelContext(...options.contexts) 52 | } 53 | 54 | let contexts = options.contexts(contextsOptions) 55 | 56 | return mergeModelContext(...contexts) 57 | } 58 | 59 | return function (InputComponent: NextPage) { 60 | type PageInitialProps = InitialProps & { 61 | __STATE_LIST__?: any[] 62 | } 63 | 64 | const Page = (props: PageInitialProps) => { 65 | let { __STATE_LIST__, ...rest } = props 66 | 67 | let ReactModelArgs = useMemo(() => { 68 | return Object.values(options.Models).map((Model, index) => { 69 | let preloadedState = __STATE_LIST__?.[index] 70 | let optionContexts = getOptionContexts({ 71 | getInitialProps: false, 72 | isServer, 73 | }) 74 | 75 | return { 76 | Model, 77 | context: optionContexts, 78 | preloadedState, 79 | } 80 | }) 81 | }, [__STATE_LIST__]) 82 | 83 | let key = useMemo(() => { 84 | return uid++ 85 | }, [ReactModelArgs]) 86 | 87 | let Component: any = InputComponent 88 | 89 | return ( 90 | 91 | 92 | 93 | ) 94 | } 95 | 96 | Page.getInitialProps = async (ctx: NextPageContext) => { 97 | let initialProps = await InputComponent.getInitialProps?.(ctx) 98 | 99 | let optionContexts = getOptionContexts({ 100 | ctx, 101 | getInitialProps: true, 102 | isServer, 103 | }) 104 | 105 | let context = mergeModelContext( 106 | optionContexts, 107 | PageContext.create(ctx), 108 | EnvContext.create({ 109 | req: ctx.req, 110 | res: ctx.res, 111 | }), 112 | ) 113 | 114 | let container = createPureModelContainer() 115 | 116 | let modelList = Object.values(options.Models).map((Model) => { 117 | return createPureModel(Model.create as Initializer, { 118 | context, 119 | container, 120 | }) 121 | }) 122 | 123 | let models = {} 124 | 125 | Object.keys(options.Models).forEach((key, index) => { 126 | let model = modelList[index] 127 | models[key] = model 128 | }) 129 | 130 | await options.preload?.(models as PageModels, ctx) 131 | 132 | await Promise.all(modelList.map((model) => model.preload())) 133 | 134 | let __STATE_LIST__ = modelList.map((model) => model.store.getState()) 135 | 136 | return { 137 | ...initialProps, 138 | __STATE_LIST__, 139 | } 140 | } 141 | 142 | return (Page as unknown) as NextPage 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /packages/next.js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./esm", 6 | "composite": true 7 | }, 8 | "references": [ 9 | { 10 | "path": "../core" 11 | }, 12 | { 13 | "path": "../react" 14 | } 15 | ], 16 | "include": ["./src"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/next.js/tsconfig.next.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2018", 5 | "outDir": "./next" 6 | }, 7 | "references": [ 8 | { 9 | "path": "../core/tsconfig.next.json" 10 | }, 11 | { 12 | "path": "../react/tsconfig.next.json" 13 | } 14 | ], 15 | "include": ["./src"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | # @pure-model/react 2 | -------------------------------------------------------------------------------- /packages/react/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/react", 3 | "version": "1.3.2", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "js-tokens": { 8 | "version": "4.0.0", 9 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 10 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 11 | "dev": true 12 | }, 13 | "loose-envify": { 14 | "version": "1.4.0", 15 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 16 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 17 | "dev": true, 18 | "requires": { 19 | "js-tokens": "^3.0.0 || ^4.0.0" 20 | } 21 | }, 22 | "object-assign": { 23 | "version": "4.1.1", 24 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 25 | "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", 26 | "dev": true 27 | }, 28 | "react": { 29 | "version": "17.0.2", 30 | "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", 31 | "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", 32 | "dev": true, 33 | "requires": { 34 | "loose-envify": "^1.1.0", 35 | "object-assign": "^4.1.1" 36 | } 37 | }, 38 | "react-dom": { 39 | "version": "17.0.2", 40 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", 41 | "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", 42 | "dev": true, 43 | "requires": { 44 | "loose-envify": "^1.1.0", 45 | "object-assign": "^4.1.1", 46 | "scheduler": "^0.20.2" 47 | } 48 | }, 49 | "scheduler": { 50 | "version": "0.20.2", 51 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", 52 | "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", 53 | "dev": true, 54 | "requires": { 55 | "loose-envify": "^1.1.0", 56 | "object-assign": "^4.1.1" 57 | } 58 | }, 59 | "tslib": { 60 | "version": "2.1.0", 61 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", 62 | "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/react", 3 | "version": "1.3.3", 4 | "description": "React adapter for pure-model", 5 | "main": "dist/index.js", 6 | "esnext": "next/index.js", 7 | "types": "next/index.d.ts", 8 | "author": "Jade Gu", 9 | "license": "MIT", 10 | "peerDependencies": { 11 | "@pure-model/core": "^1.0.0", 12 | "react": "^16.14.0 || ^17.0.0 || ^18.0.0", 13 | "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" 14 | }, 15 | "devDependencies": { 16 | "@pure-model/core": "^1.3.1", 17 | "react": "^17.0.0", 18 | "react-dom": "^17.0.0" 19 | }, 20 | "dependencies": { 21 | "tslib": "^2.0.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/react/src/__tests__/react.test.tsx: -------------------------------------------------------------------------------- 1 | import 'jest' 2 | import React, { useEffect } from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { act } from 'react-dom/test-utils' 5 | import { 6 | createModelContext, 7 | setupStore, 8 | setupContext, 9 | setupStartCallback, 10 | setupFinishCallback, 11 | setupPreloadCallback, 12 | MODEL_CONTEXT, 13 | setupModel, 14 | createPureModelContainer, 15 | } from '@pure-model/core' 16 | import { createReactModel, Provider, preload, useReactModel, provide, ReactModelArgs } from '../' 17 | 18 | const createDeferred = () => { 19 | let resolve 20 | let reject 21 | let promise = new Promise((a, b) => { 22 | resolve = a 23 | reject = b 24 | }) 25 | return { resolve, reject, promise } 26 | } 27 | 28 | let setupCounter = (n = 0) => { 29 | let { store, actions } = setupStore({ 30 | initialState: n, 31 | reducers: { 32 | incre: (state: number) => state + 1, 33 | decre: (state: number) => state - 1, 34 | increBy: (state: number, step: number) => state + step, 35 | }, 36 | }) 37 | return { 38 | store, 39 | actions, 40 | } 41 | } 42 | 43 | let delay = (duration: number) => 44 | new Promise((resolve) => { 45 | setTimeout(resolve, duration) 46 | }) 47 | 48 | describe('react bindings of pure-model', () => { 49 | let container: any 50 | let deferred: any 51 | let next = (value?: any) => { 52 | deferred.resolve(value) 53 | } 54 | 55 | beforeEach(() => { 56 | deferred = createDeferred() 57 | container = document.createElement('div') 58 | document.body.appendChild(container) 59 | }) 60 | 61 | afterEach(() => { 62 | document.body.removeChild(container) 63 | container = null 64 | deferred = null 65 | }) 66 | 67 | it('basic usage work correctly', async () => { 68 | let Context = createModelContext(10) 69 | let Model = createReactModel(() => { 70 | let initialCount = setupContext(Context) 71 | 72 | expect(initialCount).toBe(20) 73 | 74 | let { store, actions } = setupCounter(initialCount) 75 | 76 | return { store, actions } 77 | }) 78 | let App = () => { 79 | let state = Model.useState() 80 | let actions = Model.useActions() 81 | 82 | let handleIncre = () => { 83 | actions.incre() 84 | } 85 | 86 | let handleDescre = () => { 87 | actions.decre() 88 | } 89 | 90 | useEffect(() => { 91 | next() 92 | }) 93 | 94 | return ( 95 | <> 96 | 99 | {state} 100 | 103 | 104 | ) 105 | } 106 | 107 | act(() => { 108 | ReactDOM.render( 109 | 110 | 111 | , 112 | container, 113 | ) 114 | }) 115 | 116 | await deferred.promise 117 | deferred = createDeferred() 118 | 119 | // tslint:disable-next-line: no-unnecessary-type-assertion 120 | let $incre = document.querySelector('#incre') as Element 121 | // tslint:disable-next-line: no-unnecessary-type-assertion 122 | let $decre = document.querySelector('#decre') as Element 123 | // tslint:disable-next-line: no-unnecessary-type-assertion 124 | let $count = document.querySelector('#count') as Element 125 | 126 | expect($count.textContent).toBe('0') 127 | 128 | // tslint:disable-next-line: await-promise 129 | await act(async () => { 130 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 131 | await deferred.promise 132 | deferred = createDeferred() 133 | }) 134 | 135 | expect($count.textContent).toBe('1') 136 | 137 | // tslint:disable-next-line: await-promise 138 | await act(async () => { 139 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 140 | await deferred.promise 141 | deferred = createDeferred() 142 | }) 143 | 144 | expect($count.textContent).toBe('2') 145 | 146 | // tslint:disable-next-line: await-promise 147 | await act(async () => { 148 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 149 | await deferred.promise 150 | deferred = createDeferred() 151 | }) 152 | 153 | expect($count.textContent).toBe('1') 154 | 155 | // tslint:disable-next-line: await-promise 156 | await act(async () => { 157 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 158 | await deferred.promise 159 | deferred = createDeferred() 160 | }) 161 | 162 | expect($count.textContent).toBe('0') 163 | }) 164 | 165 | it('supports preload state', async () => { 166 | let Model = createReactModel(() => { 167 | let { store, actions } = setupCounter() 168 | 169 | setupPreloadCallback(async () => { 170 | await delay(10) 171 | actions.increBy(20) 172 | }) 173 | 174 | return { 175 | store, 176 | actions, 177 | } 178 | }) 179 | 180 | let { Provider, state, model } = await Model.preload() 181 | 182 | expect(state).toBe(20) 183 | 184 | expect(model.store.getState()).toBe(20) 185 | 186 | let App = () => { 187 | let state = Model.useState() 188 | let actions = Model.useActions() 189 | 190 | let handleIncre = () => { 191 | actions.incre() 192 | } 193 | 194 | let handleDescre = () => { 195 | actions.decre() 196 | } 197 | 198 | useEffect(() => { 199 | next() 200 | }) 201 | 202 | return ( 203 | <> 204 | 207 | {state} 208 | 211 | 212 | ) 213 | } 214 | 215 | act(() => { 216 | ReactDOM.render( 217 | 218 | 219 | , 220 | container, 221 | ) 222 | }) 223 | 224 | await deferred.promise 225 | deferred = createDeferred() 226 | 227 | // tslint:disable-next-line: no-unnecessary-type-assertion 228 | let $incre = document.querySelector('#incre') as Element 229 | // tslint:disable-next-line: no-unnecessary-type-assertion 230 | let $decre = document.querySelector('#decre') as Element 231 | // tslint:disable-next-line: no-unnecessary-type-assertion 232 | let $count = document.querySelector('#count') as Element 233 | 234 | expect($count.textContent).toBe('20') 235 | 236 | // tslint:disable-next-line: await-promise 237 | await act(async () => { 238 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 239 | await deferred.promise 240 | deferred = createDeferred() 241 | }) 242 | 243 | expect($count.textContent).toBe('21') 244 | 245 | // tslint:disable-next-line: await-promise 246 | await act(async () => { 247 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 248 | await deferred.promise 249 | deferred = createDeferred() 250 | }) 251 | 252 | expect($count.textContent).toBe('22') 253 | 254 | // tslint:disable-next-line: await-promise 255 | await act(async () => { 256 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 257 | await deferred.promise 258 | deferred = createDeferred() 259 | }) 260 | 261 | expect($count.textContent).toBe('21') 262 | 263 | // tslint:disable-next-line: await-promise 264 | await act(async () => { 265 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 266 | await deferred.promise 267 | deferred = createDeferred() 268 | }) 269 | 270 | expect($count.textContent).toBe('20') 271 | }) 272 | 273 | it('can pass multiple ReactModel to Provider component', async () => { 274 | let Model0 = createReactModel(() => setupCounter()) 275 | let Model1 = createReactModel(() => setupCounter()) 276 | 277 | let App = () => { 278 | let state0 = Model0.useState() 279 | let state1 = Model1.useState() 280 | let actions0 = Model0.useActions() 281 | let actions1 = Model1.useActions() 282 | 283 | let handleIncre = () => { 284 | actions0.incre() 285 | actions1.incre() 286 | } 287 | 288 | let handleDescre = () => { 289 | actions0.decre() 290 | actions1.decre() 291 | } 292 | 293 | useEffect(() => { 294 | next() 295 | }) 296 | 297 | return ( 298 | <> 299 | 302 | {state0 + state1} 303 | 306 | 307 | ) 308 | } 309 | 310 | act(() => { 311 | ReactDOM.render( 312 | 318 | 319 | , 320 | container, 321 | ) 322 | }) 323 | 324 | await deferred.promise 325 | deferred = createDeferred() 326 | 327 | // tslint:disable-next-line: no-unnecessary-type-assertion 328 | let $incre = document.querySelector('#incre') as Element 329 | // tslint:disable-next-line: no-unnecessary-type-assertion 330 | let $decre = document.querySelector('#decre') as Element 331 | // tslint:disable-next-line: no-unnecessary-type-assertion 332 | let $count = document.querySelector('#count') as Element 333 | 334 | expect($count.textContent).toBe('0') 335 | 336 | // tslint:disable-next-line: await-promise 337 | await act(async () => { 338 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 339 | await deferred.promise 340 | deferred = createDeferred() 341 | }) 342 | 343 | expect($count.textContent).toBe('-2') 344 | 345 | // tslint:disable-next-line: await-promise 346 | await act(async () => { 347 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 348 | await deferred.promise 349 | deferred = createDeferred() 350 | }) 351 | 352 | expect($count.textContent).toBe('0') 353 | }) 354 | 355 | it('support use ReactModel locally', async () => { 356 | let list: string[] = [] 357 | let Model = createReactModel(() => { 358 | let { store, actions } = setupCounter() 359 | 360 | setupPreloadCallback(() => { 361 | list.push('preload') 362 | }) 363 | 364 | setupStartCallback(() => { 365 | list.push('start') 366 | }) 367 | 368 | setupPreloadCallback(() => { 369 | list.push('preload') 370 | }) 371 | 372 | setupFinishCallback(() => { 373 | list.push('finish') 374 | }) 375 | 376 | setupStartCallback(() => { 377 | list.push('start') 378 | }) 379 | 380 | setupFinishCallback(() => { 381 | list.push('finish') 382 | }) 383 | 384 | setupFinishCallback(() => { 385 | next() 386 | }) 387 | 388 | return { 389 | store, 390 | actions, 391 | } 392 | }) 393 | let App = () => { 394 | let [state, actions] = useReactModel(Model, { 395 | preloadedState: 10, 396 | }) 397 | 398 | let handleIncre = () => { 399 | actions.incre() 400 | } 401 | 402 | let handleDescre = () => { 403 | actions.decre() 404 | } 405 | 406 | useEffect(() => { 407 | next() 408 | }) 409 | 410 | return ( 411 | <> 412 | 415 | {state} 416 | 419 | 420 | ) 421 | } 422 | 423 | act(() => { 424 | ReactDOM.render(, container) 425 | }) 426 | 427 | await deferred.promise 428 | deferred = createDeferred() 429 | 430 | // tslint:disable-next-line: no-unnecessary-type-assertion 431 | let $incre = document.querySelector('#incre') as Element 432 | // tslint:disable-next-line: no-unnecessary-type-assertion 433 | let $decre = document.querySelector('#decre') as Element 434 | // tslint:disable-next-line: no-unnecessary-type-assertion 435 | let $count = document.querySelector('#count') as Element 436 | 437 | expect($count.textContent).toBe('10') 438 | 439 | // tslint:disable-next-line: await-promise 440 | await act(async () => { 441 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 442 | await deferred.promise 443 | deferred = createDeferred() 444 | }) 445 | 446 | expect($count.textContent).toBe('9') 447 | 448 | // tslint:disable-next-line: await-promise 449 | await act(async () => { 450 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 451 | await deferred.promise 452 | deferred = createDeferred() 453 | }) 454 | 455 | expect($count.textContent).toBe('10') 456 | 457 | act(() => { 458 | ReactDOM.unmountComponentAtNode(container) 459 | }) 460 | 461 | await deferred.promise 462 | 463 | expect(list).toEqual(['start', 'start', 'finish', 'finish']) 464 | }) 465 | 466 | it('can preload multiple ReactModel', async () => { 467 | let Model0 = createReactModel(() => setupCounter()) 468 | let Model1 = createReactModel(() => setupCounter()) 469 | 470 | let App = () => { 471 | let state0 = Model0.useState() 472 | let state1 = Model1.useState() 473 | let actions0 = Model0.useActions() 474 | let actions1 = Model1.useActions() 475 | 476 | let handleIncre = () => { 477 | actions0.incre() 478 | actions1.incre() 479 | } 480 | 481 | let handleDescre = () => { 482 | actions0.decre() 483 | actions1.decre() 484 | } 485 | 486 | useEffect(() => { 487 | next() 488 | }) 489 | 490 | return ( 491 | <> 492 | 495 | {state0 + state1} 496 | 499 | 500 | ) 501 | } 502 | 503 | let { Provider, stateList, modelList } = await preload([ 504 | { Model: Model0, preloadedState: 10 }, 505 | { Model: Model1, preloadedState: -10 }, 506 | ]) 507 | 508 | expect(stateList).toEqual([10, -10]) 509 | 510 | expect(modelList[0].store.getState()).toBe(10) 511 | expect(modelList[1].store.getState()).toBe(-10) 512 | 513 | act(() => { 514 | ReactDOM.render( 515 | 516 | 517 | , 518 | container, 519 | ) 520 | }) 521 | 522 | await deferred.promise 523 | deferred = createDeferred() 524 | 525 | // tslint:disable-next-line: no-unnecessary-type-assertion 526 | let $incre = document.querySelector('#incre') as Element 527 | // tslint:disable-next-line: no-unnecessary-type-assertion 528 | let $decre = document.querySelector('#decre') as Element 529 | // tslint:disable-next-line: no-unnecessary-type-assertion 530 | let $count = document.querySelector('#count') as Element 531 | 532 | expect($count.textContent).toBe('0') 533 | 534 | // tslint:disable-next-line: await-promise 535 | await act(async () => { 536 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 537 | await deferred.promise 538 | deferred = createDeferred() 539 | }) 540 | 541 | expect($count.textContent).toBe('-2') 542 | 543 | // tslint:disable-next-line: await-promise 544 | await act(async () => { 545 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 546 | await deferred.promise 547 | deferred = createDeferred() 548 | }) 549 | 550 | expect($count.textContent).toBe('0') 551 | }) 552 | 553 | it('supports HOC style usage', async () => { 554 | let ModelContext0 = createModelContext(0) 555 | let ModelContext1 = createModelContext(1) 556 | let Model = createReactModel(() => { 557 | let count0 = setupContext(ModelContext0) 558 | let count1 = setupContext(ModelContext1) 559 | let { store, actions } = setupCounter(count0 + count1) 560 | return { store, actions } 561 | }) 562 | 563 | let Counter = () => { 564 | let state = Model.useState() 565 | let actions = Model.useActions() 566 | let handleIncre = () => { 567 | actions.incre() 568 | } 569 | 570 | let handleDescre = () => { 571 | actions.decre() 572 | } 573 | 574 | useEffect(() => { 575 | next() 576 | }) 577 | 578 | return ( 579 | <> 580 | 583 | {state} 584 | 587 | 588 | ) 589 | } 590 | 591 | @provide({ Model }) 592 | // @ts-ignore 593 | class App extends React.Component { 594 | [MODEL_CONTEXT] = { 595 | ...ModelContext0.impl(10), 596 | ...ModelContext1.impl(20), 597 | } 598 | render() { 599 | return 600 | } 601 | } 602 | 603 | act(() => { 604 | ReactDOM.render(, container) 605 | }) 606 | 607 | await deferred.promise 608 | deferred = createDeferred() 609 | 610 | // tslint:disable-next-line: no-unnecessary-type-assertion 611 | let $incre = document.querySelector('#incre') as Element 612 | // tslint:disable-next-line: no-unnecessary-type-assertion 613 | let $decre = document.querySelector('#decre') as Element 614 | // tslint:disable-next-line: no-unnecessary-type-assertion 615 | let $count = document.querySelector('#count') as Element 616 | 617 | expect($count.textContent).toBe('30') 618 | 619 | // tslint:disable-next-line: await-promise 620 | await act(async () => { 621 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 622 | await deferred.promise 623 | deferred = createDeferred() 624 | }) 625 | 626 | expect($count.textContent).toBe('29') 627 | 628 | // tslint:disable-next-line: await-promise 629 | await act(async () => { 630 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 631 | await deferred.promise 632 | deferred = createDeferred() 633 | }) 634 | 635 | expect($count.textContent).toBe('28') 636 | 637 | // tslint:disable-next-line: await-promise 638 | await act(async () => { 639 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 640 | await deferred.promise 641 | deferred = createDeferred() 642 | }) 643 | 644 | expect($count.textContent).toBe('29') 645 | }) 646 | 647 | it('should trigger callbacks is right orders', async () => { 648 | let list: string[] = [] 649 | let Model = createReactModel(() => { 650 | let { store, actions } = setupCounter() 651 | 652 | setupPreloadCallback(() => { 653 | list.push('preload') 654 | }) 655 | 656 | setupStartCallback(() => { 657 | list.push('start') 658 | }) 659 | 660 | setupPreloadCallback(() => { 661 | list.push('preload') 662 | }) 663 | 664 | setupFinishCallback(() => { 665 | list.push('finish') 666 | }) 667 | 668 | setupStartCallback(() => { 669 | list.push('start') 670 | }) 671 | 672 | setupFinishCallback(() => { 673 | list.push('finish') 674 | }) 675 | 676 | setupStartCallback(() => { 677 | next() 678 | }) 679 | 680 | setupFinishCallback(() => { 681 | next() 682 | }) 683 | 684 | return { 685 | store, 686 | actions, 687 | } 688 | }) 689 | 690 | let App = () => { 691 | return null 692 | } 693 | 694 | act(() => { 695 | ReactDOM.render( 696 | 697 | 698 | , 699 | container, 700 | ) 701 | }) 702 | 703 | await deferred.promise 704 | deferred = createDeferred() 705 | 706 | expect(list).toEqual(['preload', 'preload', 'start', 'start']) 707 | 708 | act(() => { 709 | ReactDOM.unmountComponentAtNode(container) 710 | }) 711 | 712 | await deferred.promise 713 | deferred = createDeferred() 714 | 715 | expect(list).toEqual(['preload', 'preload', 'start', 'start', 'finish', 'finish']) 716 | }) 717 | 718 | it('should support selector', async () => { 719 | let Context = createModelContext(10) 720 | let Model = createReactModel(() => { 721 | let initialCount = setupContext(Context) 722 | 723 | expect(initialCount).toBe(20) 724 | 725 | let { store, actions } = setupCounter(initialCount) 726 | 727 | return { store, actions } 728 | }) 729 | let App = (props: { count: number }) => { 730 | let state = Model.useState((state) => state + props.count) 731 | let actions = Model.useActions() 732 | 733 | let handleIncre = () => { 734 | actions.incre() 735 | } 736 | 737 | let handleDescre = () => { 738 | actions.decre() 739 | } 740 | 741 | useEffect(() => { 742 | next() 743 | }) 744 | 745 | return ( 746 | <> 747 | 750 | {state} 751 | 754 | 755 | ) 756 | } 757 | 758 | act(() => { 759 | ReactDOM.render( 760 | 761 | 762 | , 763 | container, 764 | ) 765 | }) 766 | 767 | await deferred.promise 768 | deferred = createDeferred() 769 | 770 | // tslint:disable-next-line: no-unnecessary-type-assertion 771 | let $incre = document.querySelector('#incre') as Element 772 | // tslint:disable-next-line: no-unnecessary-type-assertion 773 | let $decre = document.querySelector('#decre') as Element 774 | // tslint:disable-next-line: no-unnecessary-type-assertion 775 | let $count = document.querySelector('#count') as Element 776 | 777 | expect($count.textContent).toBe('0') 778 | 779 | // tslint:disable-next-line: await-promise 780 | await act(async () => { 781 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 782 | await deferred.promise 783 | deferred = createDeferred() 784 | }) 785 | 786 | expect($count.textContent).toBe('1') 787 | 788 | // tslint:disable-next-line: await-promise 789 | await act(async () => { 790 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 791 | await deferred.promise 792 | deferred = createDeferred() 793 | }) 794 | 795 | expect($count.textContent).toBe('2') 796 | 797 | // tslint:disable-next-line: await-promise 798 | await act(async () => { 799 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 800 | await deferred.promise 801 | deferred = createDeferred() 802 | }) 803 | 804 | expect($count.textContent).toBe('1') 805 | 806 | // tslint:disable-next-line: await-promise 807 | await act(async () => { 808 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 809 | await deferred.promise 810 | deferred = createDeferred() 811 | }) 812 | 813 | expect($count.textContent).toBe('0') 814 | 815 | await act(async () => { 816 | ReactDOM.render( 817 | 818 | 819 | , 820 | container, 821 | ) 822 | }) 823 | 824 | expect($count.textContent).toBe('1') 825 | 826 | // tslint:disable-next-line: await-promise 827 | await act(async () => { 828 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 829 | await deferred.promise 830 | deferred = createDeferred() 831 | }) 832 | 833 | expect($count.textContent).toBe('2') 834 | 835 | await act(async () => { 836 | ReactDOM.render( 837 | 838 | 839 | , 840 | container, 841 | ) 842 | }) 843 | 844 | expect($count.textContent).toBe('3') 845 | }) 846 | 847 | it('support setupModel to access another ReactModel', async () => { 848 | let Model0 = createReactModel(() => { 849 | let { store, actions } = setupCounter(3) 850 | 851 | setupPreloadCallback(() => { 852 | actions.increBy(10) 853 | }) 854 | 855 | return { store, actions } 856 | }) 857 | 858 | let Model1 = createReactModel(() => { 859 | let { store, actions } = setupCounter(4) 860 | 861 | let model1 = setupModel(Model0) 862 | 863 | setupPreloadCallback(() => { 864 | actions.increBy(model1.store.getState()) 865 | }) 866 | 867 | return { store, actions } 868 | }) 869 | 870 | let App = () => { 871 | let model0state = Model0.useState() 872 | let model0actions = Model0.useActions() 873 | 874 | let model1state = Model1.useState() 875 | let model1actions = Model1.useActions() 876 | 877 | let handleIncre = () => { 878 | model0actions.incre() 879 | model1actions.incre() 880 | } 881 | 882 | let handleDescre = () => { 883 | model0actions.decre() 884 | model1actions.decre() 885 | } 886 | 887 | useEffect(() => { 888 | next() 889 | }) 890 | 891 | return ( 892 | <> 893 | 896 | {model0state} 897 | {model1state} 898 | 901 | 902 | ) 903 | } 904 | 905 | let modelContainer = createPureModelContainer() 906 | 907 | let list: ReactModelArgs[] = [ 908 | { 909 | Model: Model0, 910 | }, 911 | { 912 | Model: Model1, 913 | }, 914 | ] 915 | 916 | act(() => { 917 | ReactDOM.render( 918 | 919 | 920 | , 921 | container, 922 | ) 923 | }) 924 | 925 | await deferred.promise 926 | deferred = createDeferred() 927 | 928 | // tslint:disable-next-line: no-unnecessary-type-assertion 929 | let $incre = document.querySelector('#incre') as Element 930 | // tslint:disable-next-line: no-unnecessary-type-assertion 931 | let $decre = document.querySelector('#decre') as Element 932 | // tslint:disable-next-line: no-unnecessary-type-assertion 933 | let $count1 = document.querySelector('#count1') as Element 934 | // tslint:disable-next-line: no-unnecessary-type-assertion 935 | let $count2 = document.querySelector('#count2') as Element 936 | 937 | expect($count1.textContent).toBe('13') 938 | expect($count2.textContent).toBe('17') 939 | 940 | // tslint:disable-next-line: await-promise 941 | await act(async () => { 942 | $incre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 943 | await deferred.promise 944 | deferred = createDeferred() 945 | }) 946 | 947 | expect($count1.textContent).toBe('14') 948 | expect($count2.textContent).toBe('18') 949 | 950 | // tslint:disable-next-line: await-promise 951 | await act(async () => { 952 | $decre.dispatchEvent(new MouseEvent('click', { bubbles: true })) 953 | await deferred.promise 954 | deferred = createDeferred() 955 | }) 956 | 957 | expect($count1.textContent).toBe('13') 958 | expect($count2.textContent).toBe('17') 959 | }) 960 | }) 961 | -------------------------------------------------------------------------------- /packages/react/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useContext, 3 | useState as useReactState, 4 | useReducer, 5 | useLayoutEffect, 6 | useEffect, 7 | useMemo, 8 | useRef, 9 | } from 'react' 10 | 11 | import { 12 | Store, 13 | Initializer, 14 | Model, 15 | InitializerState, 16 | InitializerActions, 17 | createPureModel, 18 | CreatePureModelOptions, 19 | ModelContextValue, 20 | identity, 21 | shallowEqual, 22 | PureModelContainer, 23 | createPureModelContainer, 24 | } from '@pure-model/core' 25 | 26 | const useIsomorphicLayoutEffect = 27 | // tslint:disable-next-line: strict-type-predicates 28 | typeof window !== 'undefined' && 29 | // tslint:disable-next-line: strict-type-predicates 30 | typeof window.document !== 'undefined' && 31 | // tslint:disable-next-line: deprecation & strict-type-predicates 32 | typeof window.document.createElement !== 'undefined' 33 | ? useLayoutEffect 34 | : useEffect 35 | 36 | export type ReactModel = { 37 | isReactModel: boolean 38 | useState: >( 39 | selector?: (state: InitializerState) => TSelected, 40 | compare?: (curr: TSelected, prev: TSelected) => boolean, 41 | ) => TSelected 42 | useActions: () => InitializerActions 43 | Provider: React.FC<{ 44 | model?: Model> 45 | context?: ModelContextValue 46 | preloadedState?: InitializerState 47 | container?: PureModelContainer 48 | fallback?: React.ReactNode 49 | }> 50 | preload: ( 51 | context?: ModelContextValue, 52 | preloadedState?: InitializerState, 53 | container?: PureModelContainer, 54 | ) => Promise<{ 55 | Provider: React.FC 56 | state: InitializerState 57 | model: Model> 58 | }> 59 | create: I 60 | initializer: I 61 | } 62 | 63 | export type ReactModelInitializer = RM extends ReactModel ? I : never 64 | 65 | export type ReactModelState = InitializerState> 66 | 67 | const DefaultValue = Symbol('default-value') 68 | 69 | type DefaultValue = typeof DefaultValue 70 | 71 | export const createReactModel = (initializer: I): ReactModel => { 72 | type State = InitializerState 73 | type Value = { 74 | store: Store 75 | actions: InitializerActions 76 | } 77 | 78 | let ReactContext = React.createContext(null) 79 | 80 | let useState: ReactModel['useState'] = (selector = identity, compare = shallowEqual) => { 81 | type Selector = typeof selector 82 | type SelectedState = ReturnType 83 | let ctx = useContext(ReactContext) 84 | 85 | if (ctx === null) { 86 | throw new Error(`You may forget to attach Provider to component tree before calling Model.useState()`) 87 | } 88 | 89 | let { store } = ctx 90 | // modified from react-redux useSelector 91 | let [_, forceRender] = useReducer((s) => s + 1, 0) 92 | 93 | let latestSubscriptionCallbackError = useRef(null) 94 | let latestSelector = useRef(null) 95 | let latestStoreState = useRef(DefaultValue) 96 | let latestSelectedState = useRef(DefaultValue) 97 | 98 | let storeState = store.getState() 99 | let selectedState: SelectedState | DefaultValue = DefaultValue 100 | 101 | try { 102 | if ( 103 | selector !== latestSelector.current || 104 | storeState !== latestStoreState.current || 105 | latestSubscriptionCallbackError.current 106 | ) { 107 | selectedState = selector(storeState) 108 | } else { 109 | selectedState = latestSelectedState.current 110 | } 111 | } catch (err: any) { 112 | if (latestSubscriptionCallbackError.current) { 113 | err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n` 114 | } 115 | 116 | throw err 117 | } 118 | 119 | useIsomorphicLayoutEffect(() => { 120 | latestSelector.current = selector 121 | latestStoreState.current = storeState 122 | latestSelectedState.current = selectedState 123 | latestSubscriptionCallbackError.current = null 124 | }) 125 | 126 | useIsomorphicLayoutEffect(() => { 127 | let isUnmounted = false 128 | let checkForUpdates = () => { 129 | if (!latestSelector.current) return 130 | if (isUnmounted) return 131 | 132 | if (latestSelectedState.current === DefaultValue) { 133 | throw new Error(`latestSelectedState should not be default value`) 134 | } 135 | 136 | try { 137 | let storeState = store.getState() 138 | let newSelectedState = latestSelector.current(storeState) 139 | 140 | if (compare(newSelectedState, latestSelectedState.current)) { 141 | return 142 | } 143 | 144 | latestSelectedState.current = newSelectedState 145 | latestStoreState.current = storeState 146 | } catch (err: any) { 147 | // we ignore all errors here, since when the component 148 | // is re-rendered, the selectors are called again, and 149 | // will throw again, if neither props nor store state 150 | // changed 151 | latestSubscriptionCallbackError.current = err 152 | } 153 | 154 | forceRender() 155 | } 156 | let unsubscribe = store.subscribe(checkForUpdates) 157 | 158 | return () => { 159 | isUnmounted = true 160 | unsubscribe() 161 | } 162 | }, [store]) 163 | 164 | if (selectedState === DefaultValue) { 165 | throw new Error(`selectedState should not be default value`) 166 | } 167 | 168 | return selectedState 169 | } 170 | 171 | let useActions = () => { 172 | let ctx = useContext(ReactContext) 173 | if (ctx === null) { 174 | throw new Error(`You may forget to attach Provider to component tree before calling Model.useActions()`) 175 | } 176 | return ctx.actions 177 | } 178 | 179 | let Provider: ReactModel['Provider'] = (props) => { 180 | let { children, context, preloadedState, container } = props 181 | 182 | let model = useMemo(() => { 183 | if (props.model) return props.model 184 | let options = { context, preloadedState, container } 185 | return createPureModel(initializer, options) 186 | }, []) 187 | 188 | let value = useMemo(() => { 189 | return { 190 | store: model.store, 191 | actions: model.actions, 192 | } 193 | }, [model]) 194 | 195 | let [isReady, setReady] = useReactState(() => { 196 | return model.isPreloaded() 197 | }) 198 | 199 | useIsomorphicLayoutEffect(() => { 200 | let isUnmounted = false 201 | 202 | if (!model.isPreloaded()) { 203 | // tslint:disable-next-line: no-floating-promises 204 | model.preload().then(() => { 205 | if (isUnmounted) return 206 | setReady(true) 207 | model.start() 208 | }) 209 | } else if (!model.isStarted()) { 210 | model.start() 211 | } 212 | 213 | return () => { 214 | isUnmounted = true 215 | if (model.isStarted()) { 216 | model.finish() 217 | } 218 | } 219 | }, []) 220 | 221 | if (!isReady) return <>{props.fallback ?? null} 222 | 223 | return {children} 224 | } 225 | 226 | let preload: ReactModel['preload'] = async (context, preloadedState, container) => { 227 | let model = createPureModel(initializer, { context, preloadedState, container }) 228 | 229 | await model.preload() 230 | 231 | let state = model.store.getState() 232 | 233 | let PreloadedProvider: React.FC = ({ children }) => { 234 | return ( 235 | 236 | {children} 237 | 238 | ) 239 | } 240 | 241 | return { Provider: PreloadedProvider, state, model } 242 | } 243 | 244 | return { 245 | isReactModel: true, 246 | useState, 247 | useActions, 248 | Provider, 249 | preload, 250 | create: initializer, 251 | initializer, 252 | } 253 | } 254 | 255 | export type ReactModels = { 256 | [key: string]: ReactModel 257 | } 258 | 259 | export type ReactModelArgs = { 260 | Model: ReactModel 261 | context?: ModelContextValue 262 | preloadedState?: any 263 | } 264 | 265 | export type ProviderProps = { 266 | list: ReactModelArgs[] 267 | children: React.ReactNode 268 | fallback?: React.ReactNode 269 | container?: PureModelContainer 270 | } 271 | 272 | export const Provider = ({ list, children, fallback, container }: ProviderProps) => { 273 | let [state, setState] = useReactState<{ Provider: React.FC } | null>(null) 274 | 275 | useIsomorphicLayoutEffect(() => { 276 | let isUnmounted = false 277 | 278 | preload(list, container).then((result) => { 279 | if (isUnmounted) return 280 | setState({ 281 | Provider: result.Provider, 282 | }) 283 | }) 284 | 285 | return () => { 286 | isUnmounted = true 287 | } 288 | }, []) 289 | 290 | let Provider = state?.Provider 291 | 292 | if (!Provider) return <>{fallback ?? null} 293 | 294 | return {children} 295 | } 296 | 297 | export type HydrateProviderProps = ProviderProps 298 | 299 | export const HydrateProvider = ({ 300 | list, 301 | children, 302 | fallback, 303 | container = createPureModelContainer(), 304 | }: HydrateProviderProps) => { 305 | for (const item of list) { 306 | const contextValue = container.get(item.Model) 307 | container.set(item.Model, { 308 | ...contextValue, 309 | context: item.context, 310 | preloadedState: item.preloadedState, 311 | }) 312 | children = ( 313 | 319 | {children} 320 | 321 | ) 322 | } 323 | 324 | return <>{children} 325 | } 326 | 327 | type CombinedReactModelState = ReactModelState[] 328 | 329 | type PreloadResultType = Promise<{ 330 | Provider: React.FC 331 | stateList: CombinedReactModelState 332 | modelList: Model[] 333 | }> 334 | 335 | export const preload = async (list: T, container?: PureModelContainer) => { 336 | container = container ?? createPureModelContainer() 337 | 338 | for (const item of list) { 339 | const contextValue = container.get(item.Model) 340 | container.set(item.Model, { 341 | ...contextValue, 342 | context: item.context, 343 | preloadedState: item.preloadedState, 344 | }) 345 | } 346 | 347 | let resultList = await Promise.all( 348 | list.map((item) => { 349 | let { Model, context, preloadedState } = item 350 | return Model.preload(context, preloadedState, container) 351 | }), 352 | ) 353 | 354 | let ProviderList = resultList.map((item) => item.Provider) 355 | let stateList = resultList.map((item) => item.state) 356 | let modelList = resultList.map((item) => item.model) 357 | 358 | let Provider: React.FC = ({ children }) => { 359 | for (let i = ProviderList.length - 1; i >= 0; i--) { 360 | let Provider = ProviderList[i] 361 | children = {children} 362 | } 363 | 364 | return <>{children} 365 | } 366 | 367 | return ({ 368 | Provider, 369 | stateList: stateList, 370 | modelList, 371 | } as unknown) as PreloadResultType 372 | } 373 | 374 | export const useReactModel = ( 375 | ReactModel: RM, 376 | options?: CreatePureModelOptions> & { 377 | onError?: (error: Error) => any 378 | }, 379 | ): [InitializerState>, InitializerActions>] => { 380 | let model = useMemo(() => { 381 | let model = createPureModel(ReactModel.create, options) 382 | return model as Model 383 | }, []) 384 | 385 | let [state, setState] = useReactState(() => model.store.getState() as InitializerState>) 386 | 387 | useIsomorphicLayoutEffect(() => { 388 | let isUnmounted = false 389 | 390 | let unsubscribe = model.store.subscribe(() => { 391 | setState(model.store.getState()) 392 | }) 393 | 394 | if (!model.isPreloaded()) { 395 | model 396 | .preload() 397 | .then(() => { 398 | if (isUnmounted) return 399 | model.start() 400 | }) 401 | .catch(options?.onError) 402 | } else if (!model.isStarted()) { 403 | model.start() 404 | } 405 | 406 | return () => { 407 | isUnmounted = true 408 | unsubscribe() 409 | if (model.isStarted()) { 410 | model.finish() 411 | } 412 | } 413 | }, []) 414 | 415 | return [state, model.actions as InitializerActions>] 416 | } 417 | 418 | type Constructor = new (...args: any[]) => any 419 | 420 | const createReactModelArgs = ( 421 | Models: MS, 422 | renderable: Renderable, 423 | ) => { 424 | let list: ReactModelArgs[] = Object.values(Models).map((Model) => { 425 | return { 426 | Model, 427 | context: renderable, 428 | } 429 | }) 430 | return list 431 | } 432 | 433 | export const provide = (Models: MS) => (Renderable: C) => { 434 | return class extends Renderable { 435 | ReactModelArgs = createReactModelArgs(Models, this as any) 436 | 437 | render() { 438 | return {super.render()} 439 | } 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./esm", 6 | "composite": true 7 | }, 8 | "references": [ 9 | { 10 | "path": "../core" 11 | } 12 | ], 13 | "include": ["./src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/react/tsconfig.next.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2018", 5 | "outDir": "./next" 6 | }, 7 | "references": [ 8 | { 9 | "path": "../core/tsconfig.next.json" 10 | } 11 | ], 12 | "include": ["./src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/test/README.md: -------------------------------------------------------------------------------- 1 | # @pure-model/test 2 | -------------------------------------------------------------------------------- /packages/test/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/test", 3 | "version": "1.3.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "tslib": { 8 | "version": "2.1.0", 9 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", 10 | "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pure-model/test", 3 | "version": "1.3.1", 4 | "description": "test utils for pure-model", 5 | "main": "dist/index.js", 6 | "esnext": "next/index.js", 7 | "types": "next/index.d.ts", 8 | "author": "Jade Gu", 9 | "license": "MIT", 10 | "peerDependencies": { 11 | "@pure-model/core": "^1.0.0" 12 | }, 13 | "devDependencies": { 14 | "@pure-model/core": "^1.3.1" 15 | }, 16 | "dependencies": { 17 | "tslib": "^2.0.3" 18 | }, 19 | "gitHead": "46f33fbc6a53f32851d35963ddd764c6140a6c99" 20 | } 21 | -------------------------------------------------------------------------------- /packages/test/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createPureModel, setupStore, ModelContextValue } from '@pure-model/core' 2 | 3 | export const testHook = any>(f: F, context?: ModelContextValue): ReturnType => { 4 | let model = createPureModel( 5 | () => { 6 | let { store } = setupStore({ 7 | initialState: 0, 8 | reducers: {}, 9 | }) 10 | 11 | return { 12 | store, 13 | actions: { 14 | result: f(), 15 | }, 16 | } 17 | }, 18 | { 19 | context, 20 | }, 21 | ) 22 | 23 | return model.actions.result as ReturnType 24 | } 25 | -------------------------------------------------------------------------------- /packages/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./esm", 6 | "composite": true 7 | }, 8 | "references": [ 9 | { 10 | "path": "../core" 11 | } 12 | ], 13 | "include": ["./src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/test/tsconfig.next.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2018", 5 | "outDir": "./next" 6 | }, 7 | "references": [ 8 | { 9 | "path": "../core/tsconfig.next.json" 10 | } 11 | ], 12 | "include": ["./src"] 13 | } 14 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'projects/*' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "downlevelIteration": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "importHelpers": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "newLine": "LF", 13 | "strict": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "target": "ES5", 17 | "sourceMap": true, 18 | "esModuleInterop": true, 19 | "stripInternal": true, 20 | "lib": ["dom", "ES5", "ES2015", "ES2016", "ES2017", "ES2018", "ES2019", "esnext"], 21 | "baseUrl": "./packages", 22 | "paths": { 23 | "react": ["node_modules/@types/react"], 24 | "immer": ["node_modules/immer"], 25 | "@pure-model/isomorphic-unfetch": ["./isomorphic-unfetch"], 26 | "@pure-model/*": ["./*/src/index.ts"] 27 | } 28 | }, 29 | "include": ["./packages/**/*"] 30 | } 31 | --------------------------------------------------------------------------------