Repository: foca-js/foca Branch: master Commit: 97b6d78d3d0e Files: 120 Total size: 255.9 KB Directory structure: gitextract_3jrecnzc/ ├── .github/ │ └── workflows/ │ ├── codeql.yml │ ├── prerelease.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .prettierignore ├── .prettierrc.yml ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs/ │ ├── .nojekyll │ ├── CNAME │ ├── _sidebar.md │ ├── advanced.md │ ├── api.md │ ├── changelog.md │ ├── donate.md │ ├── events.md │ ├── home.md │ ├── index.html │ ├── initialize.md │ ├── model.md │ ├── persist.md │ ├── redux-toolkit.md │ ├── test.md │ └── troubleshooting.md ├── package.json ├── src/ │ ├── actions/ │ │ ├── loading.ts │ │ ├── model.ts │ │ ├── persist.ts │ │ └── refresh.ts │ ├── api/ │ │ ├── get-loading.ts │ │ ├── use-computed.ts │ │ ├── use-isolate.ts │ │ ├── use-loading.ts │ │ └── use-model.ts │ ├── engines/ │ │ ├── memory.ts │ │ └── storage-engine.ts │ ├── index.ts │ ├── middleware/ │ │ ├── action-in-action.interceptor.ts │ │ ├── destroy-loading.interceptor.ts │ │ ├── freeze-state.middleware.ts │ │ ├── loading.interceptor.ts │ │ └── model.interceptor.ts │ ├── model/ │ │ ├── clone-model.ts │ │ ├── define-model.ts │ │ ├── enhance-action.ts │ │ ├── enhance-computed.ts │ │ ├── enhance-effect.ts │ │ ├── guard.ts │ │ └── types.ts │ ├── persist/ │ │ ├── persist-gate.tsx │ │ ├── persist-item.ts │ │ └── persist-manager.ts │ ├── reactive/ │ │ ├── computed-value.ts │ │ ├── create-computed-deps.ts │ │ ├── deps-collector.ts │ │ └── object-deps.ts │ ├── redux/ │ │ ├── connect.ts │ │ ├── contexts.ts │ │ ├── create-reducer.ts │ │ ├── foca-provider.tsx │ │ └── use-selector.ts │ ├── store/ │ │ ├── loading-store.ts │ │ ├── model-store.ts │ │ ├── proxy-store.ts │ │ └── store-basic.ts │ └── utils/ │ ├── deep-equal.ts │ ├── get-method-category.ts │ ├── getter.ts │ ├── immer.ts │ ├── is-promise.ts │ ├── is-type.ts │ ├── serialize.ts │ ├── symbol-observable.ts │ ├── to-args.ts │ └── to-promise.ts ├── test/ │ ├── __snapshots__/ │ │ └── serialize.test.ts.snap │ ├── action-in-action.test.tsx │ ├── build.test.ts │ ├── clone.test.ts │ ├── computed.test.ts │ ├── connect.test.tsx │ ├── deep-equal.test.ts │ ├── engine.test.ts │ ├── fixtures/ │ │ ├── equals.ts │ │ └── not-equals.ts │ ├── get-loading.test.ts │ ├── helpers/ │ │ ├── render-hook.tsx │ │ └── slow-engine.ts │ ├── lifecycle.test.ts │ ├── middleware.test.ts │ ├── model.test.ts │ ├── models/ │ │ ├── basic.model.ts │ │ ├── complex.model.ts │ │ ├── computed.model.ts │ │ └── persist.model.ts │ ├── persist.gate.test.tsx │ ├── persist.test.ts │ ├── provider.test.tsx │ ├── serialize.test.ts │ ├── store.test.ts │ ├── typescript/ │ │ ├── computed.check.ts │ │ ├── define-model.check.ts │ │ ├── get-loading.check.ts │ │ ├── persist.check.ts │ │ ├── use-isolate.check.ts │ │ ├── use-loading.check.ts │ │ └── use-model.check.ts │ ├── use-computed.test.ts │ ├── use-isolate.test.tsx │ ├── use-loading.test.ts │ └── use-model.test.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/codeql.yml ================================================ name: 'CodeQL' on: push: branches: ['master'] pull_request: branches: ['master'] schedule: - cron: '29 3 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [javascript] steps: - name: Checkout uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild uses: github/codeql-action/autobuild@v2 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 with: category: '/language:${{ matrix.language }}' ================================================ FILE: .github/workflows/prerelease.yml ================================================ name: Pre Release on: release: types: [prereleased] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: cache: 'pnpm' node-version-file: 'package.json' - run: pnpm install - uses: JS-DevTools/npm-publish@v3 with: token: ${{ secrets.NPM_TOKEN }} access: public tag: next ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: release: types: [released] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: cache: 'pnpm' node-version-file: 'package.json' - run: pnpm install - run: npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }} - run: npm publish ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: - '*' tags-ignore: - '*' pull_request: branches: jobs: formatting: if: "!contains(toJson(github.event.commits), '[skip ci]')" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: 'pnpm' - run: pnpm install - run: npx --no-install prettier --cache --verbose --check . type-checking: if: "!contains(toJson(github.event.commits), '[skip ci]')" strategy: matrix: ts-version: [5.0.x, 5.1.x, 5.2.x, 5.3.x, 5.4.x, 5.5.x, 5.6.x, 5.7.x, 5.8.x] react-version: [18.x, 19.x] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - name: Use Typescript ${{ matrix.ts-version }} & React ${{ matrix.react-version }} uses: actions/setup-node@v4 with: cache: 'pnpm' node-version-file: 'package.json' - run: | pnpm install pnpm add -D \ typescript@${{ matrix.ts-version }} \ @types/react@${{ matrix.react-version }} \ @types/react-dom@${{ matrix.react-version }} - run: npx --no-install tsc --noEmit test: if: "!contains(toJson(github.event.commits), '[skip ci]')" strategy: matrix: node-version: [18.x, 20.x, 22.x] react-version: [18.x, 19.x] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} & React ${{ matrix.react-version }} uses: actions/setup-node@v4 with: cache: 'pnpm' node-version: ${{ matrix.node-version }} - run: | pnpm install pnpm add -D \ react@${{ matrix.react-version }} \ react-dom@${{ matrix.react-version }} \ react-test-renderer@${{ matrix.react-version }} - run: pnpm run test - name: Upload Coverage uses: actions/upload-artifact@v4 if: github.ref == 'refs/heads/master' && strategy.job-index == 0 with: name: coverage path: coverage if-no-files-found: error retention-days: 1 coverage: if: github.ref == 'refs/heads/master' needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download Coverage uses: actions/download-artifact@v4 with: name: coverage path: coverage - uses: codecov/codecov-action@v5 ================================================ FILE: .gitignore ================================================ .idea/ .DS_Store dist/ *.log node_modules/ coverage .nyc_output TODO /test.* /dist/ /build/ ================================================ FILE: .husky/commit-msg ================================================ npx --no-install commitlint --edit $1 ================================================ FILE: .husky/pre-commit ================================================ npx --no-install prettier --cache --check . ================================================ FILE: .prettierignore ================================================ dist/ build/ coverage/ pnpm-lock.yaml _sidebar.md ================================================ FILE: .prettierrc.yml ================================================ semi: true singleQuote: true # Change when properties in objects are quoted. # If at least one property in an object requires quotes, quote all properties. quoteProps: consistent tabWidth: 2 printWidth: 80 endOfLine: lf trailingComma: all bracketSpacing: true # Include parentheses around a sole arrow function parameter. arrowParens: always proseWrap: preserve jsxSingleQuote: false # Put > on the last line instead of at a new line. bracketSameLine: false ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["esbenp.prettier-vscode"] } ================================================ FILE: .vscode/settings.json ================================================ { "typescript.preferences.quoteStyle": "single", "typescript.suggest.autoImports": true, "editor.tabSize": 2, "typescript.tsdk": "node_modules/typescript/lib", "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true } ================================================ FILE: CHANGELOG.md ================================================ ## [4.0.1](https://github.com/foca-js/foca/compare/v4.0.0...v4.0.1)  (2025-03-03) - 发布时未生成dist目录 ## [4.0.0](https://github.com/foca-js/foca/compare/v3.2.0...v4.0.0)  (2025-03-03) - 升级npm包的目标语法 ES5 -> ES2020 - 删除废弃的导出变量 `engines.localStorage` 和 `engines.sessionStorage` - react-native 的最低版本要求为 0.69 - 底层包 redux 升级 4 -> 5 (目标语法为esnext) - 底层包 react-redux 升级 8 -> 9 (目标语法为esnext) ## [3.2.0](https://github.com/foca-js/foca/compare/v3.1.1...v3.2.0)  (2023-10-20) - 增加局部模型钩子 `useIsolate` ## [3.1.1](https://github.com/foca-js/foca/compare/v3.1.0...v3.1.1)  (2023-10-14) - computed内使用数组状态数据时,更新数组不会触发重新计算 - 删除无用类型 `ComputedRef` ## [3.1.0](https://github.com/foca-js/foca/compare/v3.0.0...v3.1.0)  (2023-10-10) - 持久化引擎支持`同步`引擎。因此可以直接使用浏览器内置的 localStorage 和 sessionStorage 接口 ## [3.0.0](https://github.com/foca-js/foca/compare/v2.0.1...v3.0.0)  (2023-10-08) ### 破坏性更新 - 删除hooks函数 `useDefined` - 删除模型内`onDestroy`事件钩子 - 删除持久化`maxAge`配置 ```diff store.init({ persist: [ { key: 'key', - maxAge: 3600, engines: engines.localStorage, models: [], } ] }) ``` - 非hooks状态下计算属性使用执行函数的方式获取 ```diff const model = defineModel('model', { initialState: { firstName: '', lastName: '' }, methods: { myMethod() { - return this.fullName.value; + return this.fullName(); } }, computed: { fullName() { return this.state.firstName + this.state.lastName; }, } }); ``` ### 新特性 - 开启持久化的模型会立即存储initialState - 计算属性支持传递参数 ```diff const model = defineModel('model', { initialState: { firstName: '', lastName: '' }, methods: { myMethod() { + const profile = this.profile(30, 'addr', false); } }, computed: { fullName() { return this.state.firstName + this.state.lastName; }, + profile(age: number, address: string, coding: boolean = true) { + return this.fullName() + '-' + age + address + coding; + }, } }); const App: FC = () => { const fullName = useComputed(model.fullName); + const profile = useComputed(model.profile, 20, 'my-address'); } ``` - 持久化增加 **dump** 和 **load** 两个系列化函数 ```diff const model = defineModel('model', { initialState: { firstName: 'tick', lastName: 'tock' }, persist: { + dump(state) { + return state.firstName; + }, + load(dumpData) { + return { ...this.initialState, firstName: dumpData }; + }, } }); ``` - 持久化新增合并模式 `replace`, `merge`(默认), `deep-merge` ```diff store.init({ persist: [ { key: 'item1', version: '1.0', + merge: 'replace', engine: engines.localStorage, models: [], }, ] }) ``` ### 其它 - npm包转译为ES5语法以兼容更早的浏览器 (#41) - immer版本降级:10.0.2 -> 9.0.21 - react-redux版本升级:8.1.2 -> 8.1.3 ## [2.0.1](https://github.com/foca-js/foca/compare/v2.0.0...v2.0.1)  (2023-08-10) - react-redux 版本从 8.1.1 升级到 8.1.2 (#40) ## [2.0.0](https://github.com/foca-js/foca/compare/v1.3.1...v2.0.0)  (2023-06-26) - 不再兼容 IE 浏览器 - 最小 React 版本为 18 - 最小 TypeScript 版本为 5.0 - immer 版本从 9.0.21 升级到 10.0.2 - react-redux 版本从 8.0.5 升级到 8.1.1 - 删除废弃字段 `actions` 和 `effects` ## [1.3.1](https://github.com/foca-js/foca/compare/v1.3.0...v1.3.1)  (2023-04-14) - 在 onInit 事件内执行的 async method 未储存 loading 状态 (#38) - immer 版本从 9.0.16 升级到 9.0.21 - redux 版本从 4.2.0 升级到 4.2.1 - 支持 typescript@5 ## [1.3.0](https://github.com/foca-js/foca/compare/v1.2.1...v1.3.0)  (2022-12-17) - `setState` 的回调模式支持返回不完整数据 ```typescript defineModel('unique_name', { initialState: { a: 'a', b: 'b' }, methods: { test() { this.setState(() => { return { a: 'xxx' }; }); console.log(this.state); // { a: 'xxx', b: 'b' } }, }, }); ``` - 传给 reducer 的 `initialState` 不再执行深拷贝,而是在开发环境下进行冻结处理以防开发者错误操作 ```typescript const initialState = { a: 'a', b: 'b' }; defineModel('unique_name', { initialState: initialState, }); // 修改失败,严格模式下会报错 // TypeError: Cannot assign to read only property 'a' of object '#' initialState.a = 'xxx'; ``` ## [1.2.1](https://github.com/foca-js/foca/compare/v1.2.0...v1.2.1)  (2022-11-11) - 销毁模型时可能触发`onChange`勾子 ## [1.2.0](https://github.com/foca-js/foca/compare/v1.1.0...v1.2.0)  (2022-11-10) - 重命名 actions 为 reducers (#29) - 重命名 effects 为 methods (#29) - exports 导出 package.json 文件 - 不再支持typescript@4.4 ## [1.1.0](https://github.com/foca-js/foca/compare/v1.0.2...v1.1.0)  (2022-07-07) - setState 支持传入非完整数据 ```typescript const initialState = { a: 1, b: 'x' }; this.setState({ a: 2, b: 'y' }); // state === { a: 2, b: 'y' } this.setState({ a: 123 }); // state === { a: 123, b: 'y' } this.setState({ b: 'hello' }); // state === { a: 123, b: 'hello' } ``` ## [1.0.2](https://github.com/foca-js/foca/compare/v1.0.1...v1.0.2)  (2022-06-30) - 修复在 react17 下 action-in-action 中间件可能报错的问题 (#20) ## [1.0.1](https://github.com/foca-js/foca/compare/v1.0.0...v1.0.1)  (2022-06-29) - 完善 action-in-action 的报错信息 ## [1.0.0](https://github.com/foca-js/foca/compare/v0.12.3...v1.0.0)  (2022-06-17) ### 不兼容更新 - 删除已废弃函数 ~~`useDefinedModel`~~,代替函数:`useDefined` - 删除已废弃属性 ~~`hooks`~~,代替属性:`events` - 删除已废弃的方法 ~~`assign`~~ ,代替方法:`room` - 支持最小 React 版本为 `16.14.0` ### 特性 - 在开发环境下检测 `action in action` 的错误操作并抛出异常 ## [0.12.3](https://github.com/foca-js/foca/compare/v0.12.2...v0.12.3)  (2022-06-15) - 废弃函数 `useDefinedModel`,并新增函数 `useDefined` 作为代替 - 修复计算属性在返回 **原始数组** 或者 **原始对象** 时无法访问的问题 - 优化 initialState 深拷贝速度 ## [0.12.2](https://github.com/foca-js/foca/compare/v0.12.1...v0.12.2)  (2022-06-08) - initialState 现在支持传递 `undefined` 值 (#17) ## [0.12.1](https://github.com/foca-js/foca/compare/v0.12.0...v0.12.1)  (2022-05-27) - 开发模式下局部模型的名称携带组件名称以方便调试 ## [0.12.0](https://github.com/foca-js/foca/compare/v0.11.7...v0.12.0)  (2022-05-26) - 增加局部模型接口 `useDefinedModel`,数据跟随组件挂载和释放 - 提升计算属性的脏检测效率 ## [0.11.7](https://github.com/foca-js/foca/compare/v0.11.6...v0.11.7)  (2022-05-17) - 优化 loading 写入性能 - 修复 react 命名导出在 node ESM 环境中可能报错的风险 - 打包不再使用 `.mjs` 后缀,设置新的 package.json 同样可以识别成 ESM - 不再导出`combine`方法,因为几乎用不上 ## [0.11.6](https://github.com/foca-js/foca/compare/v0.11.5...v0.11.6)  (2022-05-13) - 使用`.js`文件以适配旧的打包工具 ## [0.11.5](https://github.com/foca-js/foca/compare/v0.11.3...v0.11.5)  (2022-05-10) - 优化持久化逻辑 - 使用中文提示错误和警告 - 废弃 effects 中的 `assign` 方法,并新增 `room` 作为代替 ```diff const testModel = defineModel('test', { effects: { xyz(id: number) {}, }, }); - testModel.xyz.assign(1).execute(1) + testModel.xyz.room(1).execute(1) - useLoading(testModel.xyz.assign).find(1) + useLoading(testModel.xyz.room).find(1) ``` ## [0.11.3](https://github.com/foca-js/foca/compare/v0.11.2...v0.11.3)  (2022-05-07) - 修复 setTimeout 类型 (#15) ## [0.11.2](https://github.com/foca-js/foca/compare/v0.11.1...v0.11.2)  (2022-05-06) - 提升 computed 脏检查性能 ## [0.11.1](https://github.com/foca-js/foca/compare/v0.11.0...v0.11.1)  (2022-04-29) - 优化 computed in computed 时的缓存对比策略 - 废弃属性 `hooks` 并推荐使用 `events` 以防止和 react-hooks 在名字上混淆。属性 `hooks` 将在 1.0.0 版本发布时删除。 ```diff export const testModel = defineModel('test', { initialState, - hooks: {}, + events: {}, }); ``` ## [0.11.0](https://github.com/foca-js/foca/compare/v0.10.2...v0.11.0)  (2022-04-24) - 模型新增生命周期 `onChange(prevState, nextState)` 以监听当前模型的状态变化 - 模型新增 computed 计算属性,并新增 `useComputed` 配合使用 ## [0.10.2](https://github.com/foca-js/foca/compare/v0.10.0...v0.10.2)  (2022-04-21) - 使用新的文件打包方案以解决在 node 环境下无法使用 ESM 的问题 - 使用简单的 JSON.stringify 和 JSON.parse 处理初始值的深度拷贝任务 ## [0.10.0](https://github.com/foca-js/foca/compare/v0.9.3...v0.10.0)  (2022-04-15) - 支持 react-18 并发渲染 ## [0.9.3](https://github.com/foca-js/foca/compare/v0.9.2...v0.9.3)  (2022-04-14) - 持久化数据有可能被初始值覆盖 - 模型名称唯一性检测 ## [0.9.2](https://github.com/foca-js/foca/compare/v0.9.1...v0.9.2)  (2021-12-23) - 增强初始化时的 compose 类型 - 设置 sideEffects 以适配 tree-shaking - 日志字符串 `redux-devtools` 现在只在非生产环境生效 ## [0.9.1](https://github.com/foca-js/foca/compare/v0.9.0...v0.9.1)  (2021-12-20) - 在开发环境下允许多次执行`store.init()`以适应热重载 - 持久化解析失败时一律抛出异常 ## [0.9.0](https://github.com/foca-js/foca/compare/v0.8.1...v0.9.0)  (2021-12-17) - [Breaking] 删除 `useMeta()`, `getMeta()` 接口,移除 meta 概念 - 修复 IDE 中 React 组件调用的模型方法无法点击跳转回模型的问题 ## [0.8.1](https://github.com/foca-js/foca/compare/v0.8.0...v0.8.1)  (2021-12-17) - 私有方法在运行时也不该被导出 ## [0.8.0](https://github.com/foca-js/foca/compare/v0.7.1...v0.8.0)  (2021-12-17) - 支持私有方法,在模型外部使用会触发 TS 报错(属性不存在) ## [0.7.1](https://github.com/foca-js/foca/compare/v0.7.0...v0.7.1)  (2021-12-13) - 通过缓存提升 useModel 的性能 ## [0.7.0](https://github.com/foca-js/foca/compare/v0.6.0...v0.7.0)  (2021-12-12) - [Breaking] ctx.dispatch 重命名为 ctx.setState ```diff difineModel('name', { effects: { foo() { - this.dispatch({ count: 1 }); + this.setState({ count: 1 }); } } }) ``` - 删除部分继承的 Error 类,直接使用原生 Error - 过期的持久化数据不再自动重新生成 ## [0.6.0](https://github.com/foca-js/foca/compare/v0.5.0...v0.6.0)  (2021-12-10) - [Breaking] 删除 Map/Set 特性 - 内置并简化深对比函数 ## [0.5.0](https://github.com/foca-js/foca/compare/v0.4.1...v0.5.0)  (2021-12-09) - [Breaking] effect.meta() 重命名为 effect.assign() ```diff - model.effect.meta(ID).execute(...); + model.effect.assign(ID).execute(...); ``` - [Breaking] {get|use}Meta 和 {get|use}Loading 的 pick() 重命名为 find() ```diff - useLoading(model.effect, 'pick').pick(ID) + useLoading(model.effect.assign).find(ID) - useLoading(model.effect, 'pick', ID) + useLoading(model.effect.assign, ID) ``` - 取消导出部分 redux 模块 - 增加 metas 和 loadings 在开发环境下的不可变特性 ## [0.4.1](https://github.com/foca-js/foca/compare/v0.4.0...v0.4.1)  (2021-12-08) - 修复循环引用问题 ## [0.4.0](https://github.com/foca-js/foca/compare/v0.3.6...v0.4.0)  (2021-12-08) - [Breaking] 删除重复且难以理解的 api `useLoadings`, `useMetas`, `getLoadings`, `getMetas` ## [0.3.6](https://github.com/foca-js/foca/compare/v0.3.5...v0.3.6)  (2021-12-04) - 模型增加钩子函数 onInit - 修复 getLoadings 和 useLoadings 始终返回新对象的问题 ## [0.3.5](https://github.com/foca-js/foca/compare/v0.3.4...v0.3.5)  (2021-12-01) - 使用 Object.assign 代替插件包 object-assign - 增加 combine() 函数以覆盖状态库共存时使用 connect() 高阶组件的场景 ## [0.3.4](https://github.com/foca-js/foca/compare/v0.3.3...v0.3.4)  (2021-11-29) - 提升 useModel 在传递单个模型时的执行效率 - useModel 没有传回调函数时,不再提供对比算法参数 ## [0.3.3](https://github.com/foca-js/foca/compare/v0.3.2...v0.3.3)  (2021-11-28) - react 最小依赖版本现在为 16.9.0 - 优化 dispatch 性能 - 引入 process.env.NODE_ENV 以减少生产环境的体积 ## [0.3.2](https://github.com/foca-js/foca/compare/v0.3.1...v0.3.2)  (2021-11-27) - 精简代码 - 内置插件包 symbol-observable ## [0.3.1](https://github.com/foca-js/foca/compare/v0.3.0...v0.3.1)  (2021-11-26) - 升级 immer 版本 - 重写 action 和 effect 增强函数 ## [0.3.0](https://github.com/foca-js/foca/compare/v0.2.3...v0.3.0)  (2021-11-24) - [Breaking] keepStateFromRefresh 重命名为 skipRefresh - 修复 dispatch meta 时未命中拦截条件 - 重构拦截器 - 重构 reducer 生成器 - 完善测试用例 ## [0.2.3](https://github.com/foca-js/foca/compare/v0.2.2...v0.2.3)  (2021-11-23) - 对 action 进行拦截以避免无意义的状态更新和组件重渲染 ## [0.2.2](https://github.com/foca-js/foca/compare/v0.2.1...v0.2.2)  (2021-11-22) - meta 数据使用新的内部 store 存储 ## [0.2.1](https://github.com/foca-js/foca/compare/v0.2.0...v0.2.1)  (2021-11-22) - 异步函数中的`metaId()`重命名为`meta()` ## [0.2.0](https://github.com/foca-js/foca/compare/v0.1.5...v0.2.0)  (2021-11-21) - 增加及时状态方法:`getLoading`, `getLoadings`, `getMeta`, `getMetas` - 增加 hooks 方法:`useLoadings`, `useMetas` - meta 增加 type 字段,并由此检测 loading 状态 ## [0.1.5](https://github.com/foca-js/foca/compare/v0.1.4...v0.1.5)  (2021-11-19) - useModel 可以手动传入对比算法,未传则由框架动态决策 - 提升异步状态追踪性能 - 提升数据合并性能 ## [0.1.4](https://github.com/foca-js/foca/compare/v0.1.3...v0.1.4)  (2021-11-13) - 删除 tslib 依赖 - 定义模型时的属性 state 重构为 initialState,防止和 actions 的 state 变量名重叠以及 eslint 规则报错。 ## [0.1.3](https://github.com/foca-js/foca/compare/v0.1.2...v0.1.3)  (2021-11-02) - action 的返回类型更新为 AnyAction - 内部方法 dispatch 现支持**直接**传入完整的新 state。如果你只想更新 state 的某个值,则仍然使用回调。 - 修改异步方法报错时 action.type 的文字 ## [0.1.2](https://github.com/foca-js/foca/compare/v0.1.1...v0.1.2)  (2021-11-01) - 存储引擎可自定义 keyPrefix 参数 ## [0.1.1](https://github.com/foca-js/foca/compare/v0.1.0...v0.1.1)  (2021-10-31) - 存储引擎放回当前库 ## [0.1.0](https://github.com/foca-js/foca/compare)  (2021-10-31) - 模块化 - 持久化 - 支持类型提示 - 支持 Map/Set - 支持 immer - 与其他 redux 库共存,方便迁移 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021-2023 geekact Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # FOCA 流畅的 react 状态管理库,基于[redux](https://github.com/reduxjs/redux)和[react-redux](https://github.com/reduxjs/react-redux)。简洁、极致、高效。 [![npm peer react version](https://img.shields.io/npm/dependency-version/foca/peer/react?logo=react)](https://github.com/facebook/react) [![npm peer typescript version](https://img.shields.io/npm/dependency-version/foca/peer/typescript?logo=typescript)](https://github.com/microsoft/TypeScript) [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/foca-js/foca/test.yml?branch=master&label=test&logo=vitest)](https://github.com/foca-js/foca/actions) [![Codecov](https://img.shields.io/codecov/c/github/foca-js/foca?logo=codecov)](https://codecov.io/gh/foca-js/foca) [![npm](https://img.shields.io/npm/v/foca?logo=npm)](https://www.npmjs.com/package/foca) [![npm](https://img.shields.io/npm/dt/foca?logo=codeforces)](https://www.npmjs.com/package/foca) [![npm bundle size (version)](https://img.shields.io/bundlephobia/minzip/foca?label=bundle+size&cacheSeconds=3600&logo=esbuild)](https://bundlephobia.com/package/foca@latest) [![License](https://img.shields.io/github/license/foca-js/foca?logo=open-source-initiative)](https://github.com/foca-js/foca/blob/master/LICENSE) [![Code Style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?logo=prettier)](https://github.com/prettier/prettier)
![mind map](https://raw.githubusercontent.com/foca-js/foca/master/docs/mindMap.svg) # 特性 - 模块化开发,导出即可使用 - 专注 TS 极致体验,超强的类型自动推导 - 内置 [immer](https://github.com/immerjs/immer) 响应式修改数据 - 支持计算属性,自动收集依赖,可传参数 - 自动管理异步函数的 loading 状态 - 可定制的多引擎数据持久化 - 支持局部模型,用完即扔 - 支持私有方法 # 使用环境 - Browser - React Native - Taro - Electron # 安装 ```bash # npm npm install foca # yarn yarn add foca # pnpm pnpm add foca ``` # 初始化 ```typescript import { store } from 'foca'; store.init(); ``` # 创建模型 ### reducers 修改数据 ```typescript import { defineModel } from 'foca'; const initialState: { count: number } = { count: 0 }; export const counterModel = defineModel('counter', { initialState, reducers: { // 支持无限参数 plus(state, value: number, times: number = 1) { state.count += value * times; }, minus(state, value: number) { return { count: state.count - value }; }, }, }); ``` ### computed 计算属性 ```typescript export const counterModel = defineModel('counter', { initialState, // 自动收集依赖 computed: { filled() { return Array(this.state.count) .fill('') .map((_, index) => index) .map((item) => item * 2); }, }, }); ``` ### methods 组合逻辑 ```typescript export const counterModel = defineModel('counter', { initialState, reducers: { increment(state) { state.count += 1; }, }, methods: { async incrementAsync() { await this._sleep(100); this.increment(); // 也可直接修改状态而不通过reducers,仅在内部使用 this.setState({ count: this.state.count + 1 }); this.setState((state) => { state.count += 1; }); return 'OK'; }, // 私有方法,外部使用时不会提示该方法 _sleep(duration: number) { return new Promise((resolve) => { setTimeout(resolve, duration); }); }, }, }); ``` ### events 事件回调 ```typescript export const counterModel = defineModel('counter', { initialState, events: { // 模型初始化 onInit() { console.log(this.state); }, // 模型数据变更 onChange(prevState, nextState) {}, }, }); ``` # 使用 ### 在 function 组件中使用 ```tsx import { FC, useEffect } from 'react'; import { useModel, useLoading } from 'foca'; import { counterModel } from './counterModel'; const App: FC = () => { const count = useModel(counterModel, (state) => state.count); const loading = useLoading(counterModel.incrementAsync); useEffect(() => { counterModel.incrementAsync(); }, []); return (
counterModel.plus(1)}> {count} {loading ? 'Loading...' : null}
); }; export default App; ``` ### 在 class 组件中使用 ```tsx import { Component } from 'react'; import { connect, getLoading } from 'foca'; import { counterModel } from './counterModel'; type Props = ReturnType; class App extends Component { componentDidMount() { counterModel.incrementAsync(); } render() { const { count, loading } = this.props; return (
counterModel.plus(1)}> {count} {loading ? 'Loading...' : null}
); } } const mapStateToProps = () => { return { count: counterModel.state.count, loading: getLoading(counterModel.incrementAsync), }; }; export default connect(mapStateToProps)(App); ``` # 文档 https://foca.js.org # 例子 沙盒在线试玩:https://codesandbox.io/s/foca-demos-e8rh3
React 案例仓库:https://github.com/foca-js/foca-demo-web
RN 案例仓库:https://github.com/foca-js/foca-demo-react-native
Taro 案例仓库:https://github.com/foca-js/foca-demo-taro # 生态 #### 网络请求 | 仓库 | 版本 | 描述 | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------- | | [axios](https://github.com/axios/axios) | [![npm](https://img.shields.io/npm/v/axios)](https://www.npmjs.com/package/axios) | 当下最流行的请求库 | | [foca-axios](https://github.com/foca-js/foca-axios) | [![npm](https://img.shields.io/npm/v/foca-axios)](https://www.npmjs.com/package/foca-axios) | axios++ 支持 节流、缓存、重试 | | [foca-openapi](https://github.com/foca-js/foca-openapi) | [![npm](https://img.shields.io/npm/v/foca-openapi)](https://www.npmjs.com/package/foca-openapi) | 使用openapi文档生成请求服务 | #### 持久化存储引擎 | 仓库 | 版本 | 描述 | 平台 | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -------- | | [react-native-async-storage](https://github.com/react-native-async-storage/async-storage) | [![npm](https://img.shields.io/npm/v/@react-native-async-storage/async-storage)](https://www.npmjs.com/package/@react-native-async-storage/async-storage) | React-Native 持久化引擎 | RN | | [foca-taro-storage](https://github.com/foca-js/foca-taro-storage) | [![npm](https://img.shields.io/npm/v/foca-taro-storage)](https://www.npmjs.com/package/foca-taro-storage) | Taro 持久化引擎 | Taro | | [localforage](https://github.com/localForage/localForage) | [![npm](https://img.shields.io/npm/v/localforage)](https://www.npmjs.com/package/localforage) | 浏览器端持久化引擎,支持 IndexedDB, WebSQL | Web | | [foca-electron-storage](https://github.com/foca-js/foca-electron-storage) | [![npm](https://img.shields.io/npm/v/foca-electron-storage)](https://www.npmjs.com/package/foca-electron-storage) | Electron 持久化引擎 | Electron | | [foca-mmkv-storage](https://github.com/foca-js/foca-mmkv-storage) | [![npm](https://img.shields.io/npm/v/foca-mmkv-storage)](https://www.npmjs.com/package/foca-mmkv-storage) | 基于 mmkv 的持久化引擎 | RN | | [foca-cookie-storage](https://github.com/foca-js/foca-cookie-storage) | [![npm](https://img.shields.io/npm/v/foca-cookie-storage)](https://www.npmjs.com/package/foca-cookie-storage) | Cookie 持久化引擎 | Web | #### 日志 | 仓库 | 版本 | 描述 | 平台 | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------- | | [@redux-devtools/extension](https://github.com/reduxjs/redux-devtools) | [![npm](https://img.shields.io/npm/v/@redux-devtools/extension)](https://www.npmjs.com/package/@redux-devtools/extension) | 浏览器日志插件 | Web, RN | | [react-native-debugger](https://github.com/jhen0409/react-native-debugger) | [![npm](https://img.shields.io/npm/v/react-native-debugger)](https://www.npmjs.com/package/react-native-debugger) | 日志应用程序 | RN | | [redux-logger](https://github.com/LogRocket/redux-logger) | [![npm](https://img.shields.io/npm/v/redux-logger)](https://www.npmjs.com/package/redux-logger) | 控制台输出日志 | Web, RN, Taro | # 常见疑问 ### 函数里 this 的类型是 any 答:需要在文件 **tsconfig.json** 中开启`"strict": true`或者`"noImplicitThis": true` --- 更多答案请[查看文档](https://foca.js.org/#/troubleshooting) # 捐赠 开源不易,升级维护框架和解决各种 issue 需要十分多的精力和时间。希望能得到你的支持,让项目处于良性发展的状态。捐赠地址:[二维码](https://foca.js.org#donate)

arcsin1

xiongxliu
================================================ FILE: docs/.nojekyll ================================================ ================================================ FILE: docs/CNAME ================================================ foca.js.org ================================================ FILE: docs/_sidebar.md ================================================ * [关于Foca](/) - [更新日志](/changelog.md) * [开始使用](/initialize.md) - [模型](/model.md) * [接口](/api.md) - [事件钩子](/events.md) * [持久化](/persist.md) - [进阶用法](/advanced.md) * [问题解答](/troubleshooting.md) - [编写测试](/test.md) * [与Redux-Toolkit对比](/redux-toolkit.md) - [捐赠](/donate.md) ================================================ FILE: docs/advanced.md ================================================ # # 克隆模型 虽然比较不常用,但有时候为了同一个页面的不同模块能独立使用模型数据,你就得需要复制这个模型,并把名字改掉。其实也不用这么麻烦,foca 给你来个惊喜: ```typescript import { defineModel, cloneModel } from 'foca'; // 你打算用在各个普通页面里。 cosnt userModel = defineModel('users', { ... }); // 你打算用在通用的用户列表弹窗里。 const user1Model = cloneModel('users1', userModel); // 你打算用在页头或页脚模块里。 const user2Model = cloneModel('users2', userModel); ``` 共享方法但状态是独立的,这是个不错的主意,你只要维护一份代码就行了。 克隆时支持修改 `initialState, events, persist, skipRefresh` 这些属性 ```typescript const user3Model = cloneModel('users3', userModel, { initialState: {...}, ... }); const user3Model = cloneModel('users3', userModel, (prev) => { return { initialState: { ...prev.initialState, customData: 'xyz', }, ... } }); ``` # 局部模型 通过`defineModel`和`cloneModel`创建的模型均为全局类别的模型,数据一直保持在内存中,直到应用关闭或者退出才会释放,对于比较大的项目,这可能会有性能问题。所以有时候你其实想要一种`用完就扔`的模型,即在 React 组件初始化时把模型数据扔到 store 中,当 React 组件被销毁时,模型的数据也跟着销毁。现在,局部模型很适合你的需求: ```diff import { useEffect } from 'react'; import { defineModel, useIsolate } from 'foca'; // test.model.ts export const testModel = defineModel('test', { initialState: { count: 0 }, reducers: { plus(state, value: number) { state.count += value; }, }, }); // App.tsx const App: FC = () => { + const model = useIsolate(testModel); const { count } = useModel(model); useEffect(() => { model.plus(1); }, []); return
{count}
; }; ``` 只需增加一行代码的工作量,利用 `useIsolate` 函数根据全局模型创建一个新的局部模型。局部模型拥有一份独立的状态数据,任何操作都不会影响到原来的全局模型,而且会随着组件一起 `挂载/销毁`,能有效降低内存占用。 另外,别忘了模型上还有两个对应的事件`onInit`和`onDestroy`可以使用 ```typescript export const testModel = defineModel('test', { initialState: { count: 0 }, events: { onInit() { // 全局模型创建时触发 // 局部模型随组件一起挂载时触发 }, onDestroy() { // 局部模型随组件一起销毁时触发 }, }, }); ``` !> 如果不需要持久化,那么它可以完全代替克隆模型 # loadings 默认地,methods 函数只会保存一份执行状态,如果你在同一时间多次执行同一个函数,那么状态就会互相覆盖,产生错乱的数据。如果现在有 10 个按钮,点击每个按钮都会执行`model.methodX(id)`,那么我们如何知道是哪个按钮执行的呢?这时候我们需要为执行状态开辟一个独立的存储空间,让同一个函数拥有多个状态互不干扰。 ```tsx import { useLoading } from 'foca'; const App: FC = () => { const loadings = useLoading(model.myMethod.room); const handleClick = (id: number) => { model.myMethod.room(id).execute(id); }; return (
); }; ``` 这种场景也常出现在一些表格里,每一行通常都带有切换(switch UI)控件,点击后该控件需要被禁用或者出现 loading 图标,提前是你得知道是谁。 如果你能确定 find 的参数,那么也可以直接传递: ```typescript // 适用于明确地知道编号的场景,比如是从组件props直接传入 const loading = useLoading(model.myMethod.room, 100); // boolean // 适用于列表,编号只能在for循环中获取的场景 const loadings = useLoading(model.myMethod.room); list.forEach(({ id }) => { const loading = loadings.find(id); }); ``` # 重置所有数据 当用户退出登录时,你需要清理与用户相关的一些数据,然后把页面切换到`登录页`。清理操作其实是比较麻烦的,首先 model 太多了,然后就是后期也可能再增加其它模型,不可能手动一个个清理。这时候可以用上 store 自带的方法: ```diff import { store } from 'foca'; // onLogout是你的业务方法 onLogout().then(() => { + store.refresh(); }); ``` 一个方法就能把所有数据都恢复成初始值状态,太方便了吧? 重置时,你也可以保留部分模型的数据不被影响(可能是一些全局的配置数据),在相应的模型下加入关键词`skipRefresh`即可: ```diff defineModel('my-global-model', { initialState: {}, + skipRefresh: true, }); ``` 对了,如果你实在是想无情地删除所有数据(即无视 skipRefresh 参数),那么就用`强制模式`好了: ```typescript store.refresh(true); ``` # 私有方法 我们总是会想抽出一些逻辑作为独立的方法调用,但又不想暴露给模型外部使用,而且方法一多,调用方法时 TS 会提示长长的一串方法列表,显得十分混乱。是时候声明一些私有方法了,foca 使用约定俗成的`前置下划线(_)`来代表私有方法 ```typescript const userModel = defineModel('users', { initialState, reducers: { addUser(state, user: UserItem) {}, _deleteUser(state, userId: number) {}, }, methods: { async retrieve(id: number) { const user = await http.get(`/users/${id}`); this.addUser(user); // 私有reducers方法 this._deleteUser(15); // 私有methods方法 this._myLogic(); // 私有computed变量 this._fullname.value; }, async _myLogic() {}, }, computed: { _fullname() {}, }, }); userModel.retrieve; // OK userModel._deleteUser; // 报错了,找不到属性 _deleteUser userModel._myLogic; // 报错了,找不到属性 _myLogic userModel._fullname; // 报错了,找不到属性 _fullname ``` 对外接口变得十分清爽,减少出错概率的同时,也提升了数据的安全性。 ================================================ FILE: docs/api.md ================================================ # # useModel 使用频率::star2::star2::star2::star2::star2: 你绝对想不到在 React 组件中获取一个模型的数据有多简单,试试: ```tsx // File: App.tsx import { FC } from 'react'; import { useModel } from 'foca'; import { userModel } from './userModel'; const App: FC = () => { const users = useModel(userModel); return ( <> {users.map((user) => (
{user.name}
))} ); }; export default App; ``` 就这么一小行,朴实无华,你也可以对数据进行改造,返回你当前需要的数据: ```typescript const userIds = useModel(userModel, (state) => state.map((item) => item.id)); ``` 不错,你拿到了所有用户的 id 编号,同时 userIds 的类型会自动推断为`number[]`。 !> 只要 useModel() 返回的最终值不变,就不会触发 react 组件刷新。foca 使用 **深对比** 来判断值是否变化。 --- 等等,事情还没结束!useModel 还能增加更多模型上去,这样可以少用几个 hooks: ```typescript const { users, agents, teachers } = useModel( userModel, agentModel, teacherModel, ); ``` 传递超过一个模型作为参数时,返回值将变成对象,而 key 就是模型的名称,value 就是模型的 state。这很酷,而且你仍然不用担心类型的问题。 如果你有一些数据需要多个模型才能计算出来,那么现在就是 useModel 大展身手的时候了: ```typescript const count = useModel( userModel, agentModel, teacherModel, (users, agents, teachers) => users.length + agents.length + teachers.length, ); ``` 返回的是一个数字,如假包换,TS 也自动推导出来了这是`number`类型 # useLoading 使用频率::star2::star2::star2::star2::star2: methods 函数大部分是异步的,你可能正在函数里执行一个请求 api 的操作。在用户等待期间,你需要为用户渲染`loading...`之类的字样或者图标以缓解用户的焦虑心情。利用 foca 提供的逻辑,你可以轻松地知道某个函数是否正在执行: ```tsx import { useLoading } from 'foca'; const App: FC = () => { const loading = useLoading(userModel.getUser); const handleClick = () => { userModel.getUser(1); }; return
{loading ? 'loading...' : 'OK'}
; }; ``` 每次开始执行`getUser`函数,loading 自动变成`true`,从而触发组件刷新。 在某些编辑表单的场景,很可能会同时有新增和修改两种操作。对于 restful API,你需要写两个异步函数来处理请求。但你的表单保存按钮只有一个,很显然不管是新增还是修改,你都想让保存按钮渲染`保存中...`的字样。 为了减少业务代码,useLoading 允许你传入多个异步函数,只要有任何一个函数在执行,那么最终值就会是 true。 ```typescript const loading = useLoading(userModel.create, userModel.update, ...); ``` # useComputed 使用频率::star2::star2::star2: 配合 computed 计算属性使用。携带参数的情况下,则从第二个参数开始依次传入 ```tsx import { useComputed } from 'foca'; // 假设有这么一个model const userModel = defineModel('user', { initialState: { firstName: 'tick', lastName: 'tock', }, computed: { fullName() { return this.state.firstName + '.' + this.state.lastName; }, profile(age: number) { return this.fullName() + '-' + age; }, }, }); const App: FC = () => { // 只有当 firstName 或者 lastName 变化,才会重新刷新该组件 const fullName = useComputed(userModel.fullName); // 这里会有TS提示你应该传几个参数,以及参数类型 const profile = useComputed(userModel.profile, 24); return
{fullName}
; }; ``` # getLoading 使用频率::star2: 如果想实时获取异步函数的执行状态,则可以通过 `getLoading(...)` 的方式获取。它与 **useLoading** 的唯一区别就是是否hooks。 ```typescript const loading = getLoading(userModel.create); ``` # connect 使用频率::star2: 如果你写烦了函数式组件,偶尔想写一下 class 组件,那么 foca 已经为你准备好了`connect()`函数。如果不知道这是什么,可以参考[react-redux](https://github.com/reduxjs/react-redux)的文档。事实上,我们内置了这个库并对其做了一些封装。 ```typescript import { PureComponent } from 'react'; import { connect } from 'foca'; import { userModel } from './userModel'; type Props = ReturnType; class App extends PureComponent { render() { const { users, loading } = this.props; if (loading) { return

Loading...

; } return

Hello, {users.length} people

; } } const mapStateToProps = () => { return { users: userModel.state, loading: getLoading(userModel.fetchUser), }; }; export default connect(mapStateToProps)(App); ``` 没有了 hooks 的帮忙,我们只能从模型或者方法上获取实时的数据。但只要你是在 mapStateToProps 中获取的数据,foca 就会自动为你更新并注入到组件里。 ================================================ FILE: docs/changelog.md ================================================ # 更新日志 [changelog](https://raw.githubusercontent.com/foca-js/foca/master/CHANGELOG.md ':include') ================================================ FILE: docs/donate.md ================================================ # 捐赠 开源不易,升级维护框架和解决各种 issue 需要十分多的精力和时间。希望能得到你的支持,让项目处于良性发展的状态。 > 捐赠时请备注你的 github 账号,对于每一个捐赠者,我都会放到 README.md 以及当前页表示感谢。 ### 二维码
### 鸣谢

arcsin1

xiongxliu
================================================ FILE: docs/events.md ================================================ 每个模型都有针对自身的事件回调,在某些复杂的业务场景下,事件和其它属性的组合将变得十分灵活。 ## onInit 当 store 初始化完成 并且持久化(如果有)数据已经恢复时,onInit 就会被自动触发。你可以调用 methods 或者 reducers 做一些额外操作。 ```typescript import { defineModel } from 'foca'; // 如果是持久化的模型,则初始值不一定是0 const initialState = { count: 0 }; export const myModel = defineModel('my', { initialState, reducers: { add(state, step: number) { state.count += step; }, }, methods: { async requestApi() { const result = await http.get('/path/to'); // ... }, }, events: { onInit() { this.add(10); this.requestApi(); }, }, }); ``` ## onChange 每当 state 有变化时的回调通知。初始化(onInit)执行之前不会触发该回调。如果在 onInit 中做了修改 state 的操作,则会触发该回调。 ```typescript import { defineModel } from 'foca'; const initialState = { count: 0 }; export const testModel = defineModel('test', { initialState, reducers: { add(state, step: number) { state.count += step; }, }, methods: { _notify() { // do something }, }, events: { onChange(prevState, nextState) { if (prevState.count !== nextState.count) { // 达到watch的效果 this._notify(); } }, }, }); ``` ## onDestroy 模型数据从 store 卸载时的回调通知。onDestroy 事件只针对[局部模型](/advanced?id=局部模型),即通过`useIsolate`这个 hooks api 创建的模型才会触发,因为局部模型是跟随组件一起创建和销毁的。 注意,当触发 onDestroy 回调时,模型已经被卸载了,所以无法再拿到当前数据,而且`this`上下文也被限制使用了。 ```typescript import { defineModel } from 'foca'; export const testModel = defineModel('test', { initialState: { count: 0 }, events: { onDestroy(modelName) { console.log('Destroyed', modelName); }, }, }); ``` ================================================ FILE: docs/home.md ================================================ # FOCA 流畅的 react 状态管理库,基于[redux](https://github.com/reduxjs/redux)和[react-redux](https://github.com/reduxjs/react-redux)。简洁、极致、高效。 [![npm peer react version](https://img.shields.io/npm/dependency-version/foca/peer/react?logo=react)](https://github.com/facebook/react) [![npm peer typescript version](https://img.shields.io/npm/dependency-version/foca/peer/typescript?logo=typescript)](https://github.com/microsoft/TypeScript) [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/foca-js/foca/test.yml?branch=master&label=test&logo=vitest)](https://github.com/foca-js/foca/actions) [![Codecov](https://img.shields.io/codecov/c/github/foca-js/foca?logo=codecov)](https://codecov.io/gh/foca-js/foca) [![npm](https://img.shields.io/npm/v/foca?logo=npm)](https://www.npmjs.com/package/foca) [![npm](https://img.shields.io/npm/dt/foca?logo=codeforces)](https://www.npmjs.com/package/foca) [![npm bundle size (version)](https://img.shields.io/bundlephobia/minzip/foca?label=bundle+size&cacheSeconds=3600&logo=esbuild)](https://bundlephobia.com/package/foca@latest) [![License](https://img.shields.io/github/license/foca-js/foca?logo=open-source-initiative)](https://github.com/foca-js/foca/blob/master/LICENSE) [![Code Style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?logo=prettier)](https://github.com/prettier/prettier)
![mind map](./mindMap.svg) # 使用环境 - Browser - React Native - Taro - Electron # 特性 #### 模块化开发,导出即可使用 一个模型包含了状态操作的所有方法,基础代码全部剥离,不再像原生 redux 一般拆分成 action/type/reducer 三个文件,既不利于管理,又难以在提示上关联起来。 定义完模型,导出即可在组件中使用,不用再怕忘记注册或者嫌麻烦了。 #### 专注 TS 极致体验,超强的类型自动推导 无 TS 不编程,foca 提供 **100%** 的基础类型提示,强大的自动推导能力让你产生一种提示上瘾的快感,而你只需关注业务中的类型。 #### 内置 [immer](https://github.com/immerjs/immer) 响应式修改数据 可以说加入 immer 是非常有必要的,当 reducer 数据多层嵌套时,你不必再忍受更改里层的数据而不断使用 rest/spread(...)扩展符的烦恼,相反地,直接赋值就好了,其他的交给 immer 搞定。 #### 支持计算属性,自动收集依赖,可传参数 现在,redux 家族不需要再羡慕`vue`或者`mobx`等响应式框架,咱也能支持计算属性并且自动收集依赖,而且是时候把[reselect](https://github.com/reduxjs/reselect)**扔进垃圾桶**了。 #### 自动管理异步函数的 loading 状态 我们总是想知道某个异步方法(或者请求)正在执行,然后在页面上渲染出`loading...`字样,幸运地是框架自动(按需)为你记录了执行状态。 #### 可定制的多引擎数据持久化 某些数据在一个时间段内可能是不变的,比如登录凭证 token。所以你想着先把数据存到本地,下次自动恢复到模型中,这样用户就不需要频繁登录了。 #### 支持局部模型,用完即扔 利用全局模型派生出局部模型,并跟随组件`挂载/卸载`,状态与外界隔离,组件卸载后状态自动删除,严格控制内存使用量 #### 支持私有方法 一个前置下划线(`_`)就能让方法变成私有的,外部使用时 TS 不会提示私有方法和私有变量,简单好记又省心。 # 生态 #### 网络请求 | 仓库 | 版本 | 描述 | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------- | | [axios](https://github.com/axios/axios) | [![npm](https://img.shields.io/npm/v/axios)](https://www.npmjs.com/package/axios) | 当下最流行的请求库 | | [foca-axios](https://github.com/foca-js/foca-axios) | [![npm](https://img.shields.io/npm/v/foca-axios)](https://www.npmjs.com/package/foca-axios) | axios++ 支持 节流、缓存、重试 | | [foca-openapi](https://github.com/foca-js/foca-openapi) | [![npm](https://img.shields.io/npm/v/foca-openapi)](https://www.npmjs.com/package/foca-openapi) | 使用openapi文档生成请求服务 | #### 持久化存储引擎 | 仓库 | 版本 | 描述 | 平台 | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -------- | | [react-native-async-storage](https://github.com/react-native-async-storage/async-storage) | [![npm](https://img.shields.io/npm/v/@react-native-async-storage/async-storage)](https://www.npmjs.com/package/@react-native-async-storage/async-storage) | React-Native 持久化引擎 | RN | | [foca-taro-storage](https://github.com/foca-js/foca-taro-storage) | [![npm](https://img.shields.io/npm/v/foca-taro-storage)](https://www.npmjs.com/package/foca-taro-storage) | Taro 持久化引擎 | Taro | | [localforage](https://github.com/localForage/localForage) | [![npm](https://img.shields.io/npm/v/localforage)](https://www.npmjs.com/package/localforage) | 浏览器端持久化引擎,支持 IndexedDB, WebSQL | Web | | [foca-electron-storage](https://github.com/foca-js/foca-electron-storage) | [![npm](https://img.shields.io/npm/v/foca-electron-storage)](https://www.npmjs.com/package/foca-electron-storage) | Electron 持久化引擎 | Electron | | [foca-mmkv-storage](https://github.com/foca-js/foca-mmkv-storage) | [![npm](https://img.shields.io/npm/v/foca-mmkv-storage)](https://www.npmjs.com/package/foca-mmkv-storage) | 基于 mmkv 的持久化引擎 | RN | | [foca-cookie-storage](https://github.com/foca-js/foca-cookie-storage) | [![npm](https://img.shields.io/npm/v/foca-cookie-storage)](https://www.npmjs.com/package/foca-cookie-storage) | Cookie 持久化引擎 | Web | | 仓库 | 版本 | 描述 | 平台 | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------- | | [@redux-devtools/extension](https://github.com/reduxjs/redux-devtools) | [![npm](https://img.shields.io/npm/v/@redux-devtools/extension)](https://www.npmjs.com/package/@redux-devtools/extension) | 浏览器日志插件 | Web, RN | | [react-native-debugger](https://github.com/jhen0409/react-native-debugger) | [![npm](https://img.shields.io/npm/v/react-native-debugger)](https://www.npmjs.com/package/react-native-debugger) | 日志应用程序 | RN | | [redux-logger](https://github.com/LogRocket/redux-logger) | [![npm](https://img.shields.io/npm/v/redux-logger)](https://www.npmjs.com/package/redux-logger) | 控制台输出日志 | Web, RN, Taro | # 缺陷 - [不支持 SSR](/troubleshooting?id=为什么不支持-ssr) # 例子 React 案例仓库:https://github.com/foca-js/foca-demo-web
RN 案例仓库:https://github.com/foca-js/foca-demo-react-native
Taro 案例仓库:https://github.com/foca-js/foca-demo-taro
# 在线试玩 ================================================ FILE: docs/index.html ================================================ Foca
加载中...
================================================ FILE: docs/initialize.md ================================================ # # 安装 ```bash # npm npm install foca # yarn yarn add foca # pnpm pnpm add foca ``` # 激活 foca 遵循`唯一store`原则,并提供了快速初始化的入口。 ```typescript // File: store.ts import { store } from 'foca'; store.init(); ``` 好吧,就是这么简单! # 导入 与原生 react-redux 类似,你需要把 foca 提供的 Provider 组件放置到入口文件,这样才能在业务组件中获取到数据。 #### ** React ** ```diff + import './store'; + import { FocaProvider } from 'foca'; import ReactDOM from 'react-dom/client'; import App from './App'; const container = document.getElementById('root'); const root = ReactDOM.createRoot(container); root.render( + + ); ``` #### ** React-Native ** ```diff + import './store'; + import { FocaProvider } from 'foca'; import { Text, View } from 'react-native'; export default function App() { return ( + Hello World + ) } ``` #### ** Taro.js ** ```diff + import './store'; + import { FocaProvider } from 'foca'; import { Component } from 'react'; export default class App extends Component { render() { - return this.props.children; + return {this.props.children}; } } ``` # 日志 在开发阶段,如果你想实时查看状态的操作过程以及数据的变化细节,那么开启可视化界面是必不可少的一个环节。 #### ** 全局软件 ** **优势:** 一次安装,所有项目都可以无缝使用。 - 对于 Web 项目,可以安装 Chrome 浏览器的 [redux-devtools](https://github.com/reduxjs/redux-devtools) 扩展,然后打开控制台查看。 - 对于 React-Native 项目,可以安装并启动软件 [react-native-debugger](https://github.com/jhen0409/react-native-debugger),然后点击 App 里的按钮 `Debug with Chrome`即可连接软件,其本质也是 Chrome 的控制台 接着,我们在 store 里注入增强函数: ```typescript store.init({ // 字符串 redux-devtools 即 window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 的缩写 // 设置 redux-devtools 在生产环境(process.env.NODE_ENV === 'production')下会自动关闭 // 你也可以安装等效的插件包 @redux-devtools/extension 自由控制 compose: 'redux-devtools', }); ``` compose 也支持回调形式,目的是为了注入更多插件。 ```typescript import { composeWithDevTools as compose } from '@redux-devtools/extension'; // 或者使用原生的compose // import { compose } from 'foca'; store.init({ compose(enhancer) { return compose(enhancer, ...more[]); }, }); ``` #### ** 项目插件 ** **优势:** 可选配置参数多,且在 Web 和 React-Native 中都能使用。 ```bash # npm npm install redux-logger @types/redux-logger --save-dev # yarn yarn add redux-logger @types/redux-logger --dev # pnpm pnpm add redux-logger @types/redux-logger -D ``` 接着我们把这个包注入 store: ```typescript import { store, Middleware } from 'foca'; import { createLogger } from 'redux-logger'; const middleware: Middleware[] = []; if (process.env.NODE_ENV !== 'production') { middleware.push( createLogger({ collapsed: true, diff: true, duration: true, logErrors: true, }), ); } store.init({ middleware, }); ``` 大功告成,下次你对 store 的数据做操作时,控制台就会有相应的通知输出。 # 开发热更 如果是 React-Native,你可以跳过这一节。 因为 store.ts 需要被入口文件引入,而 store.ts 又引入了部分 model(持久化需要这么做),所以如果相应的 model 做了修改操作时,会导致浏览器页面全量刷新而非热更新。如果你正在使用当前流行的打包工具,强烈建议加上`hot.accept`手动处理模块更新。 #### ** Vite ** ```typescript // File: store.ts store.init(...); // https://cn.vitejs.dev/guide/api-hmr.html#hot-acceptcb if (import.meta.hot) { import.meta.hot.accept(() => { console.log('Hot updated: store'); }); } ``` #### ** Webpack ** ```typescript // File: store.ts // ################################################## // ###### ####### // ###### yarn add @types/webpack-env --dev ####### // ###### ####### // ################################################## store.init(...); // https://webpack.docschina.org/api/hot-module-replacement/ if (module.hot) { module.hot.accept(() => { console.log('Hot updated: store'); }); } ``` #### ** Webpack ESM ** ```typescript // File: store.ts // ################################################## // ###### ####### // ###### yarn add @types/webpack-env --dev ####### // ###### ####### // ################################################## store.init(...); // https://webpack.docschina.org/api/hot-module-replacement/ if (import.meta.webpackHot) { import.meta.webpackHot.accept(() => { console.log('Hot updated: store'); }); } ``` ================================================ FILE: docs/model.md ================================================ # # Model 原生的 redux 由 action/type/reducer 三个部分组成,大多数情况我们会分成 3 个文件分别存储。在实际使用中,这种模板式的书写方式不仅繁琐,而且难以将他们关联起来,类型提示就更麻烦了。 基于此,我们提出了模型概念,以 state 为核心,任何更改 state 的操作都应该放在一起。 ```typescript // models/user.model.ts import { defineModel } from 'foca'; export interface UserItem { id: number; name: string; age: number; } const initialState: UserItem[] = []; export const userModel = defineModel('users', { initialState, }); ``` 你已`defineModel`经定义了一个最基础的模型,其中第一个字符串参数为redux中的`唯一标识`,请确保其它模型不会再使用这个名字。 对了,怎么注册到store?躺着别动!foca 已经自动把模型注册到 store 中心,也让你享受一下 **DRY** (Don't Repeat Yourself) 原则,因此在业务文件内直接导入模型就能使用。 # State foca 基于 redux 深度定制,所以 state 必须是个纯对象或者数组。 ```typescript // 对象 const initialState: { [K: string]: string } = {}; const objModel = defineModel('model-object', { initialState, }); // 数组 const initialState: number[] = []; const arrayModel = defineModel('model-array', { initialState, }); ``` # Reducers 模型光有 state 也不行,你现在拿到的就是一个空数组([])。加点数据上去吧,这时候就要用到 reducers: ```typescript export const userModel = defineModel('users', { initialState, reducers: { addUser(state, user: UserItem) { state.push(user); }, updateName(state, id: number, name: string) { const user = state.find((item) => item.id === id); if (user) { user.name = name; } }, removeUser(state, id: number) { const index = state.findIndex((item) => item.id === id); if (index >= 0) { state.splice(index, 1); } }, clear() { // 返回初始值 return this.initialState; }, }, }); ``` 就这么干,你已经赋予了模型生命,你等下可以和它互动了。现在,我们来说说这些 reducers 需要注意的几个点: - 函数的第一个参数一定是 state ,而且它是能自动识别到类型`State`的,你不用刻意地去指定。 - 函数是可以带多个参数的,这全凭你自己的喜好。 - 函数体内可以直接修改 state 对象(数组也属于对象)里的任何内容,这得益于 [immer](https://github.com/immerjs/immer) 的功劳。 - 函数返回值必须是`State`类型。当然你也可以不返回,这时 foca 会认为你正在直接修改 state。 - 如果你想使用`this`上下文,比如上面的 **clear()** 函数返回了初始值,那么请~~不要使用箭头函数~~。 # Methods 不可否认,你的数据不可能总是凭空捏造,在真实的业务场景中,数据总是通过接口获得,然后保存到 state 中。foca 贴心地为你准备了组合逻辑的函数,快来试试吧: ```typescript const userModel = defineModel('users', { initialState, methods: { async get() { const users = await http.get('/users'); this.setState(users); return users; }, async retrieve(id: number) { const user = await http.get(`/users/${id}`); this.setState((state) => { state.push(user); }); }, // 也可以是非异步的普通函数 findUser(id: number) { return this.state.users.find((user) => user.id === id); }, }, }); ``` 瞧见没,你可以在 methods 里自由地使用 async/await 方案,然后通过上下文`this.setState`快速更新 state。 接下来我们说说`setState`,这其实完全就是 reducers 的快捷方式,你可以直接传入数据或者使用匿名函数来操作,十分方便。这不禁让我们想起了 React Component 里的 setState?咳咳~~读书人的事,那能叫抄吗? #### ** 直接修改 ** 依赖 immer 的能力,你可以直接修改回调函数给的 state 参数,这也是框架最推荐的方式 ```typescript this.setState((state) => { state.b = 2; }); this.setState((state) => { state.push('a'); state.shift(); }); ``` #### ** 部分更新 ** 是的,你可以返回一部分数据,而且这个特性很简洁高效,框架会使用`Object.assign`帮你把剩余的属性加回去。 !> 只针对 object 类型,而且只有第一级属性可以缺省(参考 React Class Component) ```typescript this.setState({ a: 1 }); this.setState((state) => { return { a: 1 }; // <==> state.a = 1; }); ``` #### ** 全量更新 ** 就是重新设置所有数据 ```typescript this.setState({ a: 1, b: 2 }); this.setState((state) => { return { a: 1, b: 2 }; }); this.setState(['a', 'b', 'c']); this.setState((state) => { return ['a', 'b', 'c']; }); // 重新设置成初始值 this.setState(this.initialState); ``` 嗯?你压根就不想用`setState`,你觉得这样看起来很混乱?Hold on,你突然想起可以使用 reducers 去改变 state 不是吗? ```typescript const userModel = defineModel('users', { initialState, reducers: { addUser(state, user: UserItem) { state.push(user); }, }, methods: { async retrieve(id: number) { const user = await http.get(`/users/${id}`); // 调用reducers里的函数 this.addUser(user); }, }, }); ``` 好吧,这样看起来更纯粹一些,代价就是要委屈你多写几行代码了。 # Computed 对于一些数据,其实是需要经过比较冗长的拼接或者复杂的计算才能得出结果,同时你想自动缓存这些结果?来吧,展示: ```typescript const initialState = { firstName: 'tick', lastName: 'tock', country: 0, }; const userModel = defineModel('users', { initialState, computed: { fullName() { return this.state.firstName + '.' + this.state.lastName; }, profile(age: number, address?: string) { return this.fullName() + age + (address || 'empty'); }, }, }); ``` 恕我直言,有点 Methods 的味道了。味道是这个味道,但是本质不一样,当我们多次执行computed函数时,因为存在缓存的概念,所以不会真正地执行该函数。 ```typescript userModel.fullName(); // 执行函数,生成缓存 userModel.fullName(); // 使用缓存 userModel.fullName(); // 使用缓存 ``` 带参数的计算属性可以理解为所有参数就是一个key,每个key都会生成一个计算属性实例,互不干扰。 ```typescript userModel.profile(20); // 执行函数,生成实例1缓存 userModel.profile(20); // 实例1缓存 userModel.profile(123); // 执行函数,生成实例2缓存 userModel.profile(123); // 实例2缓存 userModel.profile(20); // 实例1缓存 userModel.profile(123); // 实例2缓存 ``` 参数尽量使用基本类型,**不建议**使用对象或者数组作为计算属性的实参,因为如果每次都传新建的复合类型,无法起到缓存的效果,执行速度反而变慢,这和`useMemo(callback, deps)`函数的第二个参数(依赖项)是一个原理。如果实在想用复合类型作为参数,不烦考虑一下放到`Methods`里? --- 缓存什么时候才会更新?框架自动收集依赖,只有其中某个依赖更新了,计算属性才会更新。上面的例子中,当`firstName`或者`lastName`有变化时,fullName 将被标记为`dirty`状态,下一次访问则会重新计算结果。而当`country`变化时,不影响 fullName 的结果,下一次访问仍使用缓存作为结果。 !> 可以在 computed 中使用其它 model 的数据。 ================================================ FILE: docs/persist.md ================================================ # 持久化 持久化是自动把数据通过引擎存储到某个空间的过程。 如果你的某个 api 数据常年不变,那么建议你把它扔到本地做个缓存,这样用户下次再访问你的页面时,可以第一时间看到缓存的内容。如果你不想让用户每次刷新页面就重新登录,那么持久化很适合你。 # 入口 你需要在初始化 store 时开启持久化 ```typescript // File: store.ts import { store } from 'foca'; import { userModel } from './userModel'; import { agentModel } from './agentModel'; store.init({ persist: [ { key: '$PROJECT_$ENV', version: 1, engine: localStorage, // 模型白名单列表 models: [userModel, agentModel], }, ], }); ``` 把需要持久化的模型扔进去,foca 就能自动帮你存取数据。 `key`即为存储路径,最好采用**项目名-环境名**的形式组织。纯前端项目如果和其他前端项目共用一个域名,或者在同一域名下,则有可能使用共同的存储空间,因此需要保证`key`是唯一的值。 # 存储引擎 不同的引擎会把数据存储到不同的位置,使用哪个引擎取决于项目跑在什么环境。引擎操作可以是同步的也可以是异步的。下面列举的第三方库也可以**直接当作**存储引擎: - window.localStorage - 浏览器自带 - window.sessionStorage - 浏览器自带 - [localforage](https://www.npmjs.com/package/localforage) (IndexedDB, WebSQL) - 浏览器专用 - [@react-native-async-storage/async-storage](https://www.npmjs.com/package/@react-native-async-storage/async-storage) - React-Native专用 - [foca-mmkv-storage](https://github.com/foca-js/foca-mmkv-storage) - React-Native专用 - [foca-taro-storage](https://github.com/foca-js/foca-taro-storage) - Taro专用 - [foca-electron-storage](https://github.com/foca-js/foca-taro-storage) - Electron专用 - [foca-cookie-storage](https://github.com/foca-js/foca-cookie-storage) - 浏览器专用,存储到cookie 如果有必要,你也可以自己实现一个引擎: ```typescript // import { StorageEngine } from 'foca'; interface StorageEngine { getItem(key: string): string | null | Promise; setItem(key: string, value: string): any; removeItem(key: string): any; clear(): any; } ``` 如果是在测试环境,也可以尝试使用内置的内存引擎 ```typescript import { memoryStorage } from 'foca'; ``` # 设置版本号 当数据结构变化,我们不得不升级版本号来`删除`持久化数据,版本号又分为`全局版本`和`模型版本`两种。当修改模型内版本号时,仅删除该模型的持久化数据,而修改全局版本号时,白名单内所有模型的持久化数据都被删除。 建议优先修改模型内版本号!! ```diff const stockModel = defineModel('stock', { initialState: {}, persist: { // 模型版本号,影响当前模型 + version: '2.0', }, }); store.init({ persist: [ { key: '$PROJECT_normal_$ENV', // 全局版本号,影响白名单全部模型 + version: '3.6', engine: engines.localStorage, models: [musicModel, stockModel], }, ], }); ``` # 数据合并 > v3.0.0 在项目的推进过程中,难免需要根据产品需求更新模型数据结构,结构变化后,我们可以简单粗暴地通过`版本号+1`的方式来删除持久化的数据。但如果只是新增了某一个字段,我们希望持久化恢复时能自动识别。试试推荐的`合并模式`吧: ```diff store.init({ persist: [ { key: 'myproject-a-prod', version: 1, + merge: 'merge', engine: engines.localStorage, models: [userModel], }, ], }); ``` 很轻松就设置上了,合并模式目前有3种可选的类型: - `replace` - 覆盖模式。数据从存储引擎取出后直接覆盖初始数据 - `merge` - 合并模式(默认)。数据从存储引擎取出后,与初始数据多余部分进行合并,可以理解为 **Object.assign()** 操作 - `deep-merge` - 二级合并模式。在合并模式的基础上,如果某个key的值为对象,则该对象也会执行合并操作 如果某个模型比较特殊,我们也可以在里面单独设置合并模式。 ```diff const userModel = defineModel('user', { initialState: {}, persist: { + merge: 'deep-merge', }, }); ``` 接下来看看它的具体表现: ```typescript const persistState = { obj: { test1: 'persist' } }; const initialState = { obj: { test2: 'initial' }, foo: 'bar' }; // replace 效果 const state = { obj: { test1: 'persist' } }; // merge 效果 const state = { obj: { test1: 'persist' }, foo: 'bar' }; // deep-merge 效果 const state = { obj: { test1: 'persist', test2: 'initial' }, foo: 'bar' }; ``` 需要注意的是合并模式对`数组无效`,当持久化数据和初始数据都为数组类型时,会强制使用持久化数据。当持久化数据和初始数据任何一边为数组类型时,会强制使用初始化数据。 ```typescript const persistState = [1, 2, 3]; ✅ const initialState = [4, 5, 6, 7]; ❌ // 合并效果 const state = [1, 2, 3]; // ------------------------- const persistState = [1, 2, 3]; ❌ const initialState = { foo: 'bar' }; ✅ // 合并效果 const state = { foo: 'bar' }; // ------------------------- const persistState = { foo: 'bar' }; ❌ const initialState = [1, 2, 3]; ✅ // 合并效果 const state = [1, 2, 3]; ``` # 系列化钩子 > v3.0.0 数据在模型与持久化引擎互相转换期间,我们希望对它进行一些额外操作以满足业务需求。比如: - 只缓存部分字段,避免存储尺寸超过存储空间限制 - 改变数据结构或者内容 - 更新时间等动态信息 foca提供了一对实用的过滤函数`dump`和`load`。**dump** 即 model->persist,**load** 即 persist->model。 ```typescript const model = defineModel('model', { initialState: { mode: 'foo', // 本地的设置,需要持久化缓存 hugeDate: [], // API请求数据,数据量太大 }, persist: { // 系列化 dump(state) { return state.mode; }, // 反系列化 load(mode) { return { ...this.initialState, mode }; }, }, }); ``` # 分组 我们注意到 persist 其实是个数组,这意味着你可以多填几组配置上去,把不同的模型数据存储到不同的地方。这看起来很酷,但我猜你不一定需要! ```typescript import { store, engines } from 'foca'; store.init({ persist: [ { key: 'myproject-a-prod', version: 1, engine: engines.localStorage, models: [userModel], }, { key: 'myproject-b-prod', version: 5, engine: engines.sessionStorage, models: [agentModel, teacherModel], }, { key: 'myproject-vip-prod', version: 1, engine: engines.localStorage, models: [customModel, otherModel], }, ], }); ``` ================================================ FILE: docs/redux-toolkit.md ================================================
foca toolkit
开源时间
2021-10 2018-03
文档地址
[foca.js.org](https://foca.js.org)(中文文档) [redux-toolkit.js.org](https://redux-toolkit.js.org/)(English documentation)
安装
```bash pnpm add foca ``` ```bash pnpm add @reduxjs/toolkit react-redux # 持久化 # pnpm add redux-persist # 计算属性 # pnpm add reselect ```
初始化
```typescript // store.ts import { store } from 'foca'; foca.init({}); ``` ```typescript // store.ts import { useDispatch, useSelector } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import counterReducer from './counterSlice'; export const store = configureStore({ reducer: { // 项目中所有reducer都要import注册到这里(枯燥) counter: counterReducer, }, }); export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; export const useAppDispatch = useDispatch.withTypes(); export const useAppSelector = useSelector.withTypes(); ```
注入React
```typescript import './store'; import { FocaProvider } from 'foca'; ReactDOM.render( , ); ``` ```typescript import { store } from './store'; import { Provider } from 'react-redux'; ReactDOM.render( , ); ```
创建Reducer
```typescript // counter.model.ts import { defineModel } from 'foca'; const initialState: { value: number } = { value: 0, }; export const counterModel = defineModel('counter', { initialState, reducers: { increment(state) { state.value += 1; }, decrement(state) { state.value -= 1; }, incrementByAmount(state, amount: number) { state.value += amount; }, }, }); ``` ```typescript // counterSlice.ts import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; const initialState: { value: number } = { value: 0, }; export const counterSlice = createSlice({ name: 'counter', initialState, reducers: { increment(state) { state.value += 1; }, decrement(state) { state.value -= 1; }, incrementByAmount(state, action: PayloadAction) { state.value += action.payload; }, }, }); const { actions, reducer } = counterSlice; export const { increment, decrement, incrementByAmount } = actions; export default reducer; ```
组件中获取数据
```tsx import { useModel } from 'foca'; import { counterModel } from './counter.model'; export const Counter: FC = () => { const count = useModel(counterModel, (state) => state.value); return
{count}
; }; ```
```tsx import { useAppSelector, useAppDispatch } from './store'; import { increment } from './counterSlice'; export const Counter: FC = () => { const count = useAppSelector((state) => state.counter.value); const dispatch = useAppDispatch(); return
dispatch(increment())}>{count}
; }; ```
异步请求和loading
```typescript import { defineModel } from 'foca'; export const todoModel = defineModel('todos', { initialState: { todos: [] }, reducers: {}, methods: { // 返回Promise时自带loading async fetchTodos() { const response = await http.request('/api'); this.setState({ todos: response.data }); }, }, }); ``` ```typescript import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; export const fetchTodosAsync = createAsyncThunk( 'todos/fetchTodos', async () => { const response = await http.request('/api'); return response.data; }, ); const todoSlice = createSlice({ name: 'todos', initialState: { todos: [], loading: false }, reducers: {}, extraReducers(builder) { builder .addCase(fetchTodosAsync.pending, (state) => { state.loading = true; }) .addCase(fetchTodosAsync.fulfilled, (state, action) => { state.loading = false; state.todos = action.payload; }) .addCase(fetchTodosAsync.rejected, (state, action) => { state.loading = false; }); }, }); export default todosSlice.reducer; ```
在组件中使用loading状态
```typescript import { useLoading } from 'foca'; import { todoModel } from './todo.model'; const Todo: FC = () => { const loading = useLoading(todoModel.fetchTodos); useEffect(() => { todoModel.fetchTodos(); }, []); return
; }; ```
```typescript import { useAppSelector, useAppDispatch } from './store'; const Todo: FC = () => { const dispatch = useAppDispatch(); const loading = useAppSelector((s) => s.todos.loading); useEffect(() => { dispatch(fetchTodosAsync()); }, [dispatch]); return
; }; ```
持久化
```typescript import { store } from 'foca'; import { counterModel } from './counter.model'; foca.init({ persist: [ { key: 'root', engine: localStorage, models: [counterModel], }, ], }); ``` ```bash pnpm add redux-persist ``` ```typescript import { configureStore } from '@reduxjs/toolkit'; import storage from 'redux-persist/lib/storage'; import { combineReducers } from 'redux'; import { persistReducer } from 'redux-persist'; import counterReducer from './counterSlice'; const reducers = combineReducers({ counter: counterReducer, }); const persistConfig = { key: 'root', storage, whitelist: ['counter'], }; const persistedReducer = persistReducer(persistConfig, reducers); const store = configureStore({ reducer: persistedReducer }); export default store; ``` ```tsx import { store } from './store'; import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; import { persistStore } from 'redux-persist'; ReactDOM.render( , ); ```
计算属性
```typescript import { defineModel } from 'foca'; export const todoModel = defineModel('todos', { initialState: { todos: [] }, computed: { todos() { return this.state.todos.filter((todo) => !!todo.completed); }, }, }); // 在组件中使用 const memoTodos = useComputed(todoModel.todos); ``` ```bash pnpm add reselect ``` ```typescript import { createSlice } from '@reduxjs/toolkit'; import { createSelector } from 'reselect'; const todoSlice = createSlice({ name: 'todos', initialState: { todos: [] }, }); const memoizedSelectCompletedTodos = createSelector( [(state: RootState) => state.todos], (todos) => { return todos.filter((todo) => !!todo.completed); }, ); // 在组件中使用 const memoTodos = memoizedSelectCompletedTodos(state); ```
================================================ FILE: docs/test.md ================================================ 前端的需求变化总是太快导致测试用例跟不上,甚至部分程序员根本就没想过为自己写的代码编写测试,他们心里总是想着`出错了再说`。对于要求拥有高质量体验的项目,测试是必不可少的,它能使得代码更加稳健,并且在新增功能和重构代码时,都无需太担心会破坏原有的逻辑。在多人协作的项目中,充足的测试可以让其他人员对相应的逻辑有更充分的了解。 ## 测试框架 - [Vitest](https://cn.vitest.dev/) - [Jest](https://jestjs.io/zh-Hans/) - [Mocha](https://mochajs.org/) - [node:test](https://nodejs.org/dist/latest-v18.x/docs/api/test.html#test-runner) node@18.8.0开始提供 ## 准备工作 我们已经知道,foca 是基于 redux 存储数据的,所以在测试模型之前,需要先激活 store,并且在测试完毕后销毁以免影响其他测试。 ```typescript // test/model.test.ts import { store } from 'foca'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); ``` ## 单元测试 我们假设你已经写好了一个模型 ```typescript // src/models/my-custom.model.ts import { defineModel } from 'foca'; export const myCustomModel = defineModel('my-model', { initialState: { count: 0 }, reducers: { plus(state, step: number = 1) { state.count += step; }, minus(state, step: number = 1) { state.count -= step; }, }, }); ``` 对,它现在很简洁,但是已经满足测试条件了 ```typescript // test/model.test.ts import { store } from 'foca'; import { myCustomModel } from '../src/models/my-custom.model.ts'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('initial state', () => { expect(myCustomModel.state.count).toBe(0); }); test('myCustomModel.plus', () => { myCustomModel.plus(); expect(myCustomModel.state.count).toBe(1); myCustomModel.plus(5); expect(myCustomModel.state.count).toBe(6); myCustomModel.plus(100); expect(myCustomModel.state.count).toBe(106); }); test('myCustomModel.minus', () => { myCustomModel.minus(); expect(myCustomModel.state.count).toBe(-1); myCustomModel.minus(10); expect(myCustomModel.state.count).toBe(-11); myCustomModel.minus(28); expect(myCustomModel.state.count).toBe(-39); }); ``` **只测试**业务上的那部分逻辑,每处逻辑分开测试,这就是 `Unit Test` 和你该做的。 ## 覆盖率 对于大一些的项目,你很难保证所有逻辑都已经写进了测试,则建议打开测试框架的覆盖率功能并检查每一行的覆盖情况。一般情况下,覆盖率的报告会放到`coverage`目录,你只需要在浏览器中打开`coverage/index.html`就可以查看了。 ================================================ FILE: docs/troubleshooting.md ================================================ # # 函数里 this 的类型是 any 需要在文件 **tsconfig.json** 中开启`"strict": true`或者`"noImplicitThis": true`。 # 为什么要用 this 1. 可调用额外的内部属性和方法; 2. 可调用自定义的私有方法; 3. 方便克隆(cloneModel),this 作为 context 是可变的。 # 没找到持久化守卫组件 内置在入口组件 `FocaProvider` 里了,初始化 store 的时候如果配置了 persist 属性,守卫会自动开启。 # setState 和 reducers 的区别 互补关系。methods.setState 是专门为网络请求和一些组合业务设置的快捷操作(直接传入 state 或者回调)。相对于一些不需要复用的 reducer 函数,用 setState 反而能让模型对外暴露更少的接口,组件里用起来就会更舒服一些。 # 追踪 methods 的执行状态有性能问题吗 没有。我们已经知道如果想获得状态,就必须通过`useLoading`, `getLoading` 这些 api 获取,但如果你没有显性地通过这些 api 获取某个函数的状态,就不会触发该函数的状态追踪逻辑,即自动忽略。 状态数据使用独立的内部 store 存储,任何变动都不会触发模型数据(useModel, connect)的重新检查。 # 为什么不支持 SSR 因为 foca 是遵循单一 store 存储(单例),它的优点就是 model 创建后无需手动注册,在 CSR(Client-Side-Rendering) 中用起来很流畅。而 SSR(Server-Side-Rendering) 方案中,node 进程常驻于内存,这意味着所有的请求都会共享同一个 store,数据也必然会乱套。所以一些 SSR 框架比如 next.js, remix 都无法使用了。 再者,需要SSR的页面,一般是需要 SEO 的展示页,这种项目也用不上状态管理。并且 SSR 其实不是唯一的 SEO 优化方案,利用 user-agent 配合服务端动态渲染一样可以搞定,参考文章:https://segmentfault.com/a/1190000023481810 # this.initialState 是否多余 大部分情况下你会觉得多余,直到你使用`cloneModel`复制出一个新的模型。我们允许复制模型的同时修改初始值,所以`this.initialState`就和`this.state`一样能明确自己归属于哪个模型。 同时,每次获取`this.initialState`,框架都会返回给你一份全新的数据(deep clone),这样再也不怕你会改动初始值了。 # 命名有什么建议 模型文件名建议采用 `some-word.model.ts` 这种命名方式,可读性好。
模型内容建议采用 `export const someWordModel = defineModel('some-word')` 驼峰的方式来创建,变量名和模型名具有一定的关联性,也不容易与其它模型冲突。 ================================================ FILE: package.json ================================================ { "name": "foca", "version": "4.0.1", "repository": "git@github.com:foca-js/foca.git", "homepage": "https://foca.js.org", "keywords": [ "redux", "redux-model", "redux-typescript", "react-redux", "react-model", "redux-toolkit" ], "description": "流畅的React状态管理库", "contributors": [ "罪 (https://github.com/geekact)" ], "license": "MIT", "main": "dist/index.js", "module": "dist/esm/index.js", "types": "dist/index.d.ts", "sideEffects": false, "scripts": { "test": "vitest run", "prepublishOnly": "tsup", "docs": "docsify serve ./docs", "prepare": "husky" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "./package.json": "./package.json" }, "files": [ "dist", "LICENSE", "package.json", "README.md", "CHANGELOG.md" ], "commitlint": { "extends": [ "@commitlint/config-conventional" ] }, "volta": { "node": "18.16.0", "pnpm": "9.15.6" }, "packageManager": "pnpm@9.15.6", "peerDependencies": { "react": "^18 || ^19", "react-native": ">=0.69", "typescript": "^5" }, "peerDependenciesMeta": { "typescript": { "optional": true }, "react-native": { "optional": true } }, "dependencies": { "immer": "^9.0.21", "react-redux": "^9.2.0", "redux": "^5.0.1", "topic": "^3.0.2" }, "devDependencies": { "@commitlint/cli": "^19.7.1", "@commitlint/config-conventional": "^19.7.1", "@react-native-async-storage/async-storage": "^2.1.2", "@redux-devtools/extension": "^3.3.0", "@testing-library/react": "^16.2.0", "@types/node": "^22.13.8", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitest/coverage-istanbul": "^3.0.7", "docsify-cli": "^4.4.4", "fake-indexeddb": "^6.0.0", "husky": "^9.1.7", "jsdom": "^26.0.0", "localforage": "^1.10.0", "prettier": "^3.5.3", "react": "^19.0.0", "react-dom": "^19.0.0", "react-test-renderer": "^19.0.0", "rxjs": "^7.8.2", "sleep-promise": "^9.1.0", "ts-expect": "^1.3.0", "tsup": "^8.4.0", "typescript": "^5.8.2", "vitest": "^3.0.7" } } ================================================ FILE: src/actions/loading.ts ================================================ import type { UnknownAction } from 'redux'; export const TYPE_SET_LOADING = '@@store/loading'; export const LOADING_CATEGORY = '##' + Math.random(); export const DESTROY_LOADING = TYPE_SET_LOADING + '/destroy'; export interface LoadingAction extends UnknownAction { type: typeof TYPE_SET_LOADING; model: string; method: string; payload: { loading: boolean; category: string | number; }; } export const isLoadingAction = ( action: UnknownAction | unknown, ): action is LoadingAction => { const tester = action as LoadingAction; return ( tester.type === TYPE_SET_LOADING && !!tester.model && !!tester.method && !!tester.payload ); }; export interface DestroyLoadingAction extends UnknownAction { type: typeof DESTROY_LOADING; model: string; } export const isDestroyLoadingAction = ( action: UnknownAction | unknown, ): action is DestroyLoadingAction => { const tester = action as DestroyLoadingAction; return tester.type === DESTROY_LOADING && !!tester.model; }; ================================================ FILE: src/actions/model.ts ================================================ import type { Action, UnknownAction } from 'redux'; import { isFunction } from '../utils/is-type'; export interface PreModelAction extends UnknownAction, Action { model: string; preModel: true; payload: Payload; actionInActionGuard?: () => void; consumer(state: State, action: PreModelAction): State | void; } export interface PostModelAction extends UnknownAction, Action { model: string; postModel: true; next: State; } export const isPreModelAction = ( action: UnknownAction | unknown, ): action is PreModelAction => { const test = action as PreModelAction; return test.preModel && !!test.model && isFunction(test.consumer); }; export const isPostModelAction = ( action: UnknownAction, ): action is PostModelAction => { const test = action as PostModelAction; return test.postModel && !!test.next; }; ================================================ FILE: src/actions/persist.ts ================================================ import type { UnknownAction } from 'redux'; const TYPE_PERSIST_HYDRATE = '@@persist/hydrate'; export interface PersistHydrateAction extends UnknownAction { type: typeof TYPE_PERSIST_HYDRATE; payload: Record; } export const actionHydrate = ( states: Record, ): PersistHydrateAction => { return { type: TYPE_PERSIST_HYDRATE, payload: states, }; }; export const isHydrateAction = ( action: UnknownAction, ): action is PersistHydrateAction => { return (action as PersistHydrateAction).type === TYPE_PERSIST_HYDRATE; }; ================================================ FILE: src/actions/refresh.ts ================================================ import type { UnknownAction } from 'redux'; const TYPE_REFRESH_STORE = '@@store/refresh'; export interface RefreshAction extends UnknownAction { type: typeof TYPE_REFRESH_STORE; payload: { force: boolean; }; } export const actionRefresh = (force: boolean): RefreshAction => { return { type: TYPE_REFRESH_STORE, payload: { force, }, }; }; export const isRefreshAction = ( action: UnknownAction, ): action is RefreshAction => { return (action as RefreshAction).type === TYPE_REFRESH_STORE; }; ================================================ FILE: src/api/get-loading.ts ================================================ import { LOADING_CATEGORY } from '../actions/loading'; import { PromiseRoomEffect, PromiseEffect } from '../model/enhance-effect'; import { loadingStore, FindLoading } from '../store/loading-store'; import { isFunction } from '../utils/is-type'; /** * 检测给定的effect方法中是否有正在执行的。支持多个方法同时传入。 * * ```typescript * loading = getLoading(effect); * loading = getLoading(effect1, effect2, ...); * ``` * */ export function getLoading( effect: PromiseEffect, ...more: PromiseEffect[] ): boolean; /** * 检测给定的effect方法是否正在执行。 * * ```typescript * loadings = getLoading(effect.room); * loading = loadings.find(CATEGORY) * ``` */ export function getLoading(effect: PromiseRoomEffect): FindLoading; /** * 检测给定的effect方法是否正在执行。 * * ```typescript * loading = getLoading(effect.room, CATEGORY); * ``` */ export function getLoading( effect: PromiseRoomEffect, category: string | number, ): boolean; export function getLoading( effect: PromiseEffect | PromiseRoomEffect, category?: string | number | PromiseEffect, ): boolean | FindLoading { const args = arguments; if (effect._.hasRoom && !isFunction(category)) { const loadings = loadingStore.get(effect).loadings; return category === void 0 ? loadings : loadings.find(category); } for (let i = args.length; i-- > 0; ) { if (loadingStore.get(args[i]).loadings.find(LOADING_CATEGORY)) { return true; } } return false; } ================================================ FILE: src/api/use-computed.ts ================================================ import { ComputedFlag } from '../model/types'; import { useModelSelector } from '../redux/use-selector'; import { toArgs } from '../utils/to-args'; export interface UseComputedFlag extends ComputedFlag { (...args: any[]): any; } /** * 计算属性hooks函数,第二个参数开始传入计算属性的参数(如果有) * * ```typescript * * const App: FC = () => { * const fullName = useComputed(model.fullName); * const profile = useComputed(model.profile, 25); * return

{profile}

; * } * * const model = defineModel('my-model', { * initialState: { firstName: '', lastName: '' }, * computed: { * fullName() { * return this.state.firstName + this.state.lastName; * }, * profile(age: number, address?: string) { * return this.fullName() + age + address; * } * } * }); * * ``` */ export function useComputed( ref: T, ...args: Parameters ): T extends (...args: any[]) => infer R ? R : never; export function useComputed(ref: UseComputedFlag) { const args = toArgs(arguments, 1); return useModelSelector(() => ref.apply(null, args)); } ================================================ FILE: src/api/use-isolate.ts ================================================ import { useEffect, useMemo, useRef, useState } from 'react'; import { DestroyLoadingAction, DESTROY_LOADING } from '../actions/loading'; import { loadingStore } from '../store/loading-store'; import { modelStore } from '../store/model-store'; import { cloneModel } from '../model/clone-model'; import { Model } from '../model/types'; let globalCounter = 0; const hotReloadCounter: Record = {}; /** * 创建局部模型,它的数据变化不会影响到全局模型,而且会随着组件一起销毁 * ```typescript * const testModel = defineModel({ * initialState, * events: { * onInit() {}, // 挂载回调 * onDestroy() {}, // 销毁回调 * } * }); * * function App() { * const model = useIsolate(testModel); * const state = useModel(model); * * return

Hello

; * } * ``` */ export const useIsolate = < State extends object = object, Action extends object = object, Effect extends object = object, Computed extends object = object, >( globalModel: Model, ): Model => { const initialCount = useState(() => globalCounter++)[0]; const uniqueName = process.env.NODE_ENV === 'production' ? useProdName(globalModel.name, initialCount) : useDevName(globalModel, initialCount, new Error()); const localModelRef = useRef(undefined); useEffect(() => { localModelRef.current = isolateModel; }); // 热更时会重新执行useMemo,因此只能用ref const isolateModel = localModelRef.current?.name === uniqueName ? localModelRef.current : cloneModel(uniqueName, globalModel); return isolateModel; }; const useProdName = (modelName: string, count: number) => { const uniqueName = `@isolate:${modelName}#${count}`; useEffect( () => () => { setTimeout(unmountModel, 0, uniqueName); }, [uniqueName], ); return uniqueName; }; /** * 开发模式下,需要Hot Reload。 * 必须保证数据不会丢,即如果用户一直保持`model.name`不变,就被判定为可以共享热更新之前的数据。 * * 必须严格控制count在组件内的自增次数,否则在第一次修改model的name时,总是会报错: * Warning: Cannot update a component (`XXX`) while rendering a different component (`XXX`) */ const useDevName = (model: Model, count: number, err: Error) => { const componentName = useMemo((): string => { try { const stacks = err.stack!.split('\n'); const innerNamePattern = new RegExp( // vitest测试框架的stack增加了 Module. `at\\s(?:Module\\.)?${useIsolate.name}\\s\\(`, 'i', ); const componentNamePattern = /at\s(.+?)\s\(/i; for (let i = 0; i < stacks.length; ++i) { if (innerNamePattern.test(stacks[i]!)) { return stacks[i + 1]!.match(componentNamePattern)![1]!; } } } catch {} return 'Component'; }, [err.stack]); /** * 模型文件重新保存时组件会导入新的对象,需根据这个特性重新克隆模型 */ const globalModelRef = useRef<{ model?: Model; count: number }>({ count: 0 }); useEffect(() => { if (globalModelRef.current.model !== model) { globalModelRef.current = { model, count: ++globalModelRef.current.count }; } }); const uniqueName = `@isolate:${model.name}:${componentName}#${count}-${ globalModelRef.current.count + Number(globalModelRef.current.model !== model) }`; /** * 计算热更次数,如果停止热更,说明组件被卸载 */ useMemo(() => { hotReloadCounter[uniqueName] ||= 0; ++hotReloadCounter[uniqueName]; }, [uniqueName]); /** * 热更新时会重新执行一次useEffect * setTimeout可以让其他useEffect有充分的时间使用model * * 需要卸载模型的场景是: * 1. 组件hooks增减或者调换顺序(initialCount会自增) * 2. 组件卸载 * 3. model.name变更 * 4. model逻辑变更 */ useEffect(() => { const prev = hotReloadCounter[uniqueName]; return () => { setTimeout(() => { const unmounted = prev === hotReloadCounter[uniqueName]; unmounted && unmountModel(uniqueName); }); }; }, [uniqueName]); return uniqueName; }; const unmountModel = (modelName: string) => { modelStore['removeReducer'](modelName); loadingStore.dispatch({ type: DESTROY_LOADING, model: modelName, }); }; ================================================ FILE: src/api/use-loading.ts ================================================ import { PromiseRoomEffect, PromiseEffect } from '../model/enhance-effect'; import { FindLoading } from '../store/loading-store'; import { useLoadingSelector } from '../redux/use-selector'; import { getLoading } from './get-loading'; /** * 检测给定的effect方法中是否有正在执行的。支持多个方法同时传入。 * * ```typescript * loading = useLoading(effect); * loading = useLoading(effect1, effect2, ...); * ``` * */ export function useLoading( effect: PromiseEffect, ...more: PromiseEffect[] ): boolean; /** * 检测给定的effect方法是否正在执行。 * * ```typescript * loadings = useLoading(effect.room); * loading = loadings.find(CATEGORY); * ``` */ export function useLoading(effect: PromiseRoomEffect): FindLoading; /** * 检测给定的effect方法是否正在执行。 * * ```typescript * loading = useLoading(effect.room, CATEGORY); * ``` */ export function useLoading( effect: PromiseRoomEffect, category: string | number, ): boolean; export function useLoading(): boolean | FindLoading { const args = arguments as unknown as Parameters; return useLoadingSelector(() => { return getLoading.apply(null, args); }); } ================================================ FILE: src/api/use-model.ts ================================================ import { shallowEqual } from 'react-redux'; import { deepEqual } from '../utils/deep-equal'; import type { Model } from '../model/types'; import { toArgs } from '../utils/to-args'; import { useModelSelector } from '../redux/use-selector'; import { isFunction, isString } from '../utils/is-type'; /** * hooks新旧数据的对比方式: * * - `deepEqual` 深度对比,对比所有层级的内容。传递selector时默认使用。 * - `shallowEqual` 浅对比,只比较对象第一层。传递多个模型但没有selector时默认使用。 * - `strictEqual` 全等(===)对比。只传一个模型但没有selector时默认使用。 */ export type Algorithm = 'strictEqual' | 'shallowEqual' | 'deepEqual'; /** * * 获取模型的状态数据。 * * 传入一个模型时,将返回该模型的状态。 * * 传入多个模型时,则返回一个以模型名称为key、状态为value的大对象。 * * 最后一个参数如果是**函数**,则为状态过滤函数,过滤函数的结果视为最终返回值。 */ export function useModel( model: Model, ): State; export function useModel( model: Model, selector: (state: State) => T, algorithm?: Algorithm, ): T; export function useModel< Name1 extends string, State1 extends object, Name2 extends string, State2 extends object, >( model1: Model, model2: Model, ): { [K in Name1]: State1; } & { [K in Name2]: State2; }; export function useModel( model1: Model, model2: Model, selector: (state1: State1, state2: State2) => T, algorithm?: Algorithm, ): T; export function useModel< Name1 extends string, State1 extends object, Name2 extends string, State2 extends object, Name3 extends string, State3 extends object, >( model1: Model, model2: Model, model3: Model, ): { [K in Name1]: State1; } & { [K in Name2]: State2; } & { [K in Name3]: State3; }; export function useModel< State1 extends object, State2 extends object, State3 extends object, T, >( model1: Model, model2: Model, model3: Model, selector: (state1: State1, state2: State2, state3: State3) => T, algorithm?: Algorithm, ): T; export function useModel< Name1 extends string, State1 extends object, Name2 extends string, State2 extends object, Name3 extends string, State3 extends object, Name4 extends string, State4 extends object, >( model1: Model, model2: Model, model3: Model, model4: Model, ): { [K in Name1]: State1; } & { [K in Name2]: State2; } & { [K in Name3]: State3; } & { [K in Name4]: State4; }; export function useModel< State1 extends object, State2 extends object, State3 extends object, State4 extends object, T, >( model1: Model, model2: Model, model3: Model, model4: Model, selector: ( state1: State1, state2: State2, state3: State3, state4: State4, ) => T, algorithm?: Algorithm, ): T; export function useModel< Name1 extends string, State1 extends object, Name2 extends string, State2 extends object, Name3 extends string, State3 extends object, Name4 extends string, State4 extends object, Name5 extends string, State5 extends object, >( model1: Model, model2: Model, model3: Model, model4: Model, model5: Model, ): { [K in Name1]: State1; } & { [K in Name2]: State2; } & { [K in Name3]: State3; } & { [K in Name4]: State4; } & { [K in Name5]: State5; }; export function useModel< State1 extends object, State2 extends object, State3 extends object, State4 extends object, State5 extends object, T, >( model1: Model, model2: Model, model3: Model, model4: Model, model5: Model, selector: ( state1: State1, state2: State2, state3: State3, state4: State4, state5: State5, ) => T, algorithm?: Algorithm, ): T; export function useModel(): any { const args = toArgs(arguments); let algorithm: Algorithm | false = args.length > 1 && isString(args[args.length - 1]) && args.pop(); const selector: Function | false = args.length > 1 && isFunction(args[args.length - 1]) && args.pop(); const models: Model[] = args; const modelsLength = models.length; const onlyOneModel = modelsLength === 1; if (!algorithm) { if (selector) { // 返回子集或者计算过的内容。 // 如果只是从模型中获取数据且没有做转换,则大部分时间会降级为shallow或者strict。 // 如果对数据做了转换,则肯定需要使用深对比。 algorithm = 'deepEqual'; } else if (onlyOneModel) { // 一个model属于一个reducer,reducer已经使用了深对比来判断是否变化, algorithm = 'strictEqual'; } else { // { key => model } 集合。 // 一个model属于一个reducer,reducer已经使用了深对比来判断是否变化, algorithm = 'shallowEqual'; } } // 储存了结果说明是state状态变化导致的对比计算。 // 因为存在闭包,除模型外的所有参数都是旧的, // 所以我们只需要保证用到的模型数据不变即可,这样可以减少无意义的计算。 let hasMemo = false, snapshot: any, prevState: Record, currentStates: object[], i: number, changed: boolean; const reducerNames: string[] = []; for (i = 0; i < modelsLength; ++i) { reducerNames.push(models[i]!.name); } return useModelSelector((state: Record) => { if (hasMemo) { changed = false; for (i = modelsLength; i-- > 0; ) { const reducerName = reducerNames[i]!; if (state[reducerName] !== prevState[reducerName]) { changed = true; break; } } if (!changed) { prevState = state; return snapshot; } } prevState = state; hasMemo = true; if (onlyOneModel) { const firstState = state[reducerNames[0]!]; return (snapshot = selector ? selector(firstState) : firstState); } if (selector) { currentStates = []; for (i = modelsLength; i-- > 0; ) { currentStates[i] = state[reducerNames[i]!]!; } return (snapshot = selector.apply(null, currentStates)); } snapshot = {}; for (i = modelsLength; i-- > 0; ) { const reducerName = reducerNames[i]!; snapshot[reducerName] = state[reducerName]; } return snapshot; }, compareFn[algorithm]); } const compareFn: Record< Algorithm, undefined | ((previous: any, next: any) => boolean) > = { deepEqual: deepEqual, shallowEqual: shallowEqual, strictEqual: void 0, }; ================================================ FILE: src/engines/memory.ts ================================================ import type { StorageEngine } from './storage-engine'; let cache: Partial> = {}; export const memoryStorage: StorageEngine = { getItem(key) { return cache[key] === void 0 ? null : cache[key]!; }, setItem(key, value) { cache[key] = value; }, removeItem(key) { cache[key] = void 0; }, clear() { cache = {}; }, }; ================================================ FILE: src/engines/storage-engine.ts ================================================ export interface StorageEngine { getItem(key: string): string | null | Promise; setItem(key: string, value: string): any; removeItem(key: string): any; clear(): any; } ================================================ FILE: src/index.ts ================================================ // 模型中使用 export { defineModel } from './model/define-model'; export { cloneModel } from './model/clone-model'; // 组件中使用 export { useModel } from './api/use-model'; export { useLoading } from './api/use-loading'; export { getLoading } from './api/get-loading'; export { useComputed } from './api/use-computed'; export { useIsolate } from './api/use-isolate'; export { connect } from './redux/connect'; // 入口使用 export { compose } from 'redux'; export { modelStore as store } from './store/model-store'; export { FocaProvider } from './redux/foca-provider'; export { memoryStorage } from './engines/memory'; // 可能用到的TS类型 export type { Action, UnknownAction, Dispatch, MiddlewareAPI, Middleware, StoreEnhancer, Unsubscribe, } from 'redux'; export type { Model } from './model/types'; export type { StorageEngine } from './engines/storage-engine'; ================================================ FILE: src/middleware/action-in-action.interceptor.ts ================================================ import type { Middleware, UnknownAction } from 'redux'; import { isPreModelAction } from '../actions/model'; // 开发者有可能在action中执行action,这是十分不规范的操作。 export const actionInActionInterceptor: Middleware = () => { let dispatching = false; let prevAction: UnknownAction | null = null; return (dispatch) => (action) => { if (!isPreModelAction(action)) { // 非model的action会直接进入redux,redux中已经有dispatch保护机制。 return dispatch(action); } // model的action如果没有变化则不会进入redux,所以需要在这里额外保护。 if (dispatching) { throw new Error( '[dispatch] 派发任务冲突,请检查是否在reducers函数中直接或者间接执行了其他reducers或者methods函数。\nreducers的唯一职责是更新当前的state,有额外的业务逻辑时请把methods作为执行入口并按需调用reducers。\n\n当前冲突的reducer:\n\n' + JSON.stringify(action, null, 4) + '\n\n上次执行未完成的reducer:\n\n' + JSON.stringify(prevAction, null, 4) + '\n\n', ); } try { dispatching = true; prevAction = action; /** * react-redux@8+ 主要服务于react18 * 在react17中,有可能出现redux遍历subscriber时立即触发dispatch,然后这边来不及设置dispatching=false * 在react18中,如果使用`ReactDOM.render()`旧入口,则依旧会有这个问题。 * @link https://github.com/foca-js/foca/issues/20 */ action.actionInActionGuard = () => { dispatching = false; prevAction = null; }; return dispatch(action); } catch (e) { prevAction = null; dispatching = false; throw e; } }; }; ================================================ FILE: src/middleware/destroy-loading.interceptor.ts ================================================ import type { Middleware } from 'redux'; import { isDestroyLoadingAction } from '../actions/loading'; export const destroyLoadingInterceptor: Middleware = (api) => (dispatch) => (action) => { if ( !isDestroyLoadingAction(action) || api.getState().hasOwnProperty(action.model) ) { return dispatch(action); } return action; }; ================================================ FILE: src/middleware/freeze-state.middleware.ts ================================================ import { freeze } from 'immer'; import type { Middleware } from 'redux'; export const freezeStateMiddleware: Middleware = (api) => { freeze(api.getState(), true); return (dispatch) => (action) => { try { return dispatch(action); } finally { freeze(api.getState(), true); } }; }; ================================================ FILE: src/middleware/loading.interceptor.ts ================================================ import type { Middleware } from 'redux'; import type { LoadingStore, LoadingStoreState } from '../store/loading-store'; import { isLoadingAction } from '../actions/loading'; export const loadingInterceptor = ( loadingStore: LoadingStore, ): Middleware<{}, LoadingStoreState> => { return () => (dispatch) => (action) => { if (!isLoadingAction(action)) { return dispatch(action); } const { model, method, payload: { category, loading }, } = action; if (loadingStore.isModelInitializing(model)) { loadingStore.activate(model, method); } else if (!loadingStore.isActive(model, method)) { return; } const record = loadingStore.getItem(model, method); if (!record || record.loadings.data[category] !== loading) { return dispatch(action); } return action; }; }; ================================================ FILE: src/middleware/model.interceptor.ts ================================================ import type { Middleware } from 'redux'; import { deepEqual } from '../utils/deep-equal'; import { isPreModelAction, PostModelAction } from '../actions/model'; import { immer } from '../utils/immer'; export const modelInterceptor: Middleware<{}, Record> = (api) => (dispatch) => (action) => { if (!isPreModelAction(action)) { return dispatch(action); } const prev = api.getState()[action.model]!; const next = immer.produce(prev, (draft) => { return action.consumer(draft, action); }); action.actionInActionGuard && action.actionInActionGuard(); if (deepEqual(prev, next)) return action; return dispatch({ type: action.type, model: action.model, postModel: true, next: next, } satisfies PostModelAction); }; ================================================ FILE: src/model/clone-model.ts ================================================ import { isFunction } from '../utils/is-type'; import { defineModel } from './define-model'; import type { DefineModelOptions, InternalModel, Model } from './types'; const editableKeys = [ 'initialState', 'events', 'persist', 'skipRefresh', ] as const; type EditableKeys = (typeof editableKeys)[number]; type OverrideOptions< State extends object, Action extends object, Effect extends object, Computed extends object, PersistDump, > = Pick< DefineModelOptions, EditableKeys >; export const cloneModel = < Name extends string, State extends object, Action extends object, Effect extends object, Computed extends object, PersistDump, >( uniqueName: Name, model: Model, options?: | Partial> | (( prev: OverrideOptions, ) => Partial< OverrideOptions >), ): Model => { const realModel = model as unknown as InternalModel< string, State, Action, Effect, Computed >; const prevOpts = realModel._$opts; const nextOpts = Object.assign({}, prevOpts); if (options) { Object.assign(nextOpts, isFunction(options) ? options(nextOpts) : options); /* istanbul ignore else -- @preserve */ if (process.env.NODE_ENV !== 'production') { (Object.keys(nextOpts) as EditableKeys[]).forEach((key) => { if ( nextOpts[key] !== prevOpts[key] && editableKeys.indexOf(key) === -1 ) { throw new Error( `[model:${uniqueName}] 复制模型时禁止重写属性'${key}'`, ); } }); } } return defineModel(uniqueName, nextOpts); }; ================================================ FILE: src/model/define-model.ts ================================================ import { parseState, stringifyState } from '../utils/serialize'; import { deepEqual } from '../utils/deep-equal'; import { EnhancedAction, enhanceAction } from './enhance-action'; import { EnhancedEffect, enhanceEffect } from './enhance-effect'; import { modelStore } from '../store/model-store'; import { createReducer } from '../redux/create-reducer'; import { composeGetter, defineGetter } from '../utils/getter'; import { getMethodCategory } from '../utils/get-method-category'; import { guard } from './guard'; import { depsCollector } from '../reactive/deps-collector'; import { ObjectDeps } from '../reactive/object-deps'; import type { ActionCtx, EffectCtx, DefineModelOptions, GetInitialState, GetState, Model, ComputedCtx, EventCtx, InternalModel, SetStateCallback, ComputedFlag, } from './types'; import { isFunction } from '../utils/is-type'; import { Unsubscribe } from 'redux'; import { freeze, original, isDraft } from 'immer'; import { isPromise } from '../utils/is-promise'; import { enhanceComputed } from './enhance-computed'; export const defineModel = < Name extends string, State extends object, Action extends object, Effect extends object, Computed extends object, PersistDump, >( uniqueName: Name, options: DefineModelOptions, ): Model => { guard(uniqueName); const { reducers, methods, computed, skipRefresh, events } = options; /** * 防止初始化数据在外面被修改从而影响到store, * 这属于小概率事件,所以仅需要在开发环境处理, * 而且在严格模式下,runtime修改冻结数据会直接报错,可以提醒开发者修正 */ const initialState = process.env.NODE_ENV !== 'production' ? freeze(options.initialState, true) : options.initialState; /* istanbul ignore else -- @preserve */ if (process.env.NODE_ENV !== 'production') { const items = [ { name: 'reducers', value: reducers }, { name: 'methods', value: methods }, { name: 'computed', value: computed }, ]; const validateUniqueMethod = (index1: number, index2: number) => { const item1 = items[index1]!; const item2 = items[index2]!; if (item1.value && item2.value) { Object.keys(item1.value).forEach((key) => { if (item2.value!.hasOwnProperty(key)) { throw new Error( `[model:${uniqueName}] 属性'${key}'在${item1.name}和${item2.name}中重复使用`, ); } }); } }; validateUniqueMethod(0, 1); validateUniqueMethod(0, 2); validateUniqueMethod(1, 2); } /* istanbul ignore else -- @preserve */ if (process.env.NODE_ENV !== 'production') { if (!deepEqual(parseState(stringifyState(initialState)), initialState)) { throw new Error( `[model:${uniqueName}] initialState 包含了不可系列化的数据,允许的类型为:Object, Array, Number, String, Undefined 和 Null`, ); } } const getState = (obj: T): T & GetState => { return defineGetter(obj, 'state', () => { const state = modelStore.getState()[uniqueName]; return depsCollector.active ? new ObjectDeps(modelStore, uniqueName).start(state) : state; }); }; const getInitialState = ( obj: T, ): T & GetInitialState => { return defineGetter(obj, 'initialState', () => parseState(stringifyState(initialState)), ); }; const actionCtx: ActionCtx = composeGetter( { name: uniqueName, }, getInitialState, ); const createEffectCtx = (methodName: string): EffectCtx => { const isArrayState = Array.isArray(initialState); const obj: Pick, 'setState'> = { // @ts-expect-error setState: enhanceAction( actionCtx, `${methodName}.setState`, ( state: State, fn_state: SetStateCallback | State | Pick, ) => { const nextState = isFunction>(fn_state) ? fn_state(state) : fn_state; if (nextState === void 0) return; return isArrayState || isDraft(nextState) ? nextState : Object.assign({}, original(state), nextState); }, ), }; return composeGetter( Object.assign(obj, { name: uniqueName }), getState, getInitialState, ); }; const enhancedMethods: { [K in ReturnType]: Record< string, EnhancedAction | EnhancedEffect | ComputedFlag >; } = { external: {}, internal: {}, }; if (reducers) { const reducerKeys = Object.keys(reducers); for (let i = reducerKeys.length; i-- > 0; ) { const key = reducerKeys[i]!; enhancedMethods[getMethodCategory(key)][key] = enhanceAction( actionCtx, key, reducers[key]!, ); } } if (computed) { const computedCtx: ComputedCtx & { [K in string]?: ComputedFlag; } = composeGetter({ name: uniqueName }, getState); const computedKeys = Object.keys(computed); for (let i = computedKeys.length; i-- > 0; ) { const key = computedKeys[i]!; computedCtx[key] = enhancedMethods[getMethodCategory(key)][key] = enhanceComputed( computedCtx, uniqueName, key, // @ts-expect-error computed[key], ); } } if (methods) { let ctx: EffectCtx; const ctxs: EffectCtx[] = [(ctx = createEffectCtx(''))]; const methodKeys = Object.keys(methods); for (let i = methodKeys.length; i-- > 0; ) { const key = methodKeys[i]!; if (process.env.NODE_ENV !== 'production') { ctxs.push((ctx = createEffectCtx(key))); } enhancedMethods[getMethodCategory(key)][key] = enhanceEffect( ctx, key, // @ts-expect-error methods[key], ); } for (let i = ctxs.length; i-- > 0; ) { Object.assign( ctxs[i]!, enhancedMethods.external, enhancedMethods.internal, ); } } if (events) { const { onInit, onChange, onDestroy } = events; const eventCtx: EventCtx = Object.assign( composeGetter({ name: uniqueName }, getState), enhancedMethods.external, enhancedMethods.internal, ); modelStore.onInitialized().then(() => { const subscriptions: Unsubscribe[] = []; if (onChange) { let prevState = eventCtx.state; subscriptions.push( modelStore.subscribe(() => { const nextState = eventCtx.state; if ( modelStore.isReady && prevState !== nextState && nextState !== void 0 ) { onChange.call(eventCtx, prevState, nextState); } prevState = nextState; }), ); } if (onDestroy) { subscriptions.push( modelStore.subscribe(() => { if (eventCtx.state === void 0) { for (let i = 0; i < subscriptions.length; ++i) { subscriptions[i]!(); } onDestroy.call(null as never, uniqueName); } }), ); } if (onInit) { /** * 初始化时,用到它的React组件可能还没加载,所以执行async-method时无法判断是否需要保存loading。因此需要一个钩子来处理事件周期 * @see https://github.com/foca-js/foca/issues/38 */ modelStore.topic.publish('modelPreInit', uniqueName); const promiseOrVoid = onInit.call(eventCtx); const postInit = () => { modelStore.topic.publish('modelPostInit', uniqueName); }; if (isPromise(promiseOrVoid)) { promiseOrVoid.then(postInit, postInit); } else { postInit(); } } }); } modelStore['appendReducer']( uniqueName, createReducer({ name: uniqueName, initialState, allowRefresh: !skipRefresh, }), ); const model: InternalModel = Object.assign( composeGetter( { name: uniqueName, _$opts: options, _$persistCtx: getInitialState({}), }, getState, ), enhancedMethods.external, ); return model as any; }; ================================================ FILE: src/model/enhance-action.ts ================================================ import type { PreModelAction } from '../actions/model'; import { modelStore } from '../store/model-store'; import { toArgs } from '../utils/to-args'; import type { ActionCtx } from './types'; export interface EnhancedAction { (payload: any): PreModelAction; } export const enhanceAction = ( ctx: ActionCtx, actionName: string, consumer: (state: State, ...args: any[]) => any, ): EnhancedAction => { const modelName = ctx.name; const actionType = modelName + '.' + actionName; const enhancedConsumer: PreModelAction['consumer'] = ( state, action, ) => { return consumer.apply( ctx, [state].concat(action.payload) as [state: State, ...args: any[]], ); }; const fn: EnhancedAction = function () { return modelStore.dispatch>({ type: actionType, model: modelName, preModel: true, payload: toArgs(arguments), consumer: enhancedConsumer, }); }; return fn; }; ================================================ FILE: src/model/enhance-computed.ts ================================================ import { ComputedValue } from '../reactive/computed-value'; import { modelStore } from '../store/model-store'; import { toArgs } from '../utils/to-args'; import { ComputedCtx, ComputedFlag } from './types'; export const enhanceComputed = ( ctx: ComputedCtx, modelName: string, computedName: string, fn: (...args: any[]) => any, ): ComputedFlag => { let caches: { deps: any[]; skipCount: number; ref: ComputedValue; }[] = []; function anonymousFn() { const args = toArgs(arguments); let hitCache: (typeof caches)[number] | undefined; searchCache: for (let i = 0; i < caches.length; ++i) { const cache = caches[i]!; if (hitCache) { ++cache.skipCount; continue; } for (let j = 0; j < cache.deps.length; ++j) { if (args[j] !== cache.deps[j]) { ++cache.skipCount; continue searchCache; } } cache.skipCount = 0; hitCache = cache; } if (hitCache) return hitCache.ref.value; if (caches.length > 10) { caches = caches.filter((cache) => cache.skipCount < 15); } hitCache = { deps: args, skipCount: 0, ref: new ComputedValue(modelStore, modelName, computedName, () => fn.apply(ctx, args), ), }; caches.push(hitCache); return hitCache.ref.value; } return anonymousFn as any; }; ================================================ FILE: src/model/enhance-effect.ts ================================================ import { LoadingAction, LOADING_CATEGORY, TYPE_SET_LOADING, } from '../actions/loading'; import type { EffectCtx } from './types'; import { isPromise } from '../utils/is-promise'; import { toArgs } from '../utils/to-args'; import { loadingStore } from '../store/loading-store'; interface RoomFunc

> { (category: number | string): { execute(...args: P): R; }; } interface AsyncRoomEffect

> extends RoomFunc { readonly _: { readonly model: string; readonly method: string; readonly hasRoom: true; }; } interface AsyncEffect

> extends EffectFunc { readonly _: { readonly model: string; readonly method: string; readonly hasRoom: ''; }; /** * 对同一effect函数的执行状态进行分类以实现独立保存。好处有: * * 1. 并发请求同一个请求时不会互相覆盖执行状态。 *
* 2. 可以精确地判断业务中是哪个控件或者逻辑正在执行。 * * ```typescript * model.effect.room(CATEGORY).execute(...); * ``` * * @see useLoading(effect.room) * @see getLoading(effect.room) * @since 0.11.4 * */ readonly room: AsyncRoomEffect; } export type PromiseEffect = AsyncEffect; export type PromiseRoomEffect = AsyncRoomEffect; interface EffectFunc

> { (...args: P): R; } export type EnhancedEffect

> = R extends Promise ? AsyncEffect : EffectFunc; type NonReadonly = { -readonly [K in keyof T]: T[K]; }; export const enhanceEffect = ( ctx: EffectCtx, methodName: string, effect: (...args: any[]) => any, ): EnhancedEffect => { const fn: NonReadonly & EffectFunc = function () { return execute(ctx, methodName, effect, toArgs(arguments)); }; fn._ = { model: ctx.name, method: methodName, hasRoom: '', }; const room: NonReadonly & RoomFunc = ( category: number | string, ) => ({ execute() { return execute(ctx, methodName, effect, toArgs(arguments), category); }, }); room._ = Object.assign({}, fn._, { hasRoom: true as const, }); fn.room = room; return fn; }; const dispatchLoading = ( modelName: string, methodName: string, loading: boolean, category?: number | string, ) => { loadingStore.dispatch({ type: TYPE_SET_LOADING, model: modelName, method: methodName, payload: { category: category === void 0 ? LOADING_CATEGORY : category, loading, }, }); }; const execute = ( ctx: EffectCtx, methodName: string, effect: (...args: any[]) => any, args: any[], category?: number | string, ) => { const modelName = ctx.name; const resultOrPromise = effect.apply(ctx, args); if (!isPromise(resultOrPromise)) return resultOrPromise; dispatchLoading(modelName, methodName, true, category); return resultOrPromise.then( (result) => { return dispatchLoading(modelName, methodName, false, category), result; }, (e: unknown) => { dispatchLoading(modelName, methodName, false, category); throw e; }, ); }; ================================================ FILE: src/model/guard.ts ================================================ const counter: Record = {}; export const guard = (modelName: string) => { counter[modelName] ||= 0; if (process.env.NODE_ENV !== 'production') { setTimeout(() => { --counter[modelName]!; }); } if (++counter[modelName] > 1) { throw new Error(`模型名称'${modelName}'被重复使用`); } }; ================================================ FILE: src/model/types.ts ================================================ import type { UnknownAction } from 'redux'; import type { EnhancedEffect } from './enhance-effect'; import type { PersistMergeMode } from '../persist/persist-item'; export interface ComputedFlag { readonly _computedFlag: never; } export interface GetName { /** * 模型名称。请在定义模型时确保是唯一的字符串 */ readonly name: Name; } export interface GetState { /** * 模型的实时状态 */ readonly state: State; } export interface GetInitialState { /** * 模型的初始状态,每次获取该属性都会执行深拷贝操作 */ readonly initialState: State; } export type ModelPersist = { /** * 持久化版本号,数据结构变化后建议立即升级该版本。默认值:`0` */ version?: number | string; /** * 持久化数据与初始数据的合并方式。默认值以全局配置为准 * * - replace - 覆盖模式。直接用持久化数据替换初始数据 * - merge - 合并模式。持久化数据与初始数据新增的key进行合并,可理解为`Object.assign` * - deep-merge - 二级合并模式。在合并模式的基础上,如果某个key的值为对象,则该对象也会执行合并操作 * * 注意:当数据为数组格式时该配置无效。 * @since 3.0.0 */ merge?: PersistMergeMode; } & ( | { /** * 模型数据从内存存储到持久化引擎时的过滤函数,允许你只持久化部分数据。 * ```typescript * * // state = { firstName: 'tick', lastName: 'tock' } * dump: (state) => state * dump: (state) => state.firstName * dump: (state) => ({ name: state.lastName }) * ``` * * @since 3.0.0 */ dump: (state: State) => PersistDump; /** * 持久化数据恢复到模型内存时的过滤函数,参数为`dump`返回的值。 * ```typescript * // state = { firstName: 'tick', lastName: 'tock' } * { * dump(state) { * return state.firstName * }, * load(firstName) { * return { ...this.initialState, firstName: firstName }; * } * } * ``` * * @since 3.0.0 */ load: (this: GetInitialState, dumpData: PersistDump) => State; } | { dump?: never; load?: never; } ); export interface ActionCtx extends GetName, GetInitialState {} export interface EffectCtx extends ActionCtx, GetState { /** * 立即更改状态,支持**immer**操作 * * ```typescript * this.setState((state) => { * state.count += 1; * }); * ``` * * 对于object类型,你可以直接传递 **全部** 或者 **部分** 数据 * ```typescript * interface State { id: number; name: string }; * * this.setState({}); // 什么也没修改 * this.setState({ id: 10 }); // 只修改id * this.setState({ id: 10, name: 'foo' }); // 修改全部 * * this.setState((state) => { * return {}; // 什么也没修改 * }); * this.setState((state) => { * return { id: 10 }; // 只修改id * }); * this.setState((state) => { * return { id: 10, name: 'foo' }; // 修改全部 * }); * ``` * * 对于array类型,直接传递数组就行了 * ```typescript * this.setState(['a', 'b', 'c']); * ``` */ readonly setState: State extends any[] ? (state: State | ((state: State) => State | void)) => UnknownAction : ( state: SetStateCallback | (Pick | State), ) => UnknownAction; } export interface SetStateCallback { (state: State): Pick | State | void; } export interface ComputedCtx extends GetName, GetState {} export interface BaseModel extends GetState, GetName {} type ModelActionItem< State extends object, Action extends object, K extends keyof Action, > = Action[K] extends (state: State, ...args: infer P) => State | void ? (...args: P) => UnknownAction : never; type ModelAction = { readonly [K in keyof Action]: ModelActionItem; }; type GetPrivateMethodKeys = { [K in keyof Method]: K extends `_${string}` ? K : never; }[keyof Method]; type ModelEffect = { readonly [K in keyof Effect]: Effect[K] extends (...args: infer P) => infer R ? EnhancedEffect : never; }; type ModelComputed = { readonly [K in keyof Computed]: Computed[K] & ComputedFlag; }; export type Model< Name extends string = string, State extends object = object, Action extends object = object, Effect extends object = object, Computed extends object = object, > = BaseModel & // [K in keyof Action as K extends `_${string}` ? never : K] // 上面这种看起来简洁,业务代码提示也正常,但是业务代码那边无法点击跳转进模型了。 // 所以需要先转换所有的属性,再把私有属性去除。 Omit, GetPrivateMethodKeys> & Omit, GetPrivateMethodKeys> & Omit, GetPrivateMethodKeys>; export type InternalModel< Name extends string = string, State extends object = object, Action extends object = object, Effect extends object = object, Computed extends object = object, > = BaseModel & { readonly _$opts: DefineModelOptions; readonly _$persistCtx: GetInitialState; }; export type InternalAction = { [key: string]: (state: State, ...args: any[]) => State | void; }; export interface Event { /** * store初始化完成,并且持久化(如果有)的数据也已经恢复。 * * 上下文 **this** 可以直接调用actions和effects的函数以及computed计算属性。 */ onInit?: () => void; /** * 每当state有变化时的回调通知。 * * 初始化(onInit)执行之前不会触发该回调。如果在onInit中做了修改state的操作,则会触发该回调。 * * 上下文 **this** 可以直接调用actions和effects的函数以及computed计算属性,请谨慎执行修改数据的操作以防止死循环。 */ onChange?: (prevState: State, nextState: State) => void; /** * 销毁模型时的回调通知,此时模型已经被销毁。 * 该事件仅在局部模型生效 * @see useIsolate */ onDestroy?: (this: never, modelName: string) => void; } export interface EventCtx extends GetName, GetState {} export interface DefineModelOptions< State extends object, Action extends object, Effect extends object, Computed extends object, PersistDump, > { /** * 初始状态 * * ```typescript * cosnt initialState: { * count: number; * } = { * count: 0, * } * * const model = defineModel('model1', { * initialState * }); * ``` */ initialState: State; /** * 定义修改状态的方法。参数一自动推断为state类型。支持**immer**操作。支持多参数。 * * ```typescript * const model = defineModel('model1', { * initialState, * reducers: { * plus(state, step: number) { * state.count += step; * }, * minus(state, step: number, scale: 1 | 2) { * state.count -= step * scale; * } * }, * }); * ``` */ reducers?: Action & InternalAction & ThisType>; /** * 定义普通方法,异步方法等。 * 调用effect方法时,一般会伴随异步操作(请求数据、耗时任务),框架会自动收集当前方法的调用状态。 * * ```typescript * const model = defineModel('model1', { * initialState, * methods: { * async foo(p1: string, p2: number) { * const result = await Promise.resolve(); * this.setState({ x: result }); * return 'OK'; * } * }, * }); * * useLoading(model.foo); // 返回值类型: boolean * ``` */ methods?: Effect & ThisType & Effect & Computed & EffectCtx>; /** * 定义计算属性。针对需要复杂的计算才能得出结果的场景而设计。如果只是简单的返回,建议使用`methods` * * ```typescript * const initialState = { firstName: 'tick', lastName: 'tock' }; * * const model = defineModel('model1', { * initialState, * computed: { * fullname() { * return this.state.firstName + '.' + this.state.lastName; * }, * names() { * return this.fullName.value.split('').map((item) => `[${item}]`); * } * }, * }); * ``` * * 可以单独使用: * ```typescript * model.fullname; // ComputedRef; * model.fullname.value; // string; * ``` * * 可以配合react hooks使用: * * ```typescript * const fullname = useComputed(model.fullname); // string * ``` */ computed?: Computed & ThisType>; /** * 是否阻止刷新数据时跳过当前模型,默认即不跳过。 * * 如果是强制刷新,则该参数无效。 * * @see store.refresh(force: boolean = false) */ skipRefresh?: boolean; /** * 定制持久化,请确保已经在初始化store的时候把当前模型加入persist配置,否则当前设置无效 * * @see store.init() */ persist?: ModelPersist & ThisType; /** * 生命周期 * @since 0.11.1 */ events?: Event & ThisType & Computed & Effect & EventCtx>; } ================================================ FILE: src/persist/persist-gate.tsx ================================================ import { ReactNode, FC, useState, useEffect } from 'react'; import { modelStore } from '../store/model-store'; import { isFunction } from '../utils/is-type'; export interface PersistGateProps { loading?: ReactNode; children?: ReactNode | ((isReady: boolean) => ReactNode); } export const PersistGate: FC = (props) => { const state = useState(() => modelStore.isReady), isReady = state[0], setIsReady = state[1]; const { loading = null, children } = props; useEffect(() => { isReady || modelStore.onInitialized().then(() => { setIsReady(true); }); }, []); /* istanbul ignore else -- @preserve */ if (process.env.NODE_ENV !== 'production') { if (loading && isFunction(children)) { console.error('[PersistGate] 当前children为函数类型,loading属性无效'); } } return ( <> {isFunction(children) ? children(isReady) : isReady ? children : loading} ); }; ================================================ FILE: src/persist/persist-item.ts ================================================ import type { StorageEngine } from '../engines/storage-engine'; import type { GetInitialState, InternalModel, Model, ModelPersist, } from '../model/types'; import { isObject, isPlainObject, isString } from '../utils/is-type'; import { toPromise } from '../utils/to-promise'; import { parseState, stringifyState } from '../utils/serialize'; export interface PersistSchema { /** * 版本 */ v: number | string; /** * 数据 */ d: { [key: string]: PersistItemSchema; }; } export interface PersistItemSchema { /** * 版本 */ v: number | string; /** * 数据 */ d: string; } export type PersistMergeMode = 'replace' | 'merge' | 'deep-merge'; export interface PersistOptions { /** * 存储唯一标识名称 */ key: string; /** * 存储名称前缀,默认值:`@@foca.persist:` */ keyPrefix?: string; /** * 持久化数据与初始数据的合并方式。默认值:`merge` * * - replace - 覆盖模式。数据从存储引擎取出后直接覆盖初始数据 * - merge - 合并模式。数据从存储引擎取出后,与初始数据多余部分进行合并,可以理解为`Object.assign()`操作 * - deep-merge - 二级合并模式。在合并模式的基础上,如果某个key的值为对象,则该对象也会执行合并操作 * * 注意:当数据为数组格式时该配置无效。 * @since 3.0.0 */ merge?: PersistMergeMode; /** * 版本号 */ version: string | number; /** * 存储引擎 */ engine: StorageEngine; /** * 允许持久化的模型列表 */ models: Model[]; } type CustomModelPersistOptions = Required> & { ctx: GetInitialState; }; const defaultDumpOrLoadFn = (value: any) => value; interface PersistRecord { model: Model; /** * 模型的persist参数 */ opts: CustomModelPersistOptions; /** * 已经存储的模型内容,data是字符串。 * 存储时,如果各项属性符合条件,则会当作最终值,从而省去了系列化的过程。 */ schema?: PersistItemSchema; /** * 已经存储的模型内容,data是对象。 * 主要用于和store变化后的state对比。 */ prev?: object; } export class PersistItem { readonly key: string; protected readonly records: Record = {}; constructor(protected readonly options: PersistOptions) { const { models, keyPrefix = '@@foca.persist:', key, merge = 'merge' satisfies PersistMergeMode, } = options; this.key = keyPrefix + key; for (let i = models.length; i-- > 0; ) { const model = models[i]!; const { load = defaultDumpOrLoadFn, dump = defaultDumpOrLoadFn, version: customVersion = 0, merge: customMerge = merge, } = (model as unknown as InternalModel)._$opts.persist || {}; this.records[model.name] = { model, opts: { version: customVersion, merge: customMerge, load, dump, ctx: (model as unknown as InternalModel)._$persistCtx, }, }; } } init(): Promise { return toPromise(() => this.options.engine.getItem(this.key)).then( (data) => { if (!data) { this.loadMissingState(); return this.dump(); } try { const schema = JSON.parse(data); if (!this.validateSchema(schema)) { this.loadMissingState(); return this.dump(); } const schemaKeys = Object.keys(schema.d); for (let i = schemaKeys.length; i-- > 0; ) { const key = schemaKeys[i]!; const record = this.records[key]; if (record) { const { opts } = record; const itemSchema = schema.d[key]!; if (this.validateItemSchema(itemSchema, opts)) { const dumpData = parseState(itemSchema.d); record.prev = this.merge( opts.load.call(opts.ctx, dumpData), opts.ctx.initialState, opts.merge, ); record.schema = itemSchema; } } } this.loadMissingState(); return this.dump(); } catch (e) { this.dump(); throw e; } }, ); } loadMissingState() { this.loop((record) => { const { prev, opts, schema } = record; if (!schema || !prev) { const dumpData = opts.dump.call(null, opts.ctx.initialState); record.prev = this.merge( opts.load.call(opts.ctx, dumpData), opts.ctx.initialState, opts.merge, ); record.schema = { v: opts.version, d: stringifyState(dumpData), }; } }); } merge(persistState: any, initialState: any, mode: PersistMergeMode) { const isStateArray = Array.isArray(persistState); const isInitialStateArray = Array.isArray(initialState); if (isStateArray && isInitialStateArray) return persistState; if (isStateArray || isInitialStateArray) return initialState; if (mode === 'replace') return persistState; const state = Object.assign({}, initialState, persistState); if (mode === 'deep-merge') { const keys = Object.keys(persistState); for (let i = 0; i < keys.length; ++i) { const key = keys[i]!; if ( Object.prototype.hasOwnProperty.call(initialState, key) && isPlainObject(state[key]) && isPlainObject(initialState[key]) ) { state[key] = Object.assign({}, initialState[key], state[key]); } } } return state; } collect(): Record { const stateMaps: Record = {}; this.loop(({ prev: state }, key) => { state && (stateMaps[key] = state); }); return stateMaps; } update(nextState: Record) { let changed = false; this.loop((record) => { const { model, prev, opts, schema } = record; const nextStateForKey = nextState[model.name]!; // 状态不变的情况下,即使过期了也无所谓,下次初始化时会自动剔除。 // 版本号改动的话一定会触发页面刷新。 if (nextStateForKey !== prev) { record.prev = nextStateForKey; const nextSchema: PersistItemSchema = { v: opts.version, d: stringifyState(opts.dump.call(null, nextStateForKey)), }; if (!schema || nextSchema.d !== schema.d) { record.schema = nextSchema; changed ||= true; } } }); changed && this.dump(); } protected loop(callback: (record: PersistRecord, key: string) => void) { const records = this.records; const recordKeys = Object.keys(records); for (let i = recordKeys.length; i-- > 0; ) { const key = recordKeys[i]!; callback(records[key]!, key); } } protected dump() { this.options.engine.setItem(this.key, JSON.stringify(this.toJSON())); } protected validateSchema(schema: any): schema is PersistSchema { return ( isObject(schema) && isObject(schema.d) && schema.v === this.options.version ); } protected validateItemSchema( schema: PersistItemSchema | undefined, options: CustomModelPersistOptions, ) { return schema && schema.v === options.version && isString(schema.d); } protected toJSON(): PersistSchema { const states: PersistSchema['d'] = {}; this.loop(({ schema }, key) => { schema && (states[key] = schema); }); return { v: this.options.version, d: states }; } } ================================================ FILE: src/persist/persist-manager.ts ================================================ import type { Reducer, Store, Unsubscribe } from 'redux'; import { actionHydrate, isHydrateAction } from '../actions/persist'; import { PersistItem, PersistOptions } from './persist-item'; export class PersistManager { protected initialized: boolean = false; protected readonly list: PersistItem[]; protected timer?: ReturnType; protected unsubscribeStore!: Unsubscribe; constructor(options: PersistOptions[]) { this.list = options.map((option) => new PersistItem(option)); } init(store: Store, hydrate: boolean) { this.unsubscribeStore = store.subscribe(() => { this.initialized && this.update(store); }); return Promise.all(this.list.map((item) => item.init())).then(() => { hydrate && store.dispatch(actionHydrate(this.collect())); this.initialized = true; }); } destroy() { this.unsubscribeStore(); this.initialized = false; } collect(): Record { return this.list.reduce>((stateMaps, item) => { return Object.assign(stateMaps, item.collect()); }, {}); } combineReducer(original: Reducer): Reducer> { return (state, action) => { if (state === void 0) state = {}; if (isHydrateAction(action)) { return Object.assign({}, state, action.payload); } return original(state, action); }; } protected update(store: Store) { this.timer ||= setTimeout(() => { const nextState = store.getState(); this.timer = void 0; for (let i = this.list.length; i-- > 0; ) { this.list[i]!.update(nextState); } }, 50); } } ================================================ FILE: src/reactive/computed-value.ts ================================================ import type { Store } from 'redux'; import { depsCollector } from './deps-collector'; import { createComputedDeps } from './create-computed-deps'; import type { Deps } from './object-deps'; export class ComputedValue { public deps: Deps[] = []; public snapshot: any; protected active?: boolean; protected root?: any; constructor( protected readonly store: Pick>, 'getState'>, public readonly model: string, public readonly property: string, protected readonly fn: () => any, ) {} public get value(): T { if (this.active) { throw new Error( `[model:${this.model}] 计算属性"${this.property}"正在被循环引用`, ); } this.active = true; this.isDirty() && this.updateSnapshot(); this.active = false; if (depsCollector.active) { // 作为其他computed的依赖 depsCollector.prepend(createComputedDeps(this)); } return this.snapshot; } isDirty(): boolean { if (!this.root) return true; const rootState = this.store.getState(); if (this.root !== rootState) { const deps = this.deps; // 前置的元素是createComputedDeps()生成的对象,执行isDirty()会触发ref.value // 后置的元素是state,所以需要从后往前判断 for (let i = deps.length; i-- > 0; ) { if (deps[i]!.isDirty()) return true; } } this.root = rootState; return false; } protected updateSnapshot() { this.deps = depsCollector.produce(() => { this.snapshot = this.fn(); this.root = this.store.getState(); }); } } ================================================ FILE: src/reactive/create-computed-deps.ts ================================================ import { shallowEqual } from 'react-redux'; import type { ComputedValue } from './computed-value'; import type { Deps } from './object-deps'; export const createComputedDeps = (body: ComputedValue): Deps => { let snapshot: any; return { id: `c-${body.model}-${body.property}`, end(): void { snapshot = body.snapshot; }, isDirty(): boolean { return !shallowEqual(snapshot, body.value); }, }; }; ================================================ FILE: src/reactive/deps-collector.ts ================================================ import type { Deps } from './object-deps'; const deps: Deps[][] = []; let level = -1; export const depsCollector = { get active(): boolean { return level >= 0; }, produce(callback: Function): Deps[] { const current: Deps[] = (deps[++level] = []); callback(); deps.length = level--; const uniqueDeps: Deps[] = []; const uniqueID: string[] = []; for (let i = 0; i < current.length; ++i) { const dep = current[i]!; const id = dep.id; if (uniqueID.indexOf(id) === -1) { uniqueID.push(id); uniqueDeps.push(dep); dep.end(); } } return uniqueDeps; }, append(dep: Deps) { deps[level]!.push(dep); }, prepend(dep: Deps) { deps[level]!.unshift(dep); }, }; ================================================ FILE: src/reactive/object-deps.ts ================================================ import type { Store } from 'redux'; import { isObject } from '../utils/is-type'; import { depsCollector } from './deps-collector'; export interface Deps { id: string; end(): void; isDirty(): boolean; } export class ObjectDeps implements Deps { protected active: boolean = true; protected snapshot: any; protected root: any; constructor( protected readonly store: Pick>, 'getState'>, protected readonly model: string, protected readonly deps: string[] = [], ) { this.root = this.getState(); } isDirty(): boolean { const rootState = this.getState(); if (this.root === rootState) return false; const { pathChanged, snapshot: nextSnapshot } = this.getSnapshot(rootState); if (pathChanged || this.snapshot !== nextSnapshot) return true; this.root = rootState; return false; } get id(): string { return this.model + '.' + this.deps.join('.'); } start>(startState: T): T { depsCollector.append(this); return this.proxy(startState); } end(): void { this.active = false; } protected getState(): T { return this.store.getState()[this.model]; } protected getSnapshot(state: any): { pathChanged: boolean; snapshot: any } { const deps = this.deps; let snapshot = state; for (let i = 0; i < deps.length; ++i) { if (!isObject>(snapshot)) { return { pathChanged: true, snapshot }; } snapshot = snapshot[deps[i]!]; } return { pathChanged: false, snapshot }; } protected proxy(currentState: Record): any { if ( currentState === null || !isObject>(currentState) || Array.isArray(currentState) ) { return currentState; } const nextState = {}; const keys = Object.keys(currentState); const currentDeps = this.deps.slice(); let visited = false; for (let i = keys.length; i-- > 0; ) { const key = keys[i]!; Object.defineProperty(nextState, key, { enumerable: true, get: () => { if (!this.active) return currentState[key]; if (visited) { return new ObjectDeps( this.store, this.model, currentDeps.slice(), ).start(currentState)[key]; } visited = true; this.deps.push(key); return this.proxy((this.snapshot = currentState[key])); }, }); } /* istanbul ignore else -- @preserve */ if (process.env.NODE_ENV !== 'production') { Object.freeze(nextState); } return nextState; } } ================================================ FILE: src/redux/connect.ts ================================================ import { Connect, connect as originalConnect } from 'react-redux'; import { ProxyContext } from './contexts'; import { toArgs } from '../utils/to-args'; export const connect: Connect = function () { const args = toArgs>(arguments); (args[3] ||= {}).context = ProxyContext; return originalConnect.apply(null, args); }; ================================================ FILE: src/redux/contexts.ts ================================================ import { createContext } from 'react'; import type { ReactReduxContextValue } from 'react-redux'; export const ModelContext = createContext(null); export const LoadingContext = createContext( null, ); export const ProxyContext = createContext(null); ================================================ FILE: src/redux/create-reducer.ts ================================================ import type { Reducer } from 'redux'; import { isPostModelAction } from '../actions/model'; import { isRefreshAction } from '../actions/refresh'; interface Options { readonly name: string; readonly initialState: State; readonly allowRefresh: boolean; } export const createReducer = ( options: Options, ): Reducer => { const allowRefresh = options.allowRefresh; const reducerName = options.name; const initialState = options.initialState; return function reducer(state, action) { if (state === void 0) return initialState; if (isPostModelAction(action) && action.model === reducerName) { return action.next; } if (isRefreshAction(action) && (allowRefresh || action.payload.force)) { return initialState; } return state; }; }; ================================================ FILE: src/redux/foca-provider.tsx ================================================ import { FC } from 'react'; import { Provider } from 'react-redux'; import { ProxyContext, ModelContext, LoadingContext } from './contexts'; import { modelStore } from '../store/model-store'; import { PersistGate, PersistGateProps } from '../persist/persist-gate'; import { proxyStore } from '../store/proxy-store'; import { loadingStore } from '../store/loading-store'; import { isFunction } from '../utils/is-type'; interface OwnProps extends PersistGateProps {} /** * 状态上下文组件,请挂载到入口文件。 * 请确保您已经初始化了store仓库。 * * @see store.init() * * ```typescript * ReactDOM.render( * * * * ); * ``` */ export const FocaProvider: FC = ({ children, loading }) => { return ( {modelStore['persister'] ? ( ) : isFunction(children) ? ( children(true) ) : ( children )} ); }; ================================================ FILE: src/redux/use-selector.ts ================================================ import { createSelectorHook } from 'react-redux'; import { ModelContext, LoadingContext } from './contexts'; export const useModelSelector = createSelectorHook(ModelContext); export const useLoadingSelector = createSelectorHook(LoadingContext); ================================================ FILE: src/store/loading-store.ts ================================================ import { UnknownAction, applyMiddleware, legacy_createStore as createStore, Middleware, } from 'redux'; import type { PromiseRoomEffect, PromiseEffect } from '../model/enhance-effect'; import { loadingInterceptor } from '../middleware/loading.interceptor'; import { isDestroyLoadingAction, isLoadingAction } from '../actions/loading'; import { actionRefresh, isRefreshAction } from '../actions/refresh'; import { combine } from './proxy-store'; import { destroyLoadingInterceptor } from '../middleware/destroy-loading.interceptor'; import { immer } from '../utils/immer'; import { StoreBasic } from './store-basic'; import { modelStore } from './model-store'; import { freeze } from 'immer'; import { freezeStateMiddleware } from '../middleware/freeze-state.middleware'; export interface FindLoading { find(category: number | string): boolean; } interface LoadingState extends FindLoading { data: { [category: string]: boolean; }; } interface LoadingStoreStateItem { loadings: LoadingState; } export type LoadingStoreState = Partial<{ [model: string]: Partial<{ [method: string]: LoadingStoreStateItem; }>; }>; const findLoading: FindLoading['find'] = function ( this: LoadingState, category, ) { return !!this.data[category]; }; const createDefaultRecord = (): LoadingStoreStateItem => { return { loadings: { find: findLoading, data: {}, }, }; }; export class LoadingStore extends StoreBasic { protected initializingModels: string[] = []; protected status: Partial<{ [model: string]: Partial<{ [method: string]: boolean; }>; }> = {}; protected defaultRecord: LoadingStoreStateItem = freeze( createDefaultRecord(), true, ); constructor() { super(); const topic = modelStore.topic; topic.subscribe('init', this.init.bind(this)); topic.subscribe('refresh', this.refresh.bind(this)); topic.subscribe('unmount', this.unmount.bind(this)); topic.subscribe('modelPreInit', (modelName) => { this.initializingModels.push(modelName); }); topic.subscribe('modelPostInit', (modelName) => { this.initializingModels = this.initializingModels.filter( (item) => item !== modelName, ); }); } init() { const middleware: Middleware[] = [ loadingInterceptor(this), destroyLoadingInterceptor, ]; /* istanbul ignore else -- @preserve */ if (process.env.NODE_ENV !== 'production') { middleware.push(freezeStateMiddleware); } this.origin = createStore( this.reducer.bind(this), applyMiddleware.apply(null, middleware), ); combine(this.store); } unmount(): void { this.origin = null; } reducer( state: LoadingStoreState | undefined, action: UnknownAction, ): LoadingStoreState { if (state === void 0) { state = {}; } if (isLoadingAction(action)) { const { model, method, payload: { category, loading }, } = action; const next = immer.produce(state, (draft) => { draft[model] ||= {}; const { loadings } = (draft[model]![method] ||= createDefaultRecord()); loadings.data[category] = loading; }); return next; } if (isDestroyLoadingAction(action)) { const next = Object.assign({}, state); delete next[action.model]; delete this.status[action.model]; return next; } if (isRefreshAction(action)) return {}; return state; } get(effect: PromiseEffect | PromiseRoomEffect): LoadingStoreStateItem { const { _: { model, method }, } = effect; let record: LoadingStoreStateItem | undefined; if (this.isActive(model, method)) { record = this.getItem(model, method); } else { this.activate(model, method); } return record || this.defaultRecord; } getItem(model: string, method: string): LoadingStoreStateItem | undefined { const level1 = this.getState()[model]; return level1 && level1[method]; } isModelInitializing(model: string): boolean { return ( this.initializingModels.length > 0 && this.initializingModels.includes(model) ); } isActive(model: string, method: string): boolean { const level1 = this.status[model]; return level1 !== void 0 && level1[method] === true; } activate(model: string, method: string) { (this.status[model] ||= {})[method] = true; } inactivate(model: string, method: string) { (this.status[model] ||= {})[method] = false; } refresh() { return this.dispatch(actionRefresh(true)); } } export const loadingStore = new LoadingStore(); ================================================ FILE: src/store/model-store.ts ================================================ import { applyMiddleware, compose, legacy_createStore as createStore, Middleware, Reducer, Store, StoreEnhancer, } from 'redux'; import { Topic } from 'topic'; import { actionRefresh, RefreshAction } from '../actions/refresh'; import { modelInterceptor } from '../middleware/model.interceptor'; import type { PersistOptions } from '../persist/persist-item'; import { PersistManager } from '../persist/persist-manager'; import { combine } from './proxy-store'; import { OBJECT } from '../utils/is-type'; import { StoreBasic } from './store-basic'; import { actionInActionInterceptor } from '../middleware/action-in-action.interceptor'; import { freezeStateMiddleware } from '../middleware/freeze-state.middleware'; type Compose = | typeof compose | ((...funcs: StoreEnhancer[]) => StoreEnhancer); interface CreateStoreOptions { preloadedState?: Record; compose?: 'redux-devtools' | Compose; middleware?: Middleware[]; persist?: PersistOptions[]; } export class ModelStore extends StoreBasic> { public topic: Topic<{ init: []; ready: []; refresh: []; unmount: []; modelPreInit: [modelName: string]; modelPostInit: [modelName: string]; }> = new Topic(); protected _isReady: boolean = false; protected consumers: Record = {}; protected reducerKeys: string[] = []; protected persister: PersistManager | null = null; protected reducer!: Reducer; constructor() { super(); this.topic.keep('ready', () => this._isReady); } get isReady(): boolean { return this._isReady; } init(options: CreateStoreOptions = {}) { const prevStore = this.origin; const firstInitialize = !prevStore; if (!firstInitialize) { if (process.env.NODE_ENV === 'production') { throw new Error(`[store] 请勿多次执行'store.init()'`); } } this._isReady = false; this.reducer = this.combineReducers(); const persistOptions = options.persist; let persister = this.persister; persister && persister.destroy(); if (persistOptions && persistOptions.length) { persister = this.persister = new PersistManager(persistOptions); this.reducer = persister.combineReducer(this.reducer); } else { persister = this.persister = null; } let store: Store; if (firstInitialize) { const middleware = (options.middleware || []).concat(modelInterceptor); /* istanbul ignore else -- @preserve */ if (process.env.NODE_ENV !== 'production') { middleware.unshift(actionInActionInterceptor); middleware.push(freezeStateMiddleware); } const enhancer = applyMiddleware.apply(null, middleware); store = this.origin = createStore( this.reducer, options.preloadedState, this.getCompose(options.compose)(enhancer), ); this.topic.publish('init'); combine(store); } else { // 重新创建store会导致组件里的subscription都失效 store = prevStore; store.replaceReducer(this.reducer); } if (persister) { persister.init(store, firstInitialize).then(() => { this.ready(); }); } else { this.ready(); } return this; } refresh(force: boolean = false): RefreshAction { const action = this.dispatch(actionRefresh(force)); this.topic.publish('refresh'); return action; } unmount() { this.origin = null; this._isReady = false; this.topic.publish('unmount'); } onInitialized(maybeSync?: () => void): Promise { return new Promise((resolve) => { if (this._isReady) { maybeSync && maybeSync(); resolve(); } else { this.topic.subscribeOnce('ready', () => { maybeSync && maybeSync(); resolve(); }); } }); } protected ready() { this._isReady = true; this.topic.publish('ready'); } protected getCompose(customCompose: CreateStoreOptions['compose']): Compose { if (customCompose === 'redux-devtools') { /* istanbul ignore if -- @preserve */ if (process.env.NODE_ENV !== 'production') { return ( /** @ts-expect-error */ (typeof window === OBJECT ? window : typeof global === OBJECT ? global : {})['__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'] || compose ); } return compose; } return customCompose || compose; } protected combineReducers(): Reducer> { return (state, action) => { if (state === void 0) { state = {}; } const reducerKeys = this.reducerKeys; const consumers = this.consumers; const keyLength = reducerKeys.length; const nextState: Record = {}; let hasChanged = false; let i = keyLength; while (i-- > 0) { const key = reducerKeys[i]!; const prevForKey = state[key]; const nextForKey = (nextState[key] = consumers[key]!( prevForKey, action, )); hasChanged ||= nextForKey !== prevForKey; } return hasChanged || keyLength !== Object.keys(state).length ? nextState : state; }; } protected appendReducer(key: string, consumer: Reducer): void { const store = this.origin; const consumers = this.consumers; const exists = store && consumers.hasOwnProperty(key); consumers[key] = consumer; this.reducerKeys = Object.keys(consumers); store && !exists && store.replaceReducer(this.reducer); } protected removeReducer(key: string): void { const store = this.origin; const consumers = this.consumers; if (consumers.hasOwnProperty(key)) { delete consumers[key]; this.reducerKeys = Object.keys(consumers); store && store.replaceReducer(this.reducer); } } } export const modelStore = new ModelStore(); ================================================ FILE: src/store/proxy-store.ts ================================================ import { legacy_createStore as createStore, Store } from 'redux'; export const proxyStore = createStore(() => ({})); const dispatch = () => { proxyStore.dispatch({ type: '-', }); }; /** * 为了触发connect(),需要将实体store都注册到代理的store。 */ export const combine = (otherStore: Store) => { otherStore.subscribe(dispatch); }; ================================================ FILE: src/store/store-basic.ts ================================================ import { Store } from 'redux'; import { $$observable } from '../utils/symbol-observable'; export abstract class StoreBasic implements Store { protected origin: Store | null = null; /** * @deprecated 请勿使用该方法,因为它其实没有被实现 */ declare replaceReducer: Store['replaceReducer']; dispatch: Store['dispatch'] = (action) => { return this.store.dispatch(action); }; getState: Store['getState'] = () => { return this.store.getState(); }; subscribe: Store['subscribe'] = (listener) => { return this.store.subscribe(listener); }; [$$observable]: Store[typeof $$observable] = () => { return this.store[$$observable](); }; protected get store(): Store { if (!this.origin) { throw new Error(`[store] 当前无实例,忘记执行'store.init()'了吗?`); } return this.origin; } abstract init(): void; abstract unmount(): void; } ================================================ FILE: src/utils/deep-equal.ts ================================================ import { OBJECT } from './is-type'; export const deepEqual = (a: any, b: any): boolean => { if (a === b) return true; if (a && b && typeof a == OBJECT && typeof b == OBJECT) { if (a.constructor !== b.constructor) return false; let i: number; let len: number; let key: string; if (Array.isArray(a)) { len = a.length; if (len != b.length) return false; for (i = len; i-- > 0; ) { if (!deepEqual(a[i], b[i])) return false; } return true; } const keys = Object.keys(a); len = keys.length; if (len !== Object.keys(b).length) return false; for (i = len; i-- > 0; ) { if (!hasOwn.call(b, keys[i]!)) return false; } for (i = len; i-- > 0; ) { key = keys[i]!; if (!deepEqual(a[key], b[key])) return false; } return true; } return a !== a && b !== b; }; const hasOwn = Object.prototype.hasOwnProperty; ================================================ FILE: src/utils/get-method-category.ts ================================================ export const getMethodCategory = (methodName: string) => methodName.indexOf('_') === 0 ? 'internal' : 'external'; ================================================ FILE: src/utils/getter.ts ================================================ import { toArgs } from './to-args'; export function composeGetter< T extends object, U1 extends (...args: any[]) => any, >(obj: T, getter1: U1): T & ReturnType; export function composeGetter< T extends object, U1 extends (...args: any[]) => any, U2 extends (...args: any[]) => any, >(obj: T, getter1: U1, getter2: U2): T & ReturnType & ReturnType; export function composeGetter< T extends object, U1 extends (...args: any[]) => any, U2 extends (...args: any[]) => any, U3 extends (...args: any[]) => any, >( obj: T, getter1: U1, getter2: U2, getter3: U3, ): T & ReturnType & ReturnType & ReturnType; export function composeGetter< T extends object, U1 extends (...args: any[]) => any, U2 extends (...args: any[]) => any, U3 extends (...args: any[]) => any, U4 extends (...args: any[]) => any, >( obj: T, getter1: U1, getter2: U2, getter3: U3, getter4: U4, ): T & ReturnType & ReturnType & ReturnType & ReturnType; export function composeGetter() { const args = toArgs(arguments); return args.reduce((carry, getter) => getter(carry), args.shift() as object); } export const defineGetter = (obj: object, key: string, get: () => any): any => { Object.defineProperty(obj, key, { get, }); return obj; }; ================================================ FILE: src/utils/immer.ts ================================================ import { Immer, enableES5 } from 'immer'; /** * 支持ES5,毕竟Proxy无法polyfill。有些用户手机可以10年不换!! * @link https://immerjs.github.io/immer/docs/installation#pick-your-immer-version * @since immer@6.0 */ enableES5(); export const immer = new Immer({ autoFreeze: false, }); ================================================ FILE: src/utils/is-promise.ts ================================================ import { FUNCTION, isFunction, isObject } from './is-type'; const hasPromise = typeof Promise === FUNCTION; export const isPromise = (value: any): value is Promise => { return ( (hasPromise && value instanceof Promise) || ((isObject(value) || isFunction(value)) && isFunction(value.then)) ); }; ================================================ FILE: src/utils/is-type.ts ================================================ export const OBJECT = 'object'; export const FUNCTION = 'function'; export const isFunction = (value: any): value is T => !!value && typeof value === FUNCTION; export const isObject = (value: any): value is T => !!value && typeof value === OBJECT; export const isPlainObject = (value: any): value is T => !!value && Object.prototype.toString.call(value) === '[object Object]'; export const isString = (value: any): value is T => typeof value === 'string'; ================================================ FILE: src/utils/serialize.ts ================================================ import { isObject } from './is-type'; const JSON_UNDEFINED = '__JSON_UNDEFINED__'; const replacer = (_key: string, value: any) => { return value === void 0 ? JSON_UNDEFINED : value; }; const reviver = (_key: string, value: any) => { if (isObject>(value)) { const keys = Object.keys(value); for (let i = keys.length; i-- > 0; ) { const key = keys[i]!; if (value[key] === JSON_UNDEFINED) { value[key] = void 0; } } } return value; }; export const stringifyState = (value: any) => { return JSON.stringify(value, replacer); }; export const parseState = (value: string) => { return JSON.parse( value, value.indexOf(JSON_UNDEFINED) >= 0 ? reviver : void 0, ); }; ================================================ FILE: src/utils/symbol-observable.ts ================================================ import { FUNCTION } from './is-type'; /** * Inlined version of the `symbol-observable` polyfill * @link https://github.com/reduxjs/redux/blob/master/src/utils/symbol-observable.ts */ export const $$observable: typeof Symbol.observable = (typeof Symbol === FUNCTION && Symbol.observable) || ('@@observable' as unknown as typeof Symbol.observable); ================================================ FILE: src/utils/to-args.ts ================================================ const slice = Array.prototype.slice; export const toArgs = (args: IArguments, start?: number): T => slice.call(args, start) as unknown as T; ================================================ FILE: src/utils/to-promise.ts ================================================ export const toPromise = (fn: () => T | Promise): Promise => { return Promise.resolve().then(fn); }; ================================================ FILE: test/__snapshots__/serialize.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`can clone null and undefined 1`] = `"{"x":["__JSON_UNDEFINED__",1,{"test":"__JSON_UNDEFINED__","test1":"hello"},null,"__JSON_UNDEFINED__"],"y":null,"z":"__JSON_UNDEFINED__"}"`; exports[`can clone null and undefined 2`] = ` { "x": [ undefined, 1, { "test": undefined, "test1": "hello", }, null, undefined, ], "y": null, "z": undefined, } `; ================================================ FILE: test/action-in-action.test.tsx ================================================ import { act, render } from '@testing-library/react'; import { FC, useEffect, version } from 'react'; import sleep from 'sleep-promise'; import { defineModel, FocaProvider, store, useModel } from '../src'; const model = defineModel('aia' + Math.random(), { initialState: { open: false, count: 1, }, reducers: { plus(state) { state.count += 1; }, toggle(state) { state.open = !state.open; }, }, }); const OtherComponent: FC = () => { useEffect(() => { model.plus(); }, []); return null; }; const App: FC = () => { const { open } = useModel(model); return <>{open && }; }; test.runIf(version.split('.')[0] === '18').each([true, false])( `[legacy: %s] forceUpdate should not cause action in action error`, async (legacy) => { store.init(); render( , { legacyRoot: legacy, }, ); // console.error // Warning: You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. const spy = vitest.spyOn(console, 'error').mockImplementation(() => {}); await expect( Promise.all( Array(3) .fill('') .map((_, i) => act(async () => { await sleep(i * 2); model.toggle(); }), ), ), ).resolves.toStrictEqual(Array(3).fill(void 0)); // 等待useEffect await sleep(1000); store.unmount(); spy.mockRestore(); }, ); ================================================ FILE: test/build.test.ts ================================================ import { writeFileSync } from 'fs'; import { execSync, exec } from 'child_process'; function testFile(filename: string, expectCode: number) { return new Promise((resolve) => { const child = exec(`node ${filename}`); child.on('exit', (code) => { try { expect(code).toBe(expectCode); } finally { resolve(code); } }); }); } beforeEach(() => { execSync('npx tsup'); }, 10000); test('ESM with type=module', async () => { await testFile('dist/esm/index.js', 0); }); test('ESM with type=commonjs', async () => { writeFileSync('dist/esm/package.json', '{"type": "commonjs"}'); await testFile('dist/esm/index.js', 1); }); test('pure commonjs', async () => { await testFile('dist/index.js', 0); }); ================================================ FILE: test/clone.test.ts ================================================ import { defineModel, cloneModel, store } from '../src'; import type { InternalModel } from '../src/model/types'; import { basicModel } from './models/basic.model'; let modelIndex = 0; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('Model can be cloned', async () => { const model = cloneModel('model' + ++modelIndex, basicModel); expect(model.state.hello).toBe('world'); expect(model.state.count).toBe(0); model.plus(11); expect(model.state.count).toBe(11); await expect(model.foo('earth', 5)).resolves.toBe('OK'); expect(model.state.hello).toBe('earth'); expect(model.state.count).toBe(16); }); test('Cloned model name', () => { const model = cloneModel('model' + ++modelIndex, basicModel); expect(model.name).toBe('model' + modelIndex); }); test('Clone model with same name is invalid', () => { const model = defineModel(Date.now().toString(), { initialState: {} }); expect(() => cloneModel(model.name, model)).toThrowError(); }); test('Override state', () => { const model = cloneModel('model' + ++modelIndex, basicModel, { initialState: { count: 20, hello: 'cat', }, }) as unknown as InternalModel; expect(model._$opts.initialState).toStrictEqual({ count: 20, hello: 'cat', }); }); test('Override persist', () => { const model1 = defineModel('model' + ++modelIndex, { initialState: {}, persist: { dump: (state) => state, load: (state) => state, }, methods: { cc() { return 3; }, }, }); const model2 = cloneModel('model' + ++modelIndex, model1, { persist: {}, }) as unknown as InternalModel; expect(model2._$opts.persist).not.toHaveProperty('maxAge'); const model3 = cloneModel('model' + ++modelIndex, model1, (prev) => { return { persist: { ...prev.persist, maxAge: 30, }, }; }) as unknown as InternalModel; expect(model3._$opts.persist).toHaveProperty('maxAge'); expect(model3._$opts.persist).toHaveProperty('dump'); expect(model3._$opts.persist).toHaveProperty('load'); }); test('override methods or unknown option can cause error', () => { const model = defineModel('model' + ++modelIndex, { initialState: {} }); expect(() => cloneModel('a', model, { // @ts-expect-error reducers: {}, }), ).toThrowError(); expect(() => cloneModel('b', model, { // @ts-expect-error methods: {}, }), ).toThrowError(); expect(() => cloneModel('c', model, { // @ts-expect-error computed: {}, }), ).toThrowError(); expect(() => cloneModel('d', model, { // @ts-expect-error whateverblabla: {}, }), ).toThrowError(); }); ================================================ FILE: test/computed.test.ts ================================================ import { defineModel, store } from '../src'; import { ComputedValue } from '../src/reactive/computed-value'; import { depsCollector } from '../src/reactive/deps-collector'; import { ObjectDeps } from '../src/reactive/object-deps'; import { computedModel } from './models/computed.model'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('Get computed value', () => { expect(computedModel.fullName()).toBe('ticktock'); computedModel.changeFirstName('hello'); expect(computedModel.fullName()).toBe('hellotock'); computedModel.changeLastName('world'); expect(computedModel.fullName()).toBe('helloworld'); }); test('Get computed value in computed function', () => { expect(computedModel.testDependentOtherComputed()).toBe('ticktock [online]'); }); test('Can return correct result from Object.keys', () => { expect(computedModel.testObjectKeys()).toMatchObject( expect.arrayContaining(['online', 'offline']), ); }); test('can enum all items for find method', () => { expect(computedModel.testFind()).toBe('offline'); }); test('can visit array item', () => { expect(computedModel.testVisitArray()[0]).toBe('online'); }); test('can return correct length from array', () => { expect(computedModel.testArrayLength()).toBe(2); }); test('Can throw error with circularly reference', () => { const model = defineModel('computed-cycle-usage', { initialState: {}, computed: { a() { this.b(); }, b() { this.c(); }, c() { this.a(); }, }, }); expect(() => model.a()).toThrowError('循环引用'); expect(() => model.b()).toThrowError('循环引用'); expect(() => model.c()).toThrowError('循环引用'); }); test('Can visit compute value from methods', () => { expect(computedModel.effectsGetFullName()).toBe('ticktock'); }); test('Split the deps for same getters', () => { let mockState = { a: { b: { c: 'd' } } }; const mockStore = { getState() { return { x: mockState, }; }, }; let deps = depsCollector.produce(() => { const proxy = new ObjectDeps(mockStore, 'x'); const proxyState = proxy.start(mockState); proxyState.a.b; proxyState.a.b.c; }); expect(deps).toHaveLength(2); deps = depsCollector.produce(() => { const proxy = new ObjectDeps(mockStore, 'x'); const proxyState = proxy.start(mockState); proxyState.a.b; }); expect(deps).toHaveLength(1); }); test('Dirty deps never turn to clean', () => { let mockState = { a: { b: { c: 'd' } } }; const mockStore = { getState() { return { x: mockState, }; }, }; let deps = depsCollector.produce(() => { const proxy = new ObjectDeps(mockStore, 'x'); const proxyState = proxy.start(mockState); proxyState.a.b; proxyState.a.b.c; }); expect(deps[0]!.isDirty()).toBeFalsy(); expect(deps[1]!.isDirty()).toBeFalsy(); mockState = { a: { b: { c: 'd' } } }; expect(deps[0]!.isDirty()).toBeTruthy(); expect(deps[1]!.isDirty()).toBeFalsy(); // @ts-expect-error mockState = { a: { b: 'm' } }; expect(deps[0]!.isDirty()).toBeTruthy(); expect(deps[1]!.isDirty()).toBeTruthy(); mockState = { a: { b: { c: 'e' } } }; expect(deps[0]!.isDirty()).toBeTruthy(); expect(deps[1]!.isDirty()).toBeTruthy(); mockState = { a: { b: { c: 'e' } } }; expect(deps[0]!.isDirty()).toBeTruthy(); expect(deps[1]!.isDirty()).toBeTruthy(); }); test('visit proxy state without collecting mode should not collect deps', () => { let mockState = { a: { b: { c: 'd' } } }; const mockStore = { getState() { return { x: mockState, }; }, }; const spy = vitest.spyOn(depsCollector, 'append'); let proxyState!: typeof mockState; depsCollector.produce(() => { proxyState = new ObjectDeps(mockStore, 'x').start(mockState); proxyState.a; proxyState.a.b; }); expect(spy).toHaveBeenCalledTimes(2); spy.mockClear(); expect(proxyState.a).toMatchObject({ b: { c: 'd', }, }); expect(proxyState.a.b).toMatchObject({ c: 'd', }); expect(spy).toHaveBeenCalledTimes(0); spy.mockClear(); depsCollector.produce(() => { const custom = new ObjectDeps(mockStore, 'x').start(mockState); custom.a; proxyState.a; proxyState.a.b; proxyState.a.b.c; }); expect(spy).toHaveBeenCalledTimes(1); }); test('ComputedValue can remove duplicated deps', () => { const mockStore = { getState() { return { [computedModel.name]: store.getState()[computedModel.name], }; }, }; const computedValue = new ComputedValue( mockStore, computedModel.name, 'prop', () => { computedModel.state.firstName; computedModel.state.firstName; computedModel.state.lastName; computedModel.fullName(); computedModel.fullName(); }, ); // Collecting computedValue.value; expect(computedValue.deps).toHaveLength(3); }); test('计算属性包含子计算属性时,子计算属性被作为一个整体', () => { const mockStore = { getState() { return { [computedModel.name]: store.getState()[computedModel.name], }; }, }; const computedValue = new ComputedValue( mockStore, computedModel.name, 'prop', () => { return computedModel.fullName() + 1; }, ); expect(computedValue.value).toBe('ticktock1'); computedModel.changeFirstName('hello-'); expect(computedValue.isDirty()).toBeTruthy(); expect(computedValue.value).toBe('hello-tock1'); }); test('only execute computed function when deps changed', () => { const spy = vitest.fn().mockImplementation(() => { model.state.a; }); const model = defineModel('x' + Math.random(), { initialState: { a: 0, b: 2, }, reducers: { updateA(state) { state.a += 1; }, updateB(state) { state.b += 1; }, }, computed: { testa: spy, }, }); expect(spy).toBeCalledTimes(0); model.testa(); expect(spy).toBeCalledTimes(1); model.testa(); expect(spy).toBeCalledTimes(1); model.updateB(); model.testa(); expect(spy).toBeCalledTimes(1); model.updateA(); model.testa(); expect(spy).toBeCalledTimes(2); model.testa(); expect(spy).toBeCalledTimes(2); }); test('Can handle JSON.stringify', () => { expect(computedModel.testJSON()).toBe(JSON.stringify(computedModel.state)); }); test('Fail to set value on proxy state', () => { expect(() => computedModel.testExtendObject()).toThrowError(); expect(() => computedModel.testModifyValue()).toThrowError(); }); test('no private computed value', () => { // @ts-expect-error expect(computedModel._privateFullname).toBeUndefined(); }); test('support parameters', () => { expect(computedModel.withParameter(31)).toBe('tick-age-31'); expect(computedModel.withDefaultParameter()).toBe('tick-age-20'); expect(computedModel.withMultipleParameters(50, 'adddddr')).toBe( 'tick-age-50-addr-adddddr', ); expect(computedModel.withMultipleAndDefaultParameters(50)).toBe( 'tick-age-50-addr-undefined', ); }); test('never re-calculate value with same parameters', () => { const spy = vitest.fn(); const model = defineModel('computed-with-params', { initialState: { name: 'x' }, computed: { myData(age: number, coding: boolean) { spy(); return this.state.name + '-' + age + String(coding); }, }, }); model.myData(20, true); expect(spy).toBeCalledTimes(1); model.myData(20, true); model.myData(20, true); model.myData(20, true); expect(spy).toBeCalledTimes(1); model.myData(20, false); expect(spy).toBeCalledTimes(2); model.myData(20, false); expect(spy).toBeCalledTimes(2); model.myData(34, false); expect(spy).toBeCalledTimes(3); model.myData(20, false); // cache expect(spy).toBeCalledTimes(3); spy.mockRestore(); }); test('remove computed value for cache always be skipped', () => { const spy = vitest.fn(); const model = defineModel('computed-with-remove-cache', { initialState: { name: 'x' }, computed: { myData(age: number, coding: boolean) { spy(); return this.state.name + '-' + age + String(coding); }, }, }); for (let i = 0; i < 30; ++i) { model.myData(i, true); } expect(spy).toBeCalledTimes(30); spy.mockReset(); model.myData(1, true); expect(spy).toBeCalledTimes(1); spy.mockRestore(); }); test('complex parameter can not hit cache', () => { const spy = vitest.fn(); const model = defineModel('computed-with-complex-parameter', { initialState: { name: 'x' }, computed: { myData(opts: object) { spy(); return this.state.name + '-' + JSON.stringify(opts); }, }, }); const obj = {}; model.myData(obj); expect(spy).toBeCalledTimes(1); model.myData(obj); expect(spy).toBeCalledTimes(1); model.myData({}); expect(spy).toBeCalledTimes(2); model.myData(obj); expect(spy).toBeCalledTimes(2); spy.mockRestore(); }); test('array should always be deps', () => { const spy = vitest.fn(); const model = defineModel('computed-from-array', { initialState: { x: [{ foo: 'bar' } as { foo: string; other?: string }], y: {}, }, reducers: { update(state, other: string) { state.x = [{ foo: 'bar' }, { foo: 'baz', other }]; }, }, computed: { myData() { spy(); return this.state.x.filter((item) => item.foo === 'bar'); }, }, }); model.myData(); expect(spy).toBeCalledTimes(1); model.update('baz'); model.myData(); expect(spy).toBeCalledTimes(2); model.update('x'); model.myData(); expect(spy).toBeCalledTimes(3); model.update('y'); model.myData(); expect(spy).toBeCalledTimes(4); }); test('re-calculate when path changed', () => { const spy = vitest.fn(); const model = defineModel('re-calculate-path-changed', { initialState: { foo: { bar: undefined } as undefined | { bar: string | undefined }, baz: '123', }, reducers: { updateFoo(state, foo: undefined | { bar: string | undefined }) { state.foo = foo; }, }, computed: { myData() { const foo = this.state.foo; spy(); return foo ? foo.bar : this.state.baz; }, }, }); expect(model.myData()).toBeUndefined(); expect(spy).toBeCalledTimes(1); model.updateFoo(undefined); expect(model.myData()).toBe('123'); expect(spy).toBeCalledTimes(2); model.updateFoo({ bar: undefined }); expect(model.myData()).toBeUndefined(); expect(spy).toBeCalledTimes(3); model.updateFoo({ bar: 'abc' }); expect(model.myData()).toBe('abc'); expect(spy).toBeCalledTimes(4); model.updateFoo({ bar: undefined }); expect(model.myData()).toBeUndefined(); expect(spy).toBeCalledTimes(5); }); ================================================ FILE: test/connect.test.tsx ================================================ import { FC } from 'react'; import { act, render, screen } from '@testing-library/react'; import { store, connect, FocaProvider, getLoading } from '../src'; import { basicModel } from './models/basic.model'; import { complexModel } from './models/complex.model'; let App: FC> = ({ count, loading }) => { return ( <>
{count}
{loading.toString()}
); }; const mapStateToProps = () => { return { count: basicModel.state.count + complexModel.state.ids.length, loading: getLoading(basicModel.pureAsync), }; }; const Wrapped = connect(mapStateToProps)(App); const Root: FC = () => { return ( ); }; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('Get state from connect', async () => { render(); const $count = screen.queryByTestId('count')!; const $loading = screen.queryByTestId('loading')!; expect($count.innerHTML).toBe('0'); expect($loading.innerHTML).toBe('false'); act(() => { basicModel.plus(0); }); expect($count.innerHTML).toBe('0'); act(() => { basicModel.plus(1); }); expect($count.innerHTML).toBe('1'); act(() => { basicModel.plus(20.5); }); expect($count.innerHTML).toBe('21.5'); act(() => { complexModel.addUser(40, ''); }); expect($count.innerHTML).toBe('22.5'); let promise!: Promise; act(() => { promise = basicModel.pureAsync(); }); expect($loading.innerHTML).toBe('true'); await act(async () => { await promise; }); expect($loading.innerHTML).toBe('false'); }); ================================================ FILE: test/deep-equal.test.ts ================================================ import { deepEqual } from '../src/utils/deep-equal'; import { equals } from './fixtures/equals'; import { notEquals } from './fixtures/not-equals'; Object.entries(equals).map(([title, { a, b }]) => { test(`[equal] ${title}`, () => { expect(deepEqual(a, b)).toBeTruthy(); }); }); Object.entries(notEquals).map(([title, { a, b }]) => { test(`[not equal] ${title}`, () => { expect(deepEqual(a, b)).toBeFalsy(); }); }); ================================================ FILE: test/engine.test.ts ================================================ import 'fake-indexeddb/auto'; import localforage from 'localforage'; import ReactNativeStorage from '@react-native-async-storage/async-storage'; import { toPromise } from '../src/utils/to-promise'; import { memoryStorage } from '../src'; const storages = [ [localStorage, 'local'], [sessionStorage, 'session'], [memoryStorage, 'memory'], [ localforage.createInstance({ driver: localforage.LOCALSTORAGE }), 'localforage local', ], [ localforage.createInstance({ driver: localforage.INDEXEDDB }), 'localforage indexedDb', ], [ReactNativeStorage, 'react-native'], ] as const; describe.each(storages)('storage io', (storage, name) => { beforeEach(() => storage.clear()); afterEach(() => storage.clear()); test(`[${name}] Get and set data`, async () => { await expect(toPromise(() => storage.getItem('test1'))).resolves.toBeNull(); await storage.setItem('test1', 'yes'); await expect(toPromise(() => storage.getItem('test1'))).resolves.toBe( 'yes', ); }); test(`[${name}] Update data`, async () => { await storage.setItem('test2', 'yes'); await expect(toPromise(() => storage.getItem('test2'))).resolves.toBe( 'yes', ); await storage.setItem('test2', 'no'); await expect(toPromise(() => storage.getItem('test2'))).resolves.toBe('no'); }); test(`[${name}] Delete data`, async () => { await storage.setItem('test3', 'yes'); await expect(toPromise(() => storage.getItem('test3'))).resolves.toBe( 'yes', ); await storage.removeItem('test3'); await expect(toPromise(() => storage.getItem('test3'))).resolves.toBeNull(); }); test(`[${name}] Clear all data`, async () => { await storage.setItem('test4', 'yes'); await storage.setItem('test5', 'yes'); await storage.clear(); await expect(toPromise(() => storage.getItem('test4'))).resolves.toBeNull(); await expect(toPromise(() => storage.getItem('test5'))).resolves.toBeNull(); }); }); ================================================ FILE: test/fixtures/equals.ts ================================================ export const equals: Record = { '0 and -0': { a: 0, b: -0, }, 'NaN': { a: NaN, b: NaN, }, 'number': { a: 1, b: 1, }, 'string': { a: 'x', b: 'x', }, 'empty array': { a: [], b: [], }, 'array': { a: [1, 2, 'x'], b: [1, 2, 'x'], }, 'complex array': { a: [1, { x: { y: 2, z: 3, x: [3, 6] } }, 5], b: [1, { x: { y: 2, z: 3, x: [3, 6] } }, 5], }, 'object': { a: { x: 1, y: 2 }, b: { x: 1, y: 2 }, }, 'complex object': { a: { x: { y: { z: [3, 6], p: [ { m: 6, }, ], }, }, }, b: { x: { y: { z: [3, 6], p: [ { m: 6, }, ], }, }, }, }, 'object without prototype': { a: Object.create(null), b: Object.create(null), }, }; ================================================ FILE: test/fixtures/not-equals.ts ================================================ export const notEquals: Record = { '0 and NaN': { a: 0, b: NaN, }, '0 and null': { a: 0, b: null, }, '0 and undefined': { a: 0, b: undefined, }, '0 and false': { a: 0, b: false, }, 'null and undefined': { a: null, b: undefined, }, 'array and arrayLike': { a: [], b: { length: 0 }, }, 'array and arrayLike with length in prototype': { a: [], b: Object.create({ length: 0 }), }, 'array and arrayLike with items': { a: [1, 'x'], b: { 0: 1, 1: 'x', length: 2 }, }, 'number': { a: 1, b: 2, }, 'string': { a: 'x', b: 'y', }, 'number and object': { a: 1, b: {}, }, 'number and array': { a: 3, b: [], }, 'object and array': { a: [], b: {}, }, 'array': { a: [1], b: [2], }, 'complex array': { a: [1, { x: { y: 2, z: 3, x: [3] } }, 5], b: [1, { x: { y: 2, z: 3, x: [3, 6] } }, 5], }, 'array with different length': { a: [1], b: [1, 2, 3], }, 'object': { a: { x: 1 }, b: { x: 2 }, }, 'complex object': { a: { x: { y: { z: [3, 6], }, }, }, b: { x: { y: { z: [3, 5], }, }, }, }, 'object with different properties': { a: { x: 1 }, b: { x: 1, y: 2 }, }, 'with different constructor': { a: new (class {})(), b: new (class {})(), }, 'Object.keys() get same length but not own property': { a: { x: 3, y: 4 }, b: (() => { const b = Object.create({ x: 3 }); b.y = 4; b.z = 5; return b; })(), }, }; ================================================ FILE: test/get-loading.test.ts ================================================ import sleep from 'sleep-promise'; import { defineModel, getLoading, store } from '../src'; import { basicModel } from './models/basic.model'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('Collect loading status for method', async () => { expect(getLoading(basicModel.bos)).toBeFalsy(); const promise = basicModel.bos(); expect(getLoading(basicModel.bos)).toBeTruthy(); await promise; expect(getLoading(basicModel.bos)).toBeFalsy(); }); test('Collect error message for method', async () => { expect(getLoading(basicModel.hasError)).toBeFalsy(); const promise = basicModel.hasError(); expect(getLoading(basicModel.hasError)).toBeTruthy(); await expect(promise).rejects.toThrowError('my-test'); expect(getLoading(basicModel.hasError)).toBeFalsy(); }); test('Trace loadings', async () => { expect(getLoading(basicModel.bos.room, 'x')).toBeFalsy(); expect(getLoading(basicModel.bos.room).find('x')).toBeFalsy(); const promise = basicModel.bos.room('x').execute(); expect(getLoading(basicModel.bos.room, 'x')).toBeTruthy(); expect(getLoading(basicModel.bos.room).find('x')).toBeTruthy(); expect(getLoading(basicModel.bos.room, 'y')).toBeFalsy(); expect(getLoading(basicModel.bos.room).find('y')).toBeFalsy(); expect(getLoading(basicModel.bos)).toBeFalsy(); await promise; expect(getLoading(basicModel.bos.room, 'x')).toBeFalsy(); expect(getLoading(basicModel.bos.room).find('x')).toBeFalsy(); }); test('async method in model.onInit should be activated automatically', async () => { const hookModel = defineModel('loading' + Math.random(), { initialState: {}, methods: { async myMethod() { await sleep(200); }, async myMethod2() { await sleep(200); }, }, events: { async onInit() { await this.myMethod(); await this.myMethod2(); }, }, }); await store.onInitialized(); expect(getLoading(hookModel.myMethod)).toBeTruthy(); await sleep(220); expect(getLoading(hookModel.myMethod)).toBeFalsy(); expect(getLoading(hookModel.myMethod2)).toBeTruthy(); await sleep(220); expect(getLoading(hookModel.myMethod2)).toBeFalsy(); }); ================================================ FILE: test/helpers/render-hook.tsx ================================================ import { renderHook as originRenderHook } from '@testing-library/react'; import { FocaProvider } from '../../src'; export const renderHook: typeof originRenderHook = ( renderCallback, options, ) => { return originRenderHook(renderCallback, { wrapper: FocaProvider, ...options, }); }; ================================================ FILE: test/helpers/slow-engine.ts ================================================ import type { StorageEngine } from '../../src'; import { toPromise } from '../../src/utils/to-promise'; let cache: Partial> = {}; export const slowEngine: StorageEngine = { getItem(key) { return new Promise((resolve) => { setTimeout(() => { resolve(cache[key] === void 0 ? null : cache[key]!); }, 300); }); }, setItem(key, value) { return toPromise(() => { cache[key] = value; }); }, removeItem(key) { return toPromise(() => { cache[key] = void 0; }); }, clear() { return toPromise(() => { cache = {}; }); }, }; ================================================ FILE: test/lifecycle.test.ts ================================================ import sleep from 'sleep-promise'; import { cloneModel, defineModel, memoryStorage, store } from '../src'; import { PersistSchema } from '../src/persist/persist-item'; describe('onInit', () => { afterEach(() => { store.unmount(); }); const createModel = () => { return defineModel('events' + Math.random(), { initialState: { count: 0 }, methods: { invokeByReadyHook() { this.setState((state) => { state.count += 101; }); }, }, events: { onInit() { this.invokeByReadyHook(); }, }, }); }; test('trigger ready events on store ready', async () => { const hookModel = createModel(); store.init(); const hook2Model = createModel(); const clonedModel = cloneModel('events' + Math.random(), hookModel); await Promise.resolve(); expect(hookModel.state.count).toBe(101); expect(hook2Model.state.count).toBe(101); expect(clonedModel.state.count).toBe(101); }); test('trigger ready events on store and persist ready', async () => { const hookModel = createModel(); await memoryStorage.setItem( 'mm:z', JSON.stringify({ v: 1, d: { [hookModel.name]: { v: 0, d: JSON.stringify({ count: 20 }), }, }, }), ); store.init({ persist: [ { key: 'z', keyPrefix: 'mm:', version: 1, engine: memoryStorage, models: [hookModel], }, ], }); const hook2Model = createModel(); const clonedModel = cloneModel('events' + Math.random(), hookModel); expect(hookModel.state.count).toBe(0); expect(hook2Model.state.count).toBe(0); expect(clonedModel.state.count).toBe(0); await store.onInitialized(); expect(hookModel.state.count).toBe(101 + 20); expect(hook2Model.state.count).toBe(101); expect(clonedModel.state.count).toBe(101); }); test('should call modelPreInit and modelPostInit', async () => { const hookModel = createModel(); let publishCount = 0; const token1 = store.topic.subscribe('modelPreInit', (modelName) => { if (modelName === hookModel.name) { publishCount += 1; } }); const token2 = store.topic.subscribe('modelPostInit', (modelName) => { if (modelName === hookModel.name) { publishCount += 0.4; } }); store.init(); await store.onInitialized(); expect(publishCount).toBe(1 + 0.4); token1.unsubscribe(); token2.unsubscribe(); }); test('should call modelPreInit and modelPostInit for promise returning', async () => { const hookModel = defineModel('events' + Math.random(), { initialState: {}, events: { async onInit() { await sleep(200); }, }, }); let publishCount = 0; const token1 = store.topic.subscribe('modelPreInit', (modelName) => { if (modelName === hookModel.name) { publishCount += 1; } }); const token2 = store.topic.subscribe('modelPostInit', (modelName) => { if (modelName === hookModel.name) { publishCount += 0.4; } }); store.init(); await store.onInitialized(); expect(publishCount).toBe(1); await sleep(210); expect(publishCount).toBe(1 + 0.4); token1.unsubscribe(); token2.unsubscribe(); }); }); describe('onChange', () => { beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('onChange should call after onInit', async () => { let testMessage = ''; const model = defineModel('events' + Math.random(), { initialState: { count: 0 }, reducers: { plus(state) { state.count += 1; }, minus(state) { state.count -= 1; }, }, methods: { _invokeByReadyHook() { this.setState((state) => { state.count += 2; }); }, }, events: { onInit() { testMessage += 'onInit-'; this._invokeByReadyHook(); }, onChange(prevState, nextState) { testMessage += `prev-${prevState.count}-next-${nextState.count}-`; }, }, }); model.plus(); model.minus(); expect(testMessage).toBe(''); await store.onInitialized(); expect(testMessage).toBe('onInit-prev-0-next-2-'); model.plus(); expect(testMessage).toBe('onInit-prev-0-next-2-prev-2-next-3-'); store.refresh(); expect(testMessage).toBe( 'onInit-prev-0-next-2-prev-2-next-3-prev-3-next-0-', ); }); }); describe('onDestroy', () => { beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('call onDestroy when invoke store.destroy()', async () => { const spy = vitest.fn(); const model = defineModel('events' + Math.random(), { initialState: { count: 0 }, reducers: { update(state) { state.count += 1; }, }, events: { onDestroy: spy, }, }); await store.onInitialized(); model.update(); expect(spy).toBeCalledTimes(0); store['removeReducer'](model.name); expect(spy).toBeCalledTimes(1); spy.mockRestore(); }); test('should not call onChange', async () => { const destroySpy = vitest.fn(); const changeSpy = vitest.fn(); const model = defineModel('events' + Math.random(), { initialState: { count: 0 }, reducers: { update(state) { state.count += 1; }, }, events: { onChange: changeSpy, onDestroy: destroySpy, }, }); await store.onInitialized(); model.update(); expect(destroySpy).toBeCalledTimes(0); expect(changeSpy).toBeCalledTimes(1); store['removeReducer'](model.name); expect(destroySpy).toBeCalledTimes(1); expect(changeSpy).toBeCalledTimes(1); destroySpy.mockRestore(); }); }); ================================================ FILE: test/middleware.test.ts ================================================ import { defineModel, getLoading, store } from '../src'; import { DestroyLoadingAction, DESTROY_LOADING } from '../src/actions/loading'; import { loadingStore } from '../src/store/loading-store'; import { basicModel } from './models/basic.model'; import { complexModel } from './models/complex.model'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('dispatch the same state should be intercepted', () => { const fn = vitest.fn(); const unsubscribe = store.subscribe(fn); expect(fn).toHaveBeenCalledTimes(0); basicModel.set(100); expect(fn).toHaveBeenCalledTimes(1); basicModel.set(100); basicModel.set(100); expect(fn).toHaveBeenCalledTimes(1); basicModel.set(101); expect(fn).toHaveBeenCalledTimes(2); complexModel.deleteUser(30); complexModel.deleteUser(34); expect(fn).toHaveBeenCalledTimes(2); complexModel.addUser(5, 'L'); expect(fn).toHaveBeenCalledTimes(3); complexModel.addUser(5, 'L'); expect(fn).toHaveBeenCalledTimes(3); complexModel.addUser(5, 'LT'); expect(fn).toHaveBeenCalledTimes(4); unsubscribe(); fn.mockRestore(); }); test('dispatch the same loading should be intercepted', async () => { const fn = vitest.fn(); const unsubscribe = loadingStore.subscribe(fn); loadingStore.inactivate(basicModel.name, 'pureAsync'); expect(fn).toHaveBeenCalledTimes(0); await basicModel.pureAsync(); await basicModel.pureAsync(); expect(fn).toHaveBeenCalledTimes(0); loadingStore.activate(basicModel.name, 'pureAsync'); await basicModel.pureAsync(); expect(fn).toHaveBeenCalledTimes(2); await basicModel.pureAsync(); await basicModel.pureAsync(); expect(fn).toHaveBeenCalledTimes(6); await Promise.all([basicModel.pureAsync(), basicModel.pureAsync()]); expect(fn).toHaveBeenCalledTimes(8); unsubscribe(); fn.mockRestore(); }); test('destroy model will not trigger reducer without method called', () => { const spy = vitest.fn(); loadingStore.subscribe(spy); loadingStore.dispatch({ type: DESTROY_LOADING, model: basicModel.name, }); expect(spy).toBeCalledTimes(0); spy.mockRestore(); }); test('destroy model will trigger reducer with method called', async () => { await basicModel.pureAsync(); const spy = vitest.fn(); loadingStore.subscribe(spy); loadingStore.dispatch({ type: DESTROY_LOADING, model: basicModel.name, }); expect(spy).toBeCalledTimes(1); spy.mockRestore(); }); test('reducer in reducer is invalid operation', () => { const model1 = defineModel('aia-1', { initialState: {}, reducers: { test1() {}, }, methods: { async ok() {}, notOk() { this.test1(); }, }, }); const model2 = defineModel('aia-2', { initialState: {}, reducers: { test2() { model1.test1(); }, test3() { model1.ok(); }, test4() { model1.notOk(); }, }, }); expect(() => model2.test2()).toThrowError('[dispatch]'); getLoading(model1.ok); expect(() => model2.test3()).not.toThrowError(); expect(() => model2.test4()).toThrowError(); }); test('freeze model state', () => { expect(Object.isFrozen(store.getState())).toBeTruthy(); expect(Object.isFrozen(complexModel.state)).toBeTruthy(); expect(Object.isFrozen(complexModel.state.ids)).toBeTruthy(); expect(Object.isFrozen(complexModel.state.users)).toBeTruthy(); complexModel.addUser(10, 'tom'); expect(Object.isFrozen(complexModel.state)).toBeTruthy(); expect(Object.isFrozen(complexModel.state.ids)).toBeTruthy(); expect(Object.isFrozen(complexModel.state.users)).toBeTruthy(); expect(() => complexModel.state.ids.push(2)).toThrowError(); }); test('freeze loading state', async () => { expect(Object.isFrozen(loadingStore.getState())).toBeTruthy(); expect(Object.isFrozen(getLoading(basicModel.pureAsync))).toBeTruthy(); loadingStore.activate(basicModel.name, 'pureAsync'); const promise = basicModel.pureAsync(); expect(Object.isFrozen(getLoading(basicModel.pureAsync.room))).toBeTruthy(); await promise; expect(Object.isFrozen(getLoading(basicModel.pureAsync.room))).toBeTruthy(); }); ================================================ FILE: test/model.test.ts ================================================ import { defineModel, store } from '../src'; import { basicModel } from './models/basic.model'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('Model name', () => { expect(basicModel.name).toBe('basic'); }); test('initialState should be serializable', () => { const createModel = (initialState: any) => { return defineModel('model' + Math.random(), { initialState }); }; [ { x: Symbol('test') }, [Symbol('test')], { x: function () {} }, { x: /test/ }, { x: new Map() }, { x: new Set() }, { x: new Date() }, [new (class {})()], new (class {})(), ].forEach((initialState) => { expect(() => createModel(initialState)).toThrowError(); }); [ { x: undefined }, { x: undefined, y: null }, { x: 0 }, [0, 1, '2', {}, { x: null }], { x: { y: { z: [{}, {}] } } }, ].forEach((initialState) => { createModel(initialState); }); }); test('Reset model state', () => { basicModel.moreParams(3, 'earth'); expect(basicModel.state.count).toBe(3); expect(basicModel.state.hello).toBe('world, earth'); basicModel.reset(); expect(basicModel.state.count).toBe(0); expect(basicModel.state.hello).toBe('world'); }); test('Call reducer', () => { expect(basicModel.state.count).toBe(0); basicModel.plus(1); expect(basicModel.state.count).toBe(1); basicModel.plus(6); expect(basicModel.state.count).toBe(7); basicModel.minus(3); expect(basicModel.state.count).toBe(4); }); test('call reducer with multiple parameters', () => { expect(basicModel.state.count).toBe(0); expect(basicModel.state.hello).toBe('world'); basicModel.moreParams(13, 'timi'); expect(basicModel.state.count).toBe(13); expect(basicModel.state.hello).toBe('world, timi'); }); test('Set state in methods', async () => { expect(basicModel.state.count).toBe(0); expect(basicModel.state.hello).toBe('world'); await expect(basicModel.foo('earth', 15)).resolves.toBe('OK'); expect(basicModel.state.count).toBe(15); expect(basicModel.state.hello).toBe('earth'); }); test('Set state without function callback in methods', () => { expect(basicModel.state.count).toBe(0); basicModel.setWithoutFn(15); expect(basicModel.state.count).toBe(15); basicModel.setWithoutFn(26); expect(basicModel.state.count).toBe(26); basicModel.setWithoutFn(54.3); expect(basicModel.state.count).toBe(54.3); }); test('set partial object state in methods', () => { type State = { test: { count: number }; hello: string | undefined; name: string; }; const model = defineModel('partial-object-model', { initialState: { test: { count: 0, }, hello: 'world', name: 'timi', }, methods: { setNothing() { this.setState({}); }, setCount() { this.setState({ test: { count: 2, }, }); }, setHello() { this.setState({ hello: 'x', }); }, setHelloByFn() { this.setState(() => { return { hello: 'xx', }; }); }, setNothingByFn() { this.setState(() => ({})); }, override() { this.setState({ // @ts-expect-error test: 123, }); }, }, }); model.setCount(); expect(model.state.test).toStrictEqual({ count: 2, }); expect(model.state.hello).toBe('world'); model.setHello(); expect(model.state.test).toStrictEqual({ count: 2, }); expect(model.state.hello).toBe('x'); model.setNothing(); expect(model.state.test).toStrictEqual({ count: 2, }); expect(model.state.hello).toBe('x'); model.setHelloByFn(); expect(model.state.test).toStrictEqual({ count: 2, }); expect(model.state.hello).toStrictEqual('xx'); model.setNothingByFn(); expect(model.state.test).toStrictEqual({ count: 2, }); expect(model.state.hello).toStrictEqual('xx'); model.override(); expect(model.state.test).toBe(123); }); test('set partial array state in methods', () => { const model = defineModel('partial-array-model', { initialState: ['2'], methods: { set() { this.setState(['20', '30']); }, }, }); model.set(); expect(model.state).toStrictEqual(['20', '30']); }); test('private reducer and method', () => { expect( // @ts-expect-error basicModel._actionIsPrivate, ).toBeUndefined(); expect( // @ts-expect-error basicModel._effectIsPrivate, ).toBeUndefined(); expect( // @ts-expect-error basicModel.____alsoPrivateAction, ).toBeUndefined(); expect( // @ts-expect-error basicModel.____alsoPrivateEffect, ).toBeUndefined(); expect(basicModel.plus).toBeInstanceOf(Function); expect(basicModel.pureAsync).toBeInstanceOf(Function); }); test('define duplicated method keys will throw error', () => { expect(() => defineModel('x' + Math.random(), { initialState: {}, reducers: { test1() {}, }, methods: { test1() {}, test2() {}, }, }), ).toThrowError('test1'); expect(() => defineModel('x' + Math.random(), { initialState: {}, reducers: { test2() {}, }, computed: { test1() {}, test2() {}, }, }), ).toThrowError('test2'); expect(() => defineModel('x' + Math.random(), { initialState: {}, methods: { test2() {}, }, computed: { test1() {}, test2() {}, }, }), ).toThrowError('test2'); }); ================================================ FILE: test/models/basic.model.ts ================================================ import sleep from 'sleep-promise'; import { cloneModel, defineModel } from '../../src'; const initialState: { count: number; hello: string; } = { count: 0, hello: 'world', }; export const basicModel = defineModel('basic', { initialState, reducers: { plus(state, step: number) { state.count += step; }, minus(state, step: number) { state.count -= step; }, moreParams(state, step: number, hello: string) { state.count += step; state.hello += ', ' + hello; }, set(state, count: number) { state.count = count; }, reset() { return this.initialState; }, _actionIsPrivate() {}, ____alsoPrivateAction() {}, }, methods: { async foo(hello: string, step: number) { await sleep(20); this.setState((state) => { state.count += step; state.hello = hello; }); return 'OK'; }, setWithoutFn(step: number) { this.setState({ count: step, hello: 'earth', }); }, setPartialState(step: number) { this.setState({ count: step, }); }, async bar() { return this.foo('', 100); }, async bos() { return this.plus(4); }, async hasError(msg: string = 'my-test') { throw new Error(msg); }, async pureAsync() { await sleep(300); return 'OK'; }, normalMethod() { return 'YES'; }, async _effectIsPrivate() {}, ____alsoPrivateEffect() {}, }, }); export const basicSkipRefreshModel = cloneModel( 'basicSkipRefresh', basicModel, { skipRefresh: true, }, ); ================================================ FILE: test/models/complex.model.ts ================================================ import { defineModel } from '../../src'; const initialState: { users: Record; ids: Array; } = { users: {}, ids: [], }; export const complexModel = defineModel('complex', { initialState, reducers: { addUser(state, id: number, name: string) { state.users[id] = name; !state.ids.includes(id) && state.ids.push(id); }, deleteUser(state, id: number) { delete state.users[id]; state.ids = state.ids.filter((item) => item !== id); }, updateUser(state, id: number, name: string) { state.users[id] = name; }, }, }); ================================================ FILE: test/models/computed.model.ts ================================================ import { defineModel } from '../../src'; const initialState: { firstName: string; lastName: string; statusList: [string, string]; translate: Record; } = { firstName: 'tick', lastName: 'tock', statusList: ['online', 'offline'], translate: { online: 'Online', offline: 'Offline', }, }; export const computedModel = defineModel('computed-model', { initialState, reducers: { changeFirstName(state, value: string) { state.firstName = value; }, changeLastName(state, value) { state.lastName = value; }, }, methods: { effectsGetFullName() { return this.fullName(); }, }, computed: { fullName() { return this.state.firstName + this.state.lastName; }, _privateFullname() { return this.state.firstName + this.state.lastName; }, testDependentOtherComputed() { const status = this.fullName() === 'ticktock' ? this.state.statusList[0] : this.state.statusList[1]; return `${this.fullName().trim()} [${status}]`; }, isOnline() { return this.fullName() === 'helloworld'; }, testArrayLength() { return this.state.statusList.length; }, testObjectKeys() { return Object.keys(this.state.translate); }, testFind() { return this.state.statusList.find((item) => item.startsWith('off')); }, testVisitArray() { return this.state.statusList; }, testJSON() { return JSON.stringify(this.state); }, testExtendObject() { this.state.statusList.push('k'); }, testModifyValue() { this.state.statusList[0] = 'BALA'; }, withParameter(age: number) { return this.state.firstName + '-age-' + age; }, withDefaultParameter(age: number = 20) { return this.state.firstName + '-age-' + age; }, withMultipleParameters(age: number = 20, address: string) { return this.state.firstName + '-age-' + age + '-addr-' + address; }, withMultipleAndDefaultParameters(age: number = 20, address?: string) { return this.state.firstName + '-age-' + age + '-addr-' + address; }, }, }); ================================================ FILE: test/models/persist.model.ts ================================================ import { cloneModel, defineModel } from '../../src'; const initialState: { counter: number; } = { counter: 0, }; export const persistModel = defineModel('persist', { initialState, reducers: { plus(state, step: number) { state.counter += step; }, minus(state, step: number) { state.counter -= step; }, }, persist: {}, }); export const hasVersionPersistModel = cloneModel('persist1', persistModel, { initialState: { counter: 56, }, persist: { version: 10, }, }); export const hasFilterPersistModel = cloneModel('persist2', persistModel, { persist: { dump(state) { return state.counter; }, load(counter) { return { ...this.initialState, counter: counter + 1 }; }, }, }); ================================================ FILE: test/persist.gate.test.tsx ================================================ import { FC } from 'react'; import { act, render, screen } from '@testing-library/react'; import { FocaProvider, store } from '../src'; import { PersistGateProps } from '../src/persist/persist-gate'; import { basicModel } from './models/basic.model'; import { slowEngine } from './helpers/slow-engine'; const Loading: FC = () =>
Yes
; const Root: FC = ({ loading, useFunction, }) => { return ( {useFunction ? ( (isReady: boolean) => ( <>
{String(isReady)}
) ) : (
)} ); }; beforeEach(() => { store.init({ persist: [ { version: 1, key: 'test1', models: [basicModel], engine: slowEngine, }, ], }); }); afterEach(() => { store.unmount(); }); test('PersistGate will inject to shadow dom', async () => { render(); expect(screen.queryByTestId('inner')).toBeNull(); await act(async () => { await store.onInitialized(); }); expect(screen.queryByTestId('inner')).not.toBeNull(); }); test('PersistGate allows function children', async () => { render(); expect(screen.queryByTestId('isReady')!.innerHTML).toBe('false'); await act(async () => { await store.onInitialized(); }); expect(screen.queryByTestId('isReady')!.innerHTML).toBe('true'); }); test('PersistGate allows loading children', async () => { render(} />); expect(screen.queryByTestId('inner')).toBeNull(); expect(screen.queryByTestId('gateLoading')!.innerHTML).toBe('Yes'); await act(async () => { await store.onInitialized(); }); expect(screen.queryByTestId('inner')).not.toBeNull(); expect(screen.queryByTestId('gateLoading')).toBeNull(); }); test('PersistGate will warning for both function children and loading children', async () => { const spy = vitest.spyOn(console, 'error').mockImplementation(() => {}); render(} />); expect(spy).toHaveBeenCalledTimes(1); await act(() => store.onInitialized()); expect(spy).toHaveBeenCalledTimes(2); spy.mockRestore(); }); ================================================ FILE: test/persist.test.ts ================================================ import sleep from 'sleep-promise'; import { defineModel, memoryStorage, Model, StorageEngine, store, } from '../src'; import { PersistItem, PersistMergeMode, PersistSchema, } from '../src/persist/persist-item'; import { stringifyState } from '../src/utils/serialize'; import { basicModel } from './models/basic.model'; import { hasFilterPersistModel, hasVersionPersistModel, persistModel, } from './models/persist.model'; import { slowEngine } from './helpers/slow-engine'; const stringifyTwice = (model: Model) => { return JSON.stringify(JSON.stringify(model.state)); }; const createDefaultInstance = () => { return new PersistItem({ version: 1, key: 'test-' + Math.random(), engine: memoryStorage, models: [persistModel], }); }; const storageDump = (opts: { key: string; model: Model; state?: object | string; persistVersion?: number; modelVersion?: number; engine?: StorageEngine; }) => { const { key, model, state = model.state, persistVersion = 1, modelVersion = 0, engine = memoryStorage, } = opts; return engine.setItem( key, JSON.stringify({ v: persistVersion, d: { [model.name]: { v: modelVersion, d: typeof state === 'string' ? state : stringifyState(state), }, }, }), ); }; beforeEach(() => { store.init(); }); afterEach(async () => { store.unmount(); memoryStorage.clear(); }); test('dump state', async () => { const persist = createDefaultInstance(); expect(memoryStorage.getItem(persist.key)).toBeNull(); await persist.init(); expect(memoryStorage.getItem(persist.key)).toBe(JSON.stringify(persist)); expect(memoryStorage.getItem(persist.key)).toContain( stringifyTwice(persistModel), ); persistModel.plus(15); expect(persistModel.state.counter).toBe(15); persist.update({ [persistModel.name]: persistModel.state, }); await sleep(1); const value = await memoryStorage.getItem(persist.key); expect(value).toBe(JSON.stringify(persist)); expect(value).toContain(stringifyTwice(persistModel)); }); test('load state', async () => { const persist = createDefaultInstance(); await storageDump({ key: persist.key, model: persistModel, state: { counter: 15, extra: undefined }, }); await persist.init(); expect(persist.collect()).toMatchObject({ [persistModel.name]: { counter: 15, extra: undefined, }, }); expect(persist.collect()[persistModel.name]).toHaveProperty( 'extra', undefined, ); }); test('load failed due to persist version changed', async () => { const persist = createDefaultInstance(); await storageDump({ key: persist.key, model: persistModel, persistVersion: 20, state: { counter: 100 }, }); await persist.init(); expect(persist.collect()).toStrictEqual({ [persistModel.name]: { counter: 0 }, }); }); test('load failed due to model version changed', async () => { const persist = createDefaultInstance(); await storageDump({ key: persist.key, model: persistModel, modelVersion: 17, state: { counter: 100 }, }); await persist.init(); expect(persist.collect()).toStrictEqual({ [persistModel.name]: { counter: 0 }, }); }); test('load failed due to invalid JSON literal', async () => { let persist = createDefaultInstance(); await storageDump({ key: persist.key, model: persistModel, state: stringifyState(persistModel.state) + '$$$$', }); await expect(persist.init()).rejects.toThrowError(); expect(persist.collect()).toStrictEqual({}); await Promise.resolve(); await expect(persist.init()).resolves.toBeUndefined(); expect(persist.collect()).toStrictEqual({ [persistModel.name]: { counter: 0 }, }); persist = createDefaultInstance(); await memoryStorage.setItem( persist.key, JSON.stringify({ v: 1, d: { [persistModel.name]: { t: Date.now(), v: 0, d: stringifyState(persistModel.state) + '$$$$', }, }, }) + '$$$$', ); await expect(persist.init()).rejects.toThrowError(); expect(persist.collect()).toStrictEqual({}); await Promise.resolve(); await expect(persist.init()).resolves.toBeUndefined(); expect(persist.collect()).toStrictEqual({ [persistModel.name]: { counter: 0 }, }); }); test('get rid of unregistered model', async () => { const persist = new PersistItem({ version: 1, key: 'test1', engine: memoryStorage, models: [basicModel], }); await memoryStorage.setItem( persist.key, JSON.stringify({ v: 1, d: { [persistModel.name]: { t: Date.now(), v: 0, d: stringifyState(persistModel.state), }, [basicModel.name]: { t: Date.now(), v: 0, d: stringifyState(basicModel.state), }, }, }), ); await persist.init(); expect(persist.collect()).toMatchObject({ [basicModel.name]: basicModel.state, }); expect(persist.collect()).not.toMatchObject({ [persistModel.name]: persistModel.state, }); const storageValue = await memoryStorage.getItem(persist.key); expect(storageValue).toContain(stringifyTwice(basicModel)); expect(storageValue).not.toContain(stringifyTwice(persistModel)); }); test('model can specific persist version', async () => { const persist = new PersistItem({ version: 1, key: 'test1', engine: memoryStorage, models: [persistModel, hasVersionPersistModel], }); await storageDump({ key: persist.key, model: hasVersionPersistModel, modelVersion: 10, }); await persist.init(); expect(persist.collect()).toMatchObject({ [hasVersionPersistModel.name]: hasVersionPersistModel.state, }); }); test('model can specific persist filter function', async () => { const persist = new PersistItem({ version: 1, key: 'test1', engine: memoryStorage, models: [persistModel, hasFilterPersistModel], }); await memoryStorage.setItem( persist.key, JSON.stringify({ v: 1, d: { [hasFilterPersistModel.name]: { t: Date.now(), v: 0, d: stringifyState(hasFilterPersistModel.state.counter), }, }, }), ); await persist.init(); expect(persist.collect()).toMatchObject({ [hasFilterPersistModel.name]: { counter: 1 }, }); hasFilterPersistModel.plus(100); hasFilterPersistModel.plus(5); persist.update({ [hasFilterPersistModel.name]: hasFilterPersistModel.state, }); expect(memoryStorage.getItem(persist.key)).toContain('"d":"105"'); }); test('no dump happen before load finished', async () => { await storageDump({ key: '@test1', model: persistModel, engine: slowEngine, }); store.unmount(); store.init({ persist: [ { version: 1, keyPrefix: '@', key: 'test1', engine: slowEngine, models: [persistModel], }, ], }); const persistManager = store['persister']!; const promise = persistManager.init(store, true); persistModel.plus(6); expect(persistModel.state.counter).toBe(6); await promise; expect(persistModel.state.counter).toBe(0); await expect(slowEngine.getItem('@test1')).resolves.toContain( stringifyTwice(persistModel), ); await sleep(100); expect(persistModel.state.counter).toBe(0); await expect(slowEngine.getItem('@test1')).resolves.toContain( stringifyTwice(persistModel), ); expect(persistManager.collect()).toMatchObject({ [persistModel.name]: persistModel.state, }); store.unmount(); }); test('default merge mode is `merge`', async () => { const persist = createDefaultInstance(); await storageDump({ key: persist.key, model: persistModel, state: { hello: 'world' }, }); await persist.init(); expect(persist.collect()).toStrictEqual({ [persistModel.name]: { hello: 'world', counter: 0, }, }); }); test('set merge mode to `replace`', async () => { const persist = new PersistItem({ version: 1, key: 'test-' + Math.random(), engine: memoryStorage, models: [persistModel], merge: 'replace', }); await storageDump({ key: persist.key, model: persistModel, state: { hello: 'world' }, }); await persist.init(); expect(persist.collect()).toStrictEqual({ [persistModel.name]: { hello: 'world', }, }); }); test('set merge mode to `deep-merge`', async () => { const model = defineModel('deep-merge-persist-model', { initialState: { hi: 'here', data: { name: 'test', age: 20 } }, }); const persist = new PersistItem({ version: 1, key: 'test-' + Math.random(), engine: memoryStorage, models: [model], merge: 'deep-merge', }); await storageDump({ key: persist.key, model: model, state: { hello: 'world', data: { name: 'g' } }, }); await persist.init(); expect(persist.collect()).toStrictEqual({ [model.name]: { hello: 'world', hi: 'here', data: { name: 'g', age: 20 }, }, }); await storageDump({ key: persist.key, model: model, state: { hello: 'world', data: 'x' }, }); await persist.init(); expect(persist.collect()).toStrictEqual({ [model.name]: { hello: 'world', hi: 'here', data: 'x', }, }); }); test('through dump and load function without storage data', async () => { const spy1 = vitest.fn(); const spy2 = vitest.fn(); const model = defineModel('persist-model-' + Math.random(), { initialState: {}, persist: { dump: spy1, load: spy2, }, }); store.unmount(); store.init({ persist: [ { engine: memoryStorage, key: 'test-initial-state-dump-and-load', version: 1, models: [model], }, ], }); expect(spy1).toBeCalledTimes(0); expect(spy2).toBeCalledTimes(0); await store.onInitialized(); expect(spy1).toBeCalledTimes(1); expect(spy2).toBeCalledTimes(1); spy1.mockRestore(); spy2.mockRestore(); }); test('context of load function contains initialState', async () => { const model = defineModel('persist-model-' + Math.random(), { initialState: { hello: 'world', count: 10 }, persist: { dump(state) { return state.hello; }, // @ts-expect-error load(hello) { return { hello: hello + 'x', test: this.initialState }; }, }, }); store.unmount(); store.init({ persist: [ { engine: memoryStorage, key: 'test-initialState-context', version: 1, models: [model], }, ], }); await store.onInitialized(); expect(model.state).toStrictEqual({ hello: 'worldx', count: 10, test: { hello: 'world', count: 10, }, }); }); describe('merge method', () => { const persistItem = new PersistItem({ key: '', version: '', engine: memoryStorage, models: [], }); describe.each([ 'replace', 'merge', 'deep-merge', ] satisfies PersistMergeMode[])('array type', (mode) => { test('object + array', () => { expect( persistItem.merge({ hello: 'world' }, [{ foo: 'bar' }, {}], mode), ).toStrictEqual([{ foo: 'bar' }, {}]); }); test('array + array', () => { expect( persistItem.merge([{ tick: 'tock' }], [{ foo: 'bar' }, {}], mode), ).toStrictEqual([{ tick: 'tock' }]); }); test('array + object', () => { expect( persistItem.merge([{ tick: 'tock' }], { hello: 'world' }, mode), ).toStrictEqual({ hello: 'world', }); }); }); test('replace', () => { expect( persistItem.merge({ hi: 'there' }, { hello: 'world' }, 'replace'), ).toStrictEqual({ hi: 'there' }); }); test('merge', () => { expect( persistItem.merge( { hello: 'world', hi: 'there', a: { c: '2' } }, { hi: 'here', a: { b: '1' } }, 'merge', ), ).toStrictEqual({ hi: 'there', hello: 'world', a: { c: '2' } }); }); test('deep-merge', () => { expect( persistItem.merge( { hello: 'world', hi: 'there', a: { c: '2' } }, { hi: 'here', a: { b: '1' } }, 'deep-merge', ), ).toStrictEqual({ hello: 'world', hi: 'there', a: { b: '1', c: '2' } }); expect( persistItem.merge( { hello: 'world', hi: 'there', a: { c: '2' } }, { hi: 'here', a: 'x' }, 'deep-merge', ), ).toStrictEqual({ hi: 'there', hello: 'world', a: { c: '2' } }); expect( persistItem.merge( { hello: 'world', hi: 'there', a: { c: '2' } }, { hi: 'here', a: ['x'] }, 'deep-merge', ), ).toStrictEqual({ hi: 'there', hello: 'world', a: { c: '2' } }); }); }); ================================================ FILE: test/provider.test.tsx ================================================ import { render, screen } from '@testing-library/react'; import { FocaProvider, store } from '../src'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('render normal tag', () => { render(
Hello World
, ); expect(screen.queryByTestId('root')!.innerHTML).toBe('Hello World'); }); test('render function tag', () => { render( {() =>
Hello World
}
, ); expect(screen.queryByTestId('root')!.innerHTML).toBe('Hello World'); }); ================================================ FILE: test/serialize.test.ts ================================================ import { parseState, stringifyState } from '../src/utils/serialize'; it('can clone basic data', () => { expect( parseState( stringifyState({ x: 1, y: 'y', z: true, }), ), ).toMatchObject({ x: 1, y: 'y', z: true, }); }); it('can clone complex data', () => { expect( parseState( stringifyState({ x: 1, y: { z: [1, 2, '3'], }, }), ), ).toMatchObject({ x: 1, y: { z: [1, 2, '3'], }, }); }); it('can clone null and undefined', () => { const data = { x: [ undefined, 1, { test: undefined, test1: 'hello', }, null, undefined, ], y: null, z: undefined, }; expect(stringifyState(data)).toMatchSnapshot(); expect(parseState(stringifyState(data))).toMatchSnapshot(); expect(parseState(stringifyState(data))).toMatchObject(data); }); ================================================ FILE: test/store.test.ts ================================================ import { compose, StoreEnhancer } from 'redux'; import sleep from 'sleep-promise'; import { from, map } from 'rxjs'; import { composeWithDevTools } from '@redux-devtools/extension'; import { defineModel, memoryStorage, store } from '../src'; import { PersistSchema } from '../src/persist/persist-item'; import { PersistManager } from '../src/persist/persist-manager'; import { basicModel, basicSkipRefreshModel } from './models/basic.model'; import { complexModel } from './models/complex.model'; import { hasFilterPersistModel, hasVersionPersistModel, persistModel, } from './models/persist.model'; afterEach(() => { store.unmount(); memoryStorage.clear(); localStorage.clear(); sessionStorage.clear(); }); const initializeStoreWithMultiplePersist = () => { return store.init({ persist: [ { key: 'test1', keyPrefix: 'Test:', version: 1, engine: memoryStorage, models: [basicModel, complexModel], }, { key: 'test1', keyPrefix: 'Test:', version: 1, engine: localStorage, models: [persistModel], }, { key: 'test2', keyPrefix: 'Test:', version: 1, engine: memoryStorage, models: [hasVersionPersistModel, hasFilterPersistModel], }, ], }); }; test('Store will throw error before initialize', () => { expect(() => store.getState()).toThrowError(); }); test('Method replaceReducer is deprecated', () => { expect(() => store.replaceReducer(() => { return {} as any; }), ).toThrowError(); }); test('Store can initialize many times except production env', async () => { store.init(); store.init(); expect(() => store.init()).not.toThrowError(); const oldEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; expect(() => store.init()).toThrowError(); process.env.NODE_ENV = oldEnv; await store.onInitialized(); }); describe('persist', () => { test('Delay to dump data when state changed', async () => { initializeStoreWithMultiplePersist(); await store.onInitialized(); // @ts-expect-error const getTimer = () => store['persister']!.timer; let prevTimer: ReturnType; expect(getTimer()).toBeUndefined(); basicModel.plus(1); expect(getTimer()).not.toBeUndefined(); prevTimer = getTimer(); basicModel.plus(20); expect(getTimer()).toBe(prevTimer); basicModel.plus(1); expect(getTimer()).toBe(prevTimer); expect(store['persister']?.collect()[basicModel.name]).not.toBe( basicModel.state, ); await sleep(50); expect(store['persister']?.collect()[basicModel.name]).toBe( basicModel.state, ); expect(getTimer()).toBeUndefined(); basicModel.plus(1); expect(getTimer()).not.toBeUndefined(); expect(getTimer()).not.toBe(prevTimer); expect(store['persister']?.collect()[basicModel.name]).not.toBe( basicModel.state, ); await sleep(50); expect(store['persister']?.collect()[basicModel.name]).toBe( basicModel.state, ); }); test('Store can define persist with multiple engines', async () => { initializeStoreWithMultiplePersist(); await store.onInitialized(); expect(JSON.stringify(store['persister']?.collect())).toMatchInlineSnapshot( '"{"basic":{"count":0,"hello":"world"},"complex":{"users":{},"ids":[]},"persist":{"counter":0},"persist1":{"counter":56},"persist2":{"counter":1}}"', ); basicModel.plus(1); persistModel.plus(103); await sleep(50); expect(store['persister']?.collect()).toMatchInlineSnapshot(` { "basic": { "count": 1, "hello": "world", }, "complex": { "ids": [], "users": {}, }, "persist": { "counter": 103, }, "persist1": { "counter": 56, }, "persist2": { "counter": 1, }, } `); }); test('Store can load persist state', async () => { await memoryStorage.setItem( 'Test:test1', JSON.stringify({ v: 1, d: { [basicModel.name]: { v: 0, t: Date.now(), d: JSON.stringify({ count: 123, hello: 'earth', }), }, }, }), ); initializeStoreWithMultiplePersist(); await store.onInitialized(); expect(basicModel.state).toMatchObject({ count: 123, hello: 'earth', }); }); }); test('refresh the total state', () => { store.init(); basicModel.plus(1); basicSkipRefreshModel.plus(1); expect(basicModel.state.count).toEqual(1); expect(basicSkipRefreshModel.state.count).toEqual(1); store.refresh(); expect(basicModel.state.count).toEqual(0); expect(basicSkipRefreshModel.state.count).toEqual(1); basicModel.plus(1); basicSkipRefreshModel.plus(1); expect(basicModel.state.count).toEqual(1); expect(basicSkipRefreshModel.state.count).toEqual(2); store.refresh(true); expect(basicModel.state.count).toEqual(0); expect(basicSkipRefreshModel.state.count).toEqual(0); }); test('duplicate init() will keep state', () => { store.init(); expect(basicModel.state.count).toEqual(0); basicModel.plus(1); expect(basicModel.state.count).toEqual(1); store.init(); expect(basicModel.state.count).toEqual(1); }); test('duplicate init() will replace persister', async () => { store.init(); await store.onInitialized(); expect(store['persister']).toBeNull(); store.init({ persist: [ { key: 'test', version: 2, engine: memoryStorage, models: [], }, ], }); expect(store['persister']).toBeInstanceOf(PersistManager); await store.onInitialized(); basicModel.plus(1); await sleep(100); expect(store['persister']!.collect()).toStrictEqual({}); store.init({ persist: [ { key: 'test', version: 2, engine: memoryStorage, models: [basicModel], }, ], }); await store.onInitialized(); basicModel.plus(1); await sleep(100); expect(store['persister']!.collect()).toStrictEqual({ [basicModel.name]: { count: 2, hello: 'world', }, }); store.init({ persist: [ { key: 'test', version: 2, engine: memoryStorage, models: [], }, ], }); await store.onInitialized(); expect(store['persister']!.collect()).toStrictEqual({}); }); test('Get custom compose', () => { const get: (typeof store)['getCompose'] = store['getCompose'].bind(store); expect(get(void 0)).toBe(compose); expect(get(compose)).toBe(compose); const customCompose = (): StoreEnhancer => '' as any; expect(get(customCompose)).toBe(customCompose); const devtoolsComposer = composeWithDevTools({ name: 'x', }); expect(get(devtoolsComposer)).toBe(devtoolsComposer); expect(get(composeWithDevTools)).toBe(composeWithDevTools); expect(get('redux-devtools')).toBe(compose); // @ts-expect-error globalThis['__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'] = customCompose; expect(get('redux-devtools')).toBe(customCompose); const prevEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; expect(get('redux-devtools')).toBe(compose); process.env.NODE_ENV = prevEnv; }); test('rxjs can observe store', () => { store.init(); const observable = from(store); const results: any[] = []; const model = defineModel('rxjs', { initialState: { foo: 0, bar: 0, }, reducers: { foo(state) { state.foo += 1; }, bar(state) { state.bar += 1; }, }, }); const sub = observable .pipe( map((state) => { return { fromRx: true, ...state[model.name] }; }), ) .subscribe((state) => { results.push(state); }); model.foo(); model.foo(); sub.unsubscribe(); model.bar(); expect(results).toEqual( expect.arrayContaining([ { foo: 0, bar: 0, fromRx: true }, { foo: 1, bar: 0, fromRx: true }, { foo: 2, bar: 0, fromRx: true }, ]), ); }); test('async callback for onInitialized', () => { let msg = 'a'; store.onInitialized(() => { msg += 'b'; }); msg += 'c'; store.init(); expect(msg).toBe('acb'); }); test('sync callback for onInitialized', () => { store.init(); let msg = 'a'; store.onInitialized(() => { msg += 'b'; }); msg += 'c'; expect(msg).toBe('abc'); }); ================================================ FILE: test/typescript/computed.check.ts ================================================ import { expectType } from 'ts-expect'; import { cloneModel, defineModel, useComputed } from '../../src'; import { ComputedFlag } from '../../src/model/types'; const model = defineModel('test', { initialState: { firstName: 't', lastName: 'r', }, computed: { fullName() { return this.state.firstName + '.' + this.state.lastName; }, nickName() { return [this.fullName() + '-nick']; }, _dirname() { return 'whatever'; }, withAge(age: number = 20) { return this.state.firstName + '-age-' + age; }, withRequiredParameter(address: string) { return this.state.firstName + this.withAge(15) + '-address-' + address; }, withMultipleParameter(address: string, age: number, extra?: boolean) { return address + age + extra; }, }, }); expectType<(() => string[]) & ComputedFlag>(model.nickName); // @ts-expect-error model.fullName = 'modify'; // @ts-expect-error model.nickName = 'modify'; // @ts-expect-error model._dirname; // @ts-expect-error model.firstName; // @ts-expect-error useComputed(model); expectType(useComputed(model.fullName)); expectType(useComputed(model.nickName)); // @ts-expect-error useComputed(model.fullName, 20); useComputed(model.withAge); useComputed(model.withAge, 20); // @ts-expect-error useComputed(model.withAge, '20'); // @ts-expect-error useComputed(() => {}); // @ts-expect-error useComputed(model.withRequiredParameter); expectType(useComputed(model.withRequiredParameter, 'addr')); useComputed(model.withRequiredParameter, 'addr').endsWith('ss'); // @ts-expect-error useComputed(model.withMultipleParameter, ''); // @ts-expect-error useComputed(model.withMultipleParameter, 0); useComputed(model.withMultipleParameter, '', 0); useComputed(model.withMultipleParameter, '', 0, false); { const model1 = cloneModel('clone-model', model); model1.fullName(); model1.withMultipleParameter('', 20); } ================================================ FILE: test/typescript/define-model.check.ts ================================================ import { expectType } from 'ts-expect'; import { UnknownAction, defineModel } from '../../src'; // @ts-expect-error defineModel('no-initial-state', {}); defineModel('null-state', { // @ts-expect-error initialState: null, }); defineModel('string-state', { // @ts-expect-error initialState: '', }); defineModel('array-state-reducers', { initialState: [] as { test: number }[], reducers: { returnNormal(_) { return []; }, returnInitialize(_) { return this.initialState; }, }, methods: { returnNormal() { this.setState([]); this.setState([{ test: 3 }]); // @ts-expect-error this.setState(); // @ts-expect-error this.setState({}); // @ts-expect-error this.setState(2); // @ts-expect-error this.setState(); // @ts-expect-error this.setState([undefined]); // @ts-expect-error this.setState([{ test: 3 }, {}]); // @ts-expect-error this.setState(undefined); this.setState((_) => { return []; }); // @ts-expect-error this.setState((_) => { return [] as object[]; }); this.setState((state) => { state.push({ test: 3 }); // @ts-expect-error state.push(4); }); }, returnInitialize() { this.setState(() => { return this.initialState; }); this.setState(this.initialState); }, }, }); defineModel('object-state-reducers', { initialState: {} as { test: { test1: number }; test2: string; test3?: string; }, reducers: { returnNormal(_state) { return { test: { test1: 3 }, test2: 'bar' }; }, returnInitialize() { return this.initialState; }, }, methods: { returnNormal() { this.setState((_) => { return {}; }); this.setState((_) => { return { test: { test1: 2 } }; }); this.setState((_) => { return { test: { test1: 2 }, test2: 'bar' }; }); // FIXME: this.setState((_) => { return { test: { test1: 2 }, test2: 'bar', test3: '', other: '' }; }); // @ts-expect-error this.setState((_) => { return { test: { test1: 2 }, foo1: 'baz' }; }); // @ts-expect-error this.setState((_) => { return { xxx: 2 }; }); // @ts-expect-error this.setState((_) => { return { test: {} }; }); // @ts-expect-error this.setState((_) => { return { test: { test1: 2 }, t: 3 }; }); // FIXME: this.setState((_) => { return { test: { test1: 2, t: 3 } }; }); // @ts-expect-error this.setState((_) => { return { test: { test2: 2 } }; }); this.setState((state) => { state.test.test1 = 4; }); }, returnPartial() { this.setState({}); this.setState({ test: { test1: 3 } }); // @ts-expect-error this.setState({ test: { test1: 3, more: 4 } }); // @ts-expect-error this.setState({ test: { test1: 3, more: undefined } }); this.setState({ test3: undefined }); this.setState({ test2: 'x', test3: undefined }); // @ts-expect-error this.setState({ test: { test1: undefined } }); // @ts-expect-error this.setState({ test2: undefined }); // @ts-expect-error this.setState({ test: 'x' }); // @ts-expect-error this.setState({ test: { test1: 'x' } }); // @ts-expect-error this.setState({ test: { test1: 3 }, ok: 'test', more: 'test1' }); // @ts-expect-error this.setState(); // @ts-expect-error this.setState({ test: {} }); // @ts-expect-error this.setState([]); // @ts-expect-error this.setState(2); // @ts-expect-error this.setState({ xxx: 2 }); this.setState({ test2: 'x' }); // @ts-expect-error this.setState({ test2: 'x', more: 'y' }); }, returnInitialize() { this.setState(() => { return this.initialState; }); this.setState(this.initialState); }, }, }); defineModel('wrong-reducer-state-1', { initialState: {} as { test: { test1: number } }, // @ts-expect-error reducers: { returnUnexpected(_) { return { test: {} }; }, right() { return { test: { test1: 3 } }; }, }, }); defineModel('wrong-reducer-state-2', { initialState: {} as { test: { test1: number } }, // @ts-expect-error reducers: { returnUnexpected(_) { return {}; }, right() { return { test: { test1: 3 } }; }, }, }); defineModel('wrong-reducer-state-3', { initialState: {} as { test: { test1: number } }, // @ts-expect-error reducers: { returnUnexpected(_) { return []; }, right() { return { test: { test1: 3 } }; }, }, }); defineModel('private-and-context', { initialState: {}, reducers: { _action1() {}, _action2() {}, action3() { // @ts-expect-error this.method3; // @ts-expect-error this.xxx; // @ts-expect-error this._fullname; }, }, methods: { _method1() { this._action1(); }, async _method2() {}, method3() { this._method1(); this.xxx().endsWith('/'); this._fullname().endsWith('/'); }, }, computed: { xxx() { // @ts-expect-error this._method1; // @ts-expect-error this._action1; // @ts-expect-error this.method3; return ''; }, yyy() { this.xxx(); return this._fullname(); }, _fullname() { return ''; }, }, events: { onInit() { this._action1(); this._method1(); this.action3(); this.method3(); this.state; // @ts-expect-error this.initialState; // @ts-expect-error this.onInit; expectType(this._fullname()); expectType<() => Promise>(this._method2); expectType<() => UnknownAction>(this._action1); expectType<() => UnknownAction>(this._action2); }, }, }); ================================================ FILE: test/typescript/get-loading.check.ts ================================================ import { expectType } from 'ts-expect'; import { getLoading } from '../../src'; import { basicModel } from '../models/basic.model'; expectType(getLoading(basicModel.foo)); expectType(getLoading(basicModel.foo.room).find('xx')); expectType(getLoading(basicModel.foo.room, 'xx')); // @ts-expect-error getLoading(basicModel.foo.room, basicModel.foo); // @ts-expect-error getLoading(basicModel.foo.room, true); // @ts-expect-error getLoading(basicModel.foo.room, false); // @ts-expect-error getLoading(basicModel.normalMethod.room); ================================================ FILE: test/typescript/persist.check.ts ================================================ import { TypeEqual, expectType } from 'ts-expect'; import { cloneModel, defineModel } from '../../src'; import { GetInitialState } from '../../src/model/types'; const state: { hello: string } = { hello: 'world' }; defineModel('model', { initialState: state, // @ts-expect-error persist: { dump() { return ''; }, }, }); defineModel('model', { initialState: state, // @ts-expect-error persist: { load() { return {} as typeof state; }, }, }); defineModel('model', { initialState: state, persist: { dump() { return ''; }, load() { return {} as typeof state; }, }, }); defineModel('model', { initialState: state, persist: {}, }); defineModel('model', { initialState: state, persist: { version: 1, }, }); defineModel('model', { initialState: state, persist: { version: '1.0.0', }, }); const model = defineModel('model', { initialState: state, persist: { dump(state) { return state.hello; }, load(s) { expectType>(true); expectType, typeof this>>(true); return { hello: s }; }, }, }); cloneModel('model-1', model, { persist: {}, }); cloneModel('model-1', model, { persist: { version: '', }, }); cloneModel('model-1', model, { persist: { dump(state) { return state.hello; }, load(s) { expectType>(true); expectType, typeof this>>(true); return { hello: s }; }, }, }); cloneModel('model-1', model, { persist: { dump() { return 0; }, load(s) { expectType>(true); return { hello: String(s) }; }, }, }); cloneModel('model-1', model, { // @ts-expect-error persist: { dump() { return 0; }, }, }); cloneModel('model-1', model, { // @ts-expect-error persist: { load(dumpData) { return dumpData as typeof state; }, }, }); ================================================ FILE: test/typescript/use-isolate.check.ts ================================================ import { TypeEqual, expectType } from 'ts-expect'; import { defineModel, useIsolate, useLoading, useModel } from '../../src'; import { basicModel } from '../models/basic.model'; const isolatedModel = useIsolate(basicModel); useModel(isolatedModel); useModel(isolatedModel, (state) => state.count); useLoading(isolatedModel.pureAsync); useLoading(isolatedModel.pureAsync.room); useModel(basicModel, isolatedModel); { const model = useModel(isolatedModel, basicModel); expectType< TypeEqual< { basic: { count: number; hello: string }; } & { [x: string]: { count: number; hello: string; }; }, typeof model > >(true); } useModel(isolatedModel, basicModel, () => {}); { const model = useIsolate(isolatedModel); expectType>(true); } // @ts-expect-error cloneModel(isolatedModel); defineModel('', { initialState: {}, events: { onDestroy() { expectType(this); }, }, }); ================================================ FILE: test/typescript/use-loading.check.ts ================================================ import { expectType } from 'ts-expect'; import { useLoading } from '../../src'; import { basicModel } from '../models/basic.model'; expectType(useLoading(basicModel.bar)); expectType(useLoading(basicModel.foo, basicModel.bar)); // @ts-expect-error useLoading(basicModel.minus); // @ts-expect-error useLoading(basicModel); // @ts-expect-error useLoading({}); expectType(useLoading(basicModel.foo.room).find('xx')); expectType(useLoading(basicModel.foo.room, 'xx')); // @ts-expect-error useLoading(basicModel.foo.room, basicModel.foo); // @ts-expect-error useLoading(basicModel.foo.room, true); // @ts-expect-error useLoading(basicModel.foo.room, false); // @ts-expect-error useLoading(basicModel.normalMethod.room); // @ts-expect-error useLoading(basicModel.normalMethod.assign); // @ts-expect-error useLoading(basicModel.minus.room); ================================================ FILE: test/typescript/use-model.check.ts ================================================ import { expectType } from 'ts-expect'; import { useModel } from '../../src'; import { basicModel } from '../models/basic.model'; import { complexModel } from '../models/complex.model'; const basic = useModel(basicModel); expectType(basic.count); expectType(basic.hello); // @ts-expect-error basic.notExist; const count = useModel(basicModel, (state) => state.count); expectType(count); const obj = useModel(basicModel, complexModel); expectType(obj.basic.count); expectType(obj.complex.ids); // @ts-expect-error obj.notExists; const hello = useModel( basicModel, complexModel, (basic, complex) => basic.hello + complex.ids.length, ); expectType(hello); ================================================ FILE: test/use-computed.test.ts ================================================ import { act } from '@testing-library/react'; import { renderHook } from './helpers/render-hook'; import { store, useComputed } from '../src'; import { computedModel } from './models/computed.model'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('get state from computed value', () => { const { result } = renderHook(() => useComputed(computedModel.fullName)); expect(result.current).toEqual('ticktock'); act(() => { computedModel.changeFirstName('hello'); }); expect(result.current).toEqual('hellotock'); act(() => { computedModel.changeFirstName('tick'); }); expect(result.current).toEqual('ticktock'); act(() => { computedModel.changeLastName('world'); }); expect(result.current).toEqual('tickworld'); }); test('with parameters', () => { const { result } = renderHook(() => useComputed(computedModel.withMultipleParameters, 43, 'address'), ); expect(result.current).toEqual('tick-age-43-addr-address'); act(() => { computedModel.changeFirstName('musk'); }); expect(result.current).toEqual('musk-age-43-addr-address'); }); ================================================ FILE: test/use-isolate.test.tsx ================================================ import { act, cleanup, render } from '@testing-library/react'; import { useEffect, useState } from 'react'; import sleep from 'sleep-promise'; import { defineModel, FocaProvider, store, useLoading, useIsolate, Model, useModel, } from '../src'; import { loadingStore } from '../src/store/loading-store'; import { renderHook } from './helpers/render-hook'; import { basicModel } from './models/basic.model'; (['development', 'production'] as const).forEach((env) => { describe(`[${env} mode]`, () => { beforeEach(() => { store.init(); process.env.NODE_ENV = env; }); afterEach(async () => { process.env.NODE_ENV = 'testing'; cleanup(); await sleep(10); store.unmount(); }); test('can register to modelStore and remove from modelStore', async () => { const { result, unmount } = renderHook(() => useIsolate(basicModel)); expect(result.current).not.toBe(basicModel); expect(store.getState()).toHaveProperty( result.current.name, result.current.state, ); unmount(); await sleep(1); expect(store.getState()).not.toHaveProperty(result.current.name); }); test('can register to loadingStore and remove from loadingStore', async () => { const { result, unmount } = renderHook(() => { const model = useIsolate(basicModel); useLoading(basicModel.pureAsync); useLoading(model.pureAsync); return model; }); const key1 = `${result.current.name}.pureAsync`; const key2 = `${basicModel.name}.pureAsync`; expect(loadingStore.getState()).not.toHaveProperty(key1); await act(async () => { const promise1 = result.current.pureAsync(); const promise2 = basicModel.pureAsync(); expect(loadingStore.getState()).toHaveProperty(key1); expect(loadingStore.getState()).toHaveProperty(key2); await promise1; await promise2; }); expect(loadingStore.getState()).toHaveProperty(key1); expect(loadingStore.getState()).toHaveProperty(key2); unmount(); await sleep(1); expect(loadingStore.getState()).not.toHaveProperty(key1); expect(loadingStore.getState()).toHaveProperty(key2); }); test('call onDestroy event when local model is destroyed', async () => { const spy = vitest.fn(); const globalModel = defineModel('isolate-demo-1', { initialState: {}, events: { onDestroy: spy, }, }); const { unmount } = renderHook(() => useIsolate(globalModel)); expect(spy).toBeCalledTimes(0); unmount(); await sleep(1); expect(spy).toBeCalledTimes(1); basicModel.plus(1); expect(spy).toBeCalledTimes(1); }); test('recreate isolated model when global model changed', async () => { const globalModel = defineModel('isolate-demo-2', { initialState: {}, }); const { result } = renderHook(() => { const [state, setState] = useState(basicModel); const model = useIsolate(state); useEffect(() => { setTimeout(() => { setState(globalModel); }, 20); }, []); return model; }); const name1 = result.current.name; expect(name1).toMatch(basicModel.name); expect(store.getState()).toHaveProperty(name1); await act(async () => { await sleep(30); }); await sleep(10); expect(result.current.name).not.toBe('isolate-demo-2'); expect(result.current.name).toMatch('isolate-demo-2'); expect(store.getState()).not.toHaveProperty(name1); }); test.runIf(env === 'development')( 'Can get component name in dev mode', () => { let model!: Model; function MyApp() { model = useIsolate(basicModel); return null; } render( , ); expect(model.name).toMatch('MyApp#'); }, ); test('can use with global model', async () => { let model: typeof basicModel; const { result } = renderHook(() => { // @ts-expect-error model = useIsolate(basicModel); return useModel(model, basicModel, (local, basic) => { return `local: ${local.count}, basic: ${basic.count}`; }); }); expect(result.current).toBe('local: 0, basic: 0'); act(() => { basicModel.plus(12); }); expect(result.current).toBe('local: 0, basic: 12'); act(() => { model.plus(7); }); expect(result.current).toBe('local: 7, basic: 12'); }); test('can isolate from isolate model', async () => { let model1: typeof basicModel; let model2: typeof basicModel; const { result } = renderHook(() => { // @ts-expect-error model1 = useIsolate(basicModel); // @ts-expect-error model2 = useIsolate(model1); return useModel(model1, model2, (local1, local2) => { return `local1: ${local1.count}, local2: ${local2.count}`; }); }); expect(result.current).toBe('local1: 0, local2: 0'); act(() => { model1.plus(12); }); expect(result.current).toBe('local1: 12, local2: 0'); act(() => { model2.plus(7); }); expect(result.current).toBe('local1: 12, local2: 7'); }); }); }); ================================================ FILE: test/use-loading.test.ts ================================================ import { act } from '@testing-library/react'; import { renderHook } from './helpers/render-hook'; import { store, useLoading } from '../src'; import { basicModel } from './models/basic.model'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('Trace loading', async () => { const { result } = renderHook(() => useLoading(basicModel.pureAsync)); expect(result.current).toBeFalsy(); let promise!: Promise; act(() => { promise = basicModel.pureAsync(); }); expect(result.current).toBeTruthy(); await act(async () => { await promise; }); expect(result.current).toBeFalsy(); }); test('Compose the loadings', async () => { const { result } = renderHook(() => useLoading(basicModel.pureAsync, basicModel.foo, basicModel.bar), ); expect(result.current).toBeFalsy(); let promise1!: Promise; act(() => { promise1 = basicModel.pureAsync(); }); expect(result.current).toBeTruthy(); let promise2!: Promise; await act(async () => { await promise1; promise2 = basicModel.foo('', 2); }); expect(result.current).toBeTruthy(); await act(async () => { await promise2; }); expect(result.current).toBeFalsy(); }); test('Trace loadings', async () => { const { result } = renderHook(() => useLoading(basicModel.pureAsync.room, 'x'), ); expect(result.current).toBeFalsy(); let promise!: Promise; act(() => { promise = basicModel.pureAsync.room('x').execute(); }); expect(result.current).toBeTruthy(); await act(async () => { await promise; }); expect(result.current).toBeFalsy(); }); test('Pick loading from loadings', async () => { const { result } = renderHook(() => useLoading(basicModel.pureAsync.room)); expect(result.current.find('m')).toBeFalsy(); expect(result.current.find('n')).toBeFalsy(); let promise!: Promise; act(() => { promise = basicModel.pureAsync.room('m').execute(); }); expect(result.current.find('m')).toBeTruthy(); expect(result.current.find('n')).toBeFalsy(); await act(async () => { await promise; }); expect(result.current.find('m')).toBeFalsy(); expect(result.current.find('n')).toBeFalsy(); }); ================================================ FILE: test/use-model.test.ts ================================================ import { act } from '@testing-library/react'; import { renderHook } from './helpers/render-hook'; import { store, useModel } from '../src'; import { basicModel, basicSkipRefreshModel } from './models/basic.model'; import { complexModel } from './models/complex.model'; beforeEach(() => { store.init(); }); afterEach(() => { store.unmount(); }); test('get state from one model', () => { const { result } = renderHook(() => useModel(basicModel)); expect(result.current.count).toEqual(0); act(() => { basicModel.plus(1); }); expect(result.current.count).toEqual(1); }); test('get state from multiple models', () => { const { result } = renderHook(() => useModel(basicModel, complexModel)); expect(result.current).toMatchObject({ basic: {}, complex: {}, }); act(() => { basicModel.plus(1); complexModel.addUser(10, 'Lucifer'); }); expect(result.current.basic.count).toEqual(1); expect(result.current.complex.users[10]).toEqual('Lucifer'); }); test('get state with selector', () => { const { result } = renderHook(() => useModel(basicModel, (state) => state.count * 10), ); expect(result.current).toEqual(0); act(() => { basicModel.plus(2); }); expect(result.current).toEqual(20); act(() => { basicModel.plus(3); }); expect(result.current).toEqual(50); }); test('get multiple state with selector', () => { const { result } = renderHook(() => useModel(basicModel, complexModel, (a, b) => a.count + b.ids.length), ); expect(result.current).toEqual(0); act(() => { basicModel.plus(1); complexModel.addUser(5, 'li'); complexModel.addUser(6, 'lu'); }); expect(result.current).toEqual(3); }); test('specific compare algorithm', async () => { { const hook = renderHook(() => useModel( basicModel, complexModel, (a, b) => ({ a, b, }), 'strictEqual', ), ); const prevValue = hook.result.current; act(() => { hook.rerender(); }); expect(hook.result.current !== prevValue).toBeTruthy(); } { const hook = renderHook(() => useModel( basicModel, complexModel, (a, b) => ({ a, b, }), 'shallowEqual', ), ); const prevValue = hook.result.current; act(() => { hook.rerender(); }); expect(hook.result.current === prevValue).toBeTruthy(); } { const hook = renderHook(() => useModel( basicModel, complexModel, (a, b) => ({ a, b, }), 'deepEqual', ), ); const prevValue = hook.result.current; act(() => { hook.rerender(); }); expect(hook.result.current === prevValue).toBeTruthy(); } }); test('Memoize the selector result', () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); const { result: result1 } = renderHook(() => { return useModel(basicModel, (state) => { fn1(); return state.count; }); }); const { result: result2 } = renderHook(() => { return useModel(basicModel, basicSkipRefreshModel, (state1, state2) => { fn2(); return state1.count + state2.count; }); }); expect(fn1).toBeCalledTimes(1); expect(fn2).toBeCalledTimes(1); act(() => { basicModel.plus(6); }); expect(result1.current).toBe(6); expect(result2.current).toBe(6); expect(fn1).toBeCalledTimes(3); expect(fn2).toBeCalledTimes(3); act(() => { // Sure not basicModel, we need trigger subscriptions complexModel.addUser(1, ''); }); expect(result1.current).toBe(6); expect(result2.current).toBe(6); expect(fn1).toBeCalledTimes(3); expect(fn2).toBeCalledTimes(3); act(() => { // Sure not basicModel, we need trigger subscriptions complexModel.addUser(2, 'L'); }); expect(result1.current).toBe(6); expect(result2.current).toBe(6); expect(fn1).toBeCalledTimes(3); expect(fn2).toBeCalledTimes(3); act(() => { basicModel.plus(1); }); expect(result1.current).toBe(7); expect(result2.current).toBe(7); expect(fn1).toBeCalledTimes(5); expect(fn2).toBeCalledTimes(5); act(() => { basicSkipRefreshModel.plus(1); }); expect(result1.current).toBe(7); expect(result2.current).toBe(8); expect(fn1).toBeCalledTimes(5); expect(fn2).toBeCalledTimes(7); fn1.mockRestore(); fn2.mockRestore(); }); test('Hooks keep working after hot reload', async () => { const { result } = renderHook(() => useModel(basicModel)); expect(result.current.count).toEqual(0); store.init(); act(() => { basicModel.plus(1); }); expect(result.current.count).toEqual(1); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping "target": "ES2015", "module": "ES2015", "lib": ["ESNext", "DOM"], "allowJs": false, "declaration": true, "outDir": "./build", "rootDir": ".", "importHelpers": false, "jsx": "react-jsx", "noEmit": true, "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "moduleResolution": "node", "esModuleInterop": true, "resolveJsonModule": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noImplicitOverride": true, "useUnknownInCatchVariables": true } } ================================================ FILE: tsup.config.ts ================================================ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], splitting: true, sourcemap: true, clean: true, format: ['cjs', 'esm'], platform: 'node', tsconfig: './tsconfig.json', target: 'es2020', legacyOutput: true, shims: false, dts: true, onSuccess: 'echo {\\"type\\": \\"module\\"} > dist/esm/package.json', }); ================================================ FILE: vitest.config.ts ================================================ /// import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { coverage: { provider: 'istanbul', enabled: true, include: ['src/**'], thresholds: { lines: 99, functions: 99, branches: 99, statements: 99, }, reporter: ['html', 'lcovonly', 'text-summary'], }, environment: 'jsdom', globals: true, snapshotFormat: { escapeString: false, printBasicPrototype: false, }, }, });