From 96c4f0c337f45d026fa4b75ccf9e239b72812512 Mon Sep 17 00:00:00 2001 From: chaoling <11017647@qq.com> Date: Thu, 23 May 2024 10:03:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20inula-adapter/pinia=20=E5=92=8C?= =?UTF-8?q?=20inula-adapter/vuex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{pinia-adapter => }/.prettierrc.js | 0 packages/inula-adapter/README.md | 7 + .../{vue-adapter => }/babel.config.js | 0 packages/inula-adapter/docs/README_pinia.md | 101 ++++ packages/inula-adapter/docs/README_vue.md | 10 + packages/inula-adapter/docs/README_vuex.md | 188 +++++++ packages/inula-adapter/npm/pinia/package.json | 5 + packages/inula-adapter/npm/vuex/package.json | 5 + .../{vue-adapter => }/package.json | 13 +- .../inula-adapter/pinia-adapter/README.md | 18 - .../inula-adapter/pinia-adapter/package.json | 21 - .../inula-adapter/pinia-adapter/src/pinia.ts | 143 ------ .../inula-adapter/pinia-adapter/src/ref.ts | 68 --- .../pinia-adapter/tests/getters.test.ts | 153 ------ .../pinia-adapter/tests/state.test.ts | 55 -- .../pinia-adapter/tests/store.test.ts | 67 --- .../inula-adapter/pinia-adapter/tsconfig.json | 13 - .../{vue-adapter => }/scripts/build-types.js | 10 +- .../scripts/rollup.config.js | 54 +- .../{pinia-adapter/src => src/pinia}/index.ts | 2 +- packages/inula-adapter/src/pinia/pinia.ts | 208 ++++++++ packages/inula-adapter/src/pinia/types.ts | 104 ++++ .../{vue-adapter/src => src/vue}/index.ts | 0 .../{vue-adapter/src => src/vue}/lifecycle.ts | 15 +- .../vitest.config.ts => src/vue/types.ts} | 8 +- packages/inula-adapter/src/vuex/index.ts | 16 + packages/inula-adapter/src/vuex/types.ts | 104 ++++ packages/inula-adapter/src/vuex/vuex.ts | 315 ++++++++++++ .../pinia/pinia.actions.test.ts} | 123 ++--- .../tests/pinia/pinia.getters.test.ts | 97 ++++ .../tests/pinia/pinia.state.test.ts | 138 +++++ .../tests/pinia/pinia.store.test.ts | 120 +++++ .../tests/pinia/pinia.storeSetup.test.ts | 152 ++++++ .../tests/pinia/pinia.storeToRefs.test.ts | 141 +++++ .../tests => tests/vue}/liftcycle.test.tsx | 2 +- .../tests/vuex/vuex.modules.test.ts | 480 ++++++++++++++++++ .../tests/vuex/vuex.store.test.ts | 335 ++++++++++++ .../{vue-adapter => }/tsconfig.json | 0 packages/inula-adapter/tsconfig.pinia.json | 10 + .../tsconfig.build.json => tsconfig.vue.json} | 4 +- packages/inula-adapter/tsconfig.vuex.json | 10 + .../{vue-adapter => }/vitest.config.ts | 4 +- packages/inula-adapter/vue-adapter/README.md | 2 - .../HorizonXTest/ReactiveTest/toRef.test.tsx | 138 +++++ .../StoreFunctionality/dollarAccess.test.tsx | 4 +- packages/inula/package.json | 2 +- packages/inula/src/index.ts | 10 +- packages/inula/src/inulax/CommonUtils.ts | 4 + packages/inula/src/inulax/Constants.ts | 2 + .../inula/src/inulax/docs/reactive_feature.md | 7 - .../inula/src/inulax/proxy/ProxyHandler.ts | 4 +- .../proxy/handlers/BaseObjectHandler.ts | 4 - .../inula/src/inulax/reactive/Computed.ts | 1 + packages/inula/src/inulax/reactive/Ref.ts | 83 ++- .../inula/src/inulax/store/StoreHandler.ts | 36 +- .../inula/src/inulax/types/ReactiveTypes.ts | 7 + packages/inula/src/inulax/types/StoreTypes.ts | 26 +- 57 files changed, 2952 insertions(+), 697 deletions(-) rename packages/inula-adapter/{pinia-adapter => }/.prettierrc.js (100%) create mode 100644 packages/inula-adapter/README.md rename packages/inula-adapter/{vue-adapter => }/babel.config.js (100%) create mode 100644 packages/inula-adapter/docs/README_pinia.md create mode 100644 packages/inula-adapter/docs/README_vue.md create mode 100644 packages/inula-adapter/docs/README_vuex.md create mode 100644 packages/inula-adapter/npm/pinia/package.json create mode 100644 packages/inula-adapter/npm/vuex/package.json rename packages/inula-adapter/{vue-adapter => }/package.json (84%) delete mode 100644 packages/inula-adapter/pinia-adapter/README.md delete mode 100644 packages/inula-adapter/pinia-adapter/package.json delete mode 100644 packages/inula-adapter/pinia-adapter/src/pinia.ts delete mode 100644 packages/inula-adapter/pinia-adapter/src/ref.ts delete mode 100644 packages/inula-adapter/pinia-adapter/tests/getters.test.ts delete mode 100644 packages/inula-adapter/pinia-adapter/tests/state.test.ts delete mode 100644 packages/inula-adapter/pinia-adapter/tests/store.test.ts delete mode 100644 packages/inula-adapter/pinia-adapter/tsconfig.json rename packages/inula-adapter/{vue-adapter => }/scripts/build-types.js (83%) rename packages/inula-adapter/{vue-adapter => }/scripts/rollup.config.js (52%) rename packages/inula-adapter/{pinia-adapter/src => src/pinia}/index.ts (91%) create mode 100644 packages/inula-adapter/src/pinia/pinia.ts create mode 100644 packages/inula-adapter/src/pinia/types.ts rename packages/inula-adapter/{vue-adapter/src => src/vue}/index.ts (100%) rename packages/inula-adapter/{vue-adapter/src => src/vue}/lifecycle.ts (77%) rename packages/inula-adapter/{pinia-adapter/vitest.config.ts => src/vue/types.ts} (81%) create mode 100644 packages/inula-adapter/src/vuex/index.ts create mode 100644 packages/inula-adapter/src/vuex/types.ts create mode 100644 packages/inula-adapter/src/vuex/vuex.ts rename packages/inula-adapter/{pinia-adapter/tests/actions.test.ts => tests/pinia/pinia.actions.test.ts} (63%) create mode 100644 packages/inula-adapter/tests/pinia/pinia.getters.test.ts create mode 100644 packages/inula-adapter/tests/pinia/pinia.state.test.ts create mode 100644 packages/inula-adapter/tests/pinia/pinia.store.test.ts create mode 100644 packages/inula-adapter/tests/pinia/pinia.storeSetup.test.ts create mode 100644 packages/inula-adapter/tests/pinia/pinia.storeToRefs.test.ts rename packages/inula-adapter/{vue-adapter/tests => tests/vue}/liftcycle.test.tsx (99%) create mode 100644 packages/inula-adapter/tests/vuex/vuex.modules.test.ts create mode 100644 packages/inula-adapter/tests/vuex/vuex.store.test.ts rename packages/inula-adapter/{vue-adapter => }/tsconfig.json (100%) create mode 100644 packages/inula-adapter/tsconfig.pinia.json rename packages/inula-adapter/{vue-adapter/tsconfig.build.json => tsconfig.vue.json} (63%) create mode 100644 packages/inula-adapter/tsconfig.vuex.json rename packages/inula-adapter/{vue-adapter => }/vitest.config.ts (92%) delete mode 100644 packages/inula-adapter/vue-adapter/README.md create mode 100644 packages/inula/__tests__/HorizonXTest/ReactiveTest/toRef.test.tsx diff --git a/packages/inula-adapter/pinia-adapter/.prettierrc.js b/packages/inula-adapter/.prettierrc.js similarity index 100% rename from packages/inula-adapter/pinia-adapter/.prettierrc.js rename to packages/inula-adapter/.prettierrc.js diff --git a/packages/inula-adapter/README.md b/packages/inula-adapter/README.md new file mode 100644 index 00000000..ce181b4a --- /dev/null +++ b/packages/inula-adapter/README.md @@ -0,0 +1,7 @@ +## 适配Vue + +### 生命周期 + +### pinia + +### vuex diff --git a/packages/inula-adapter/vue-adapter/babel.config.js b/packages/inula-adapter/babel.config.js similarity index 100% rename from packages/inula-adapter/vue-adapter/babel.config.js rename to packages/inula-adapter/babel.config.js diff --git a/packages/inula-adapter/docs/README_pinia.md b/packages/inula-adapter/docs/README_pinia.md new file mode 100644 index 00000000..ec762348 --- /dev/null +++ b/packages/inula-adapter/docs/README_pinia.md @@ -0,0 +1,101 @@ +## pinia接口差异 + +1、不支createPinia的相关函数,使用了是没有效果的 +```js +it('not support createPinia', () => { + const pinia = createPinia(); // 接口可以调用,但是没有效果 + const store = useStore(pinia); // 传入pinia也没有效果 + + expect(store.name).toBe('a'); + expect(store.$state.name).toBe('a'); + expect(pinia.state.value.main.name).toBe('a'); // 不能通过pinia.state.value.main获取store +}); +``` + +2、因为不支持createPinia,同样也不支持setActivePinia、getActivePinia() + +3、不支持store.$patch +```js +it('can not be set with patch', () => { + const pinia = createPinia(); + const store = useStore(pinia); + + store.$patch({ name: 'a' }); // 不支持 + // 改成 + store.$state.name = 'a'; // 可以改成直接赋值 + + expect(store.name).toBe('a'); +}); +``` + +4、不支持store.$reset(); +```ts +it('can not reset the state', () => { + const store = useStore(); + store.name = 'Ed'; + store.nested.n++; + store.$reset(); // 不支持 + expect(store.$state).toEqual({ + counter: 0, + name: 'Eduardo', + nested: { + n: 0, + }, + }); +}); +``` + +5、不支持store.$dispose,可以用store.$unsubscribe代替 +```js +it('can not be disposed', () => { + const useStore = defineStore({ + id: 'main', + state: () => ({ n: 0 }), + }); + + const store = useStore(); + const spy = vi.fn(); + + store.$subscribe(spy, { flush: 'sync' }); + store.$state.n++; + expect(spy).toHaveBeenCalledTimes(1); + + expect(useStore()).toBe(store); + + // store.$dispose(); + // 改成 + store.$unsubscribe(spy); + + store.$state.n++; + + expect(spy).toHaveBeenCalledTimes(1); +}); +``` + +6、支持$subscribe,不需要flush,默认就是sync +```js +it('can be $unsubscribe', () => { + const useStore = defineStore({ + id: 'main', + state: () => ({ n: 0 }), + }); + + const store = useStore(); + const spy = vi.fn(); + + // store.$subscribe(spy, { flush: 'sync' }); + // 不需要flush,默认就是sync + store.$subscribe(spy, { flush: 'sync' }); + store.$state.n++; + expect(spy).toHaveBeenCalledTimes(1); + + expect(useStore()).toBe(store); + store.$unsubscribe(spy); + store.$state.n++; + + expect(spy).toHaveBeenCalledTimes(1); +}); +``` + + + diff --git a/packages/inula-adapter/docs/README_vue.md b/packages/inula-adapter/docs/README_vue.md new file mode 100644 index 00000000..4a174d8e --- /dev/null +++ b/packages/inula-adapter/docs/README_vue.md @@ -0,0 +1,10 @@ +## 适配Vue + +### 生命周期 +onBeforeMount +onMounted +onBeforeUpdate +onUpdated +onBeforeUnmount +onUnmounted + diff --git a/packages/inula-adapter/docs/README_vuex.md b/packages/inula-adapter/docs/README_vuex.md new file mode 100644 index 00000000..85457812 --- /dev/null +++ b/packages/inula-adapter/docs/README_vuex.md @@ -0,0 +1,188 @@ +## vuex接口差异 + +1、createStore接口中的,state、mutations、actions、getters不能用相同名字属性 +```js +it('dispatching actions, sync', () => { + const store = createStore({ + state: { + a: 1 + }, + mutations: { + [TEST] (state, n) { + state.a += n + } + }, + actions: { + [TEST] ({ commit }, n) { + commit(TEST, n) + } + } + }) + store.dispatch(TEST, 2) + expect(store.state.a).toBe(3) +}) +``` + +2、createStore接口不支持strict属性 +```js +const store = createStore({ + state: { + a: 1 + }, + mutations: { + [TEST] (state, n) { + state.a += n + } + }, + actions: { + [TEST] ({ commit }, n) { + commit(TEST, n) + } + }, + strict: true +}) +``` + +3、store.registerModule不支持传入数组,只支持一层的module注册 +```js +it('dynamic module registration with namespace inheritance', () => { + const store = createStore({ + modules: { + a: { + namespaced: true, + }, + }, + }); + const actionSpy = vi.fn(); + const mutationSpy = vi.fn(); + store.registerModule(['a', 'b'], { + state: { value: 1 }, + getters: { foo: state => state.value }, + actions: { foo: actionSpy }, + mutations: { foo: mutationSpy }, + }); + + expect(store.state.a.b.value).toBe(1); + expect(store.getters['a/foo']).toBe(1); + + store.dispatch('a/foo'); + expect(actionSpy).toHaveBeenCalled(); + + store.commit('a/foo'); + expect(mutationSpy).toHaveBeenCalled(); +}); +``` +4、createStore不支持多层mudule注册 +```js +it('module: mutation', function () { + const mutations = { + [TEST](state, n) { + state.a += n; + }, + }; + const store = createStore({ + state: { + a: 1, + }, + mutations, + modules: { // 第一层module,支持 + nested: { + state: {a: 2}, + mutations, + modules: { // 第二层module,不支持 + one: { + state: {a: 3}, + mutations, + }, + nested: { + modules: { // 第三层module,不支持 + two: { + state: {a: 4}, + mutations, + }, + three: { + state: {a: 5}, + mutations, + }, + }, + }, + }, + }, + four: { + state: {a: 6}, + mutations, + }, + }, + }); +}); +``` + +4、不支持store.replaceState +```js +const store = createStore({}); +store.replaceState({ a: { foo: 'state' } }); +``` +5、store.registerModule不支持传入options参数 +```js +store.registerModule( + 'a', + { + namespaced: true, + getters: { foo: state => state.foo }, + actions: { foo: actionSpy }, + mutations: { foo: mutationSpy }, + }, + { preserveState: true } +); +``` + +5、dispatch不支持传入options参数 +```js +dispatch('foo', null, {root: true}); +``` + +6、不支持action中的root属性,action需要是个函数 +```js +const store = createStore({ + modules: { + a: { + namespaced: true, + actions: { + [TEST]: { + root: true, + handler() { + return 1; + }, + }, + }, + }, + } +}); +``` + +7、createStore中不支持plugins +```js +const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST](state, n) { + state.a += n; + }, + }, + actions: { + [TEST]: actionSpy, + }, + plugins: [ // 不支持plugins + store => { + initState = store.state; + store.subscribe((mut, state) => { + expect(state).toBe(state); + mutations.push(mut); + }); + store.subscribeAction(subscribeActionSpy); + }, + ], +}); +``` diff --git a/packages/inula-adapter/npm/pinia/package.json b/packages/inula-adapter/npm/pinia/package.json new file mode 100644 index 00000000..a330ce2c --- /dev/null +++ b/packages/inula-adapter/npm/pinia/package.json @@ -0,0 +1,5 @@ +{ + "module": "./esm/pinia-adapter.js", + "main": "./cjs/pinia-adapter.js", + "types": "./@types/index.d.ts" +} diff --git a/packages/inula-adapter/npm/vuex/package.json b/packages/inula-adapter/npm/vuex/package.json new file mode 100644 index 00000000..4f27b53e --- /dev/null +++ b/packages/inula-adapter/npm/vuex/package.json @@ -0,0 +1,5 @@ +{ + "module": "./esm/vuex-adapter.js", + "main": "./cjs/vuex-adapter.js", + "types": "./@types/index.d.ts" +} diff --git a/packages/inula-adapter/vue-adapter/package.json b/packages/inula-adapter/package.json similarity index 84% rename from packages/inula-adapter/vue-adapter/package.json rename to packages/inula-adapter/package.json index 14fd1455..702897db 100644 --- a/packages/inula-adapter/vue-adapter/package.json +++ b/packages/inula-adapter/package.json @@ -2,17 +2,17 @@ "name": "@inula/vue-adapter", "version": "0.0.1", "description": "vue adapter", - "main": "./build/cjs/vue-adapter.js", - "module": "./build/esm/vue-adapter.js", - "types": "build/@types/index.d.ts", + "main": "./vue/cjs/vue-adapter.js", + "module": "./vue/esm/vue-adapter.js", + "types": "./vue/@types/index.d.ts", "files": [ - "/build", + "build/**/*", "README.md" ], "scripts": { "test": "vitest --ui", "build": "rollup -c ./scripts/rollup.config.js && npm run build-types", - "build-types": "tsc -p tsconfig.build.json && rollup -c ./scripts/build-types.js" + "build-types": "tsc -p tsconfig.vue.json && tsc -p tsconfig.pinia.json && tsc -p tsconfig.vuex.json && rollup -c ./scripts/build-types.js" }, "dependencies": { "openinula": "workspace:*" @@ -53,7 +53,8 @@ "@vitest/ui": "^0.34.5", "jsdom": "^24.0.0", "vitest": "^0.34.5", - "@vitejs/plugin-react": "^4.2.1" + "@vitejs/plugin-react": "^4.2.1", + "@testing-library/user-event": "^12.1.10" }, "peerDependencies": { "openinula": ">=0.1.1" diff --git a/packages/inula-adapter/pinia-adapter/README.md b/packages/inula-adapter/pinia-adapter/README.md deleted file mode 100644 index 539a1cfa..00000000 --- a/packages/inula-adapter/pinia-adapter/README.md +++ /dev/null @@ -1,18 +0,0 @@ -接口差异: - -1、$patch不需要,可以直接赋值 -```ts -actions: { - setFoo(foo) { - // not support - this.$patch({ nested: { foo } }); - // support - this.nested = { foo }; - } -} - -``` - -2、watch写法不同 - - diff --git a/packages/inula-adapter/pinia-adapter/package.json b/packages/inula-adapter/pinia-adapter/package.json deleted file mode 100644 index e5217f64..00000000 --- a/packages/inula-adapter/pinia-adapter/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@inula/pinia-adapter", - "version": "0.0.1", - "description": "pinia adapter", - "main": "./src/index.ts", - "scripts": { - "test": "vitest --ui" - }, - "dependencies": { - "@babel/cli": "^7.23.9", - "@babel/core": "^7.23.9", - "@babel/plugin-syntax-typescript": "^7.23.3", - "openinula": "workspace:*" - }, - "devDependencies": { - "@testing-library/user-event": "^12.1.10", - "@vitest/ui": "^0.34.5", - "jsdom": "^24.0.0", - "vitest": "^0.34.5" - } -} diff --git a/packages/inula-adapter/pinia-adapter/src/pinia.ts b/packages/inula-adapter/pinia-adapter/src/pinia.ts deleted file mode 100644 index 3bca6636..00000000 --- a/packages/inula-adapter/pinia-adapter/src/pinia.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2020 Huawei Technologies Co.,Ltd. - * - * openInula is licensed under Mulan PSL v2. - * You can use this software according to the terms and conditions of the Mulan PSL v2. - * You may obtain a copy of Mulan PSL v2 at: - * - * http://license.coscl.org.cn/MulanPSL2 - * - * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, - * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, - * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. - * See the Mulan PSL v2 for more details. - */ -import { createStore, watch as wt, StoreObj } from 'openinula'; -import { ref } from './ref'; - -const storeMap = new Map(); - -interface StoreDefinition { - id: string; - state: () => Record; - actions?: Record; - getters?: Record; -} - -type StoreCallback = () => Record; - -export function defineStore(id: string | StoreDefinition, cbOrDef?: StoreCallback | StoreDefinition): Function { - if (!cbOrDef && typeof id === 'object') { - cbOrDef = id as StoreDefinition; - id = cbOrDef.id; - } - - if (typeof cbOrDef === 'object') { - return defineOptionsStore(id as string, cbOrDef as StoreDefinition); - } - - return () => { - const data = (cbOrDef as StoreCallback)(); - - if (storeMap.has(cbOrDef)) { - return storeMap.get(cbOrDef)(); - } - - const entries = Object.entries(data); - - const state = Object.fromEntries(entries.filter(([key, val]) => val.isRef).map(([key, val]) => [key, val.value])); - const computed = Object.fromEntries( - entries.filter(([key, val]) => val.isComputed).map(([key, val]) => [key, val.raw]) - ); - const actions = Object.fromEntries(entries.filter(([key, val]) => !val.isRef && !val.isComputed)); - - const useStore = createStore({ - id: id as string, - state, - computed, - actions: enhanceActions(actions), - }); - - Object.entries(data) - .filter(([key, val]) => val.isRef) - .forEach(([key, val]) => - val.watch(newVal => { - useStore().$s[key] = newVal; - }) - ); - - storeMap.set(cbOrDef, useStore); - - return useStore(); - }; -} - -function enhanceActions(actions) { - if (!actions) { - return {}; - } - - return Object.fromEntries( - Object.entries(actions).map(([key, value]) => { - return [ - key, - function (store, ...args) { - return value.bind(this)(...args); - }, - ]; - }) - ); -} - -function defineOptionsStore( - id: string, - { state, actions, getters }: StoreDefinition -): () => StoreObj { - if (typeof state === 'function') { - state = state(); - } - let useStore = null; - - return () => { - if (!useStore) { - useStore = createStore({ - id, - state, - actions: enhanceActions(actions), - computed: getters, - }); - } - - return useStore(); - }; -} - -export function mapStores(...stores) { - const result = {}; - - stores.forEach(store => { - const expandedStore = store(); - result[`${expandedStore.id}Store`] = () => expandedStore; - }); - - return result; -} - -export function storeToRefs(store) { - return Object.fromEntries( - Object.entries(store.$s).map(([key, value]) => { - return [key, ref(value)]; - }) - ); -} - -export function createPinia() { - const result = { - install: app => {}, // do something? - use: plugin => result, - state: {}, // - }; - return result; -} - -export const watch = wt; diff --git a/packages/inula-adapter/pinia-adapter/src/ref.ts b/packages/inula-adapter/pinia-adapter/src/ref.ts deleted file mode 100644 index 3e82c525..00000000 --- a/packages/inula-adapter/pinia-adapter/src/ref.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2020 Huawei Technologies Co.,Ltd. - * - * openInula is licensed under Mulan PSL v2. - * You can use this software according to the terms and conditions of the Mulan PSL v2. - * You may obtain a copy of Mulan PSL v2 at: - * - * http://license.coscl.org.cn/MulanPSL2 - * - * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, - * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, - * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. - * See the Mulan PSL v2 for more details. - */ - -import { useState, useRef } from 'openinula'; -export function refBase(initialValue, update, state) { - let listeners = new Set(); - - return new Proxy( - { value: initialValue }, - { - get: (target, name) => { - if (name === 'value' || name === 'current') { - return state.current; - } - if (name === Symbol.toPrimitive) { - return () => state.current; - } - if (name === 'isRef') return true; - if (name === 'watch') - return cb => { - listeners.add(cb); - }; - if (name === 'raw') return initialValue; - }, - set: (target, name, value) => { - if (name === 'value') { - if (state.current === value) return true; - state.current = value; - update(); - Array.from(listeners.values()).forEach(listener => { - listener(value); - }); - return true; - } else if (name === 'current') { - if (state.current === value) return true; - state.current = value; - return true; - } - }, - } - ); -} - -export function ref(initialValue) { - let [b, r] = useState(false); - let state = useRef(initialValue); - - return refBase(initialValue, () => r(!b), state); -} - -export function ref(initialValue) { - let [b, r] = useState(false); - let state = useRef(initialValue); - - return refBase(initialValue, () => r(!b), state); -} diff --git a/packages/inula-adapter/pinia-adapter/tests/getters.test.ts b/packages/inula-adapter/pinia-adapter/tests/getters.test.ts deleted file mode 100644 index c1fdc93a..00000000 --- a/packages/inula-adapter/pinia-adapter/tests/getters.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) 2024 Huawei Technologies Co.,Ltd. - * - * openInula is licensed under Mulan PSL v2. - * You can use this software according to the terms and conditions of the Mulan PSL v2. - * You may obtain a copy of Mulan PSL v2 at: - * - * http://license.coscl.org.cn/MulanPSL2 - * - * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, - * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, - * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. - * See the Mulan PSL v2 for more details. - */ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck For the compiled code. - -import { describe, it, vi, expect } from 'vitest'; -import { defineStore, createPinia } from '../src/pinia'; -import { ref } from '../src/ref'; - -function expectType(_value: T): void {} - -describe('pinia getters', () => { - const useStore = defineStore({ - id: 'main', - state: () => ({ - name: 'Eduardo', - }), - getters: { - upperCaseName(store) { - return store.name.toUpperCase(); - }, - doubleName(): string { - return this.upperCaseName; - }, - composed(): string { - return this.upperCaseName + ': ok'; - }, - arrowUpper: state => { - // @ts-expect-error - state.nope; - state.name.toUpperCase(); - }, - }, - actions: { - o() { - // @ts-expect-error it should type getters - this.arrowUpper.toUpperCase(); - this.o().toUpperCase(); - return 'a string'; - }, - }, - }); - - const useB = defineStore({ - id: 'B', - state: () => ({ b: 'b' }), - }); - - const useA = defineStore({ - id: 'A', - state: () => ({ a: 'a' }), - getters: { - fromB(): string { - const bStore = useB(); - return this.a + ' ' + bStore.b; - }, - }, - }); - - it('adds getters to the store', () => { - const store = useStore(); - expect(store.upperCaseName).toBe('EDUARDO'); - - // @ts-expect-error - store.nope; - - store.name = 'Ed'; - expect(store.upperCaseName).toBe('ED'); - }); - - it('updates the value', () => { - const store = useStore(); - store.name = 'Ed'; - expect(store.upperCaseName).toBe('ED'); - }); - - it('can use other getters', () => { - const store = useStore(); - expect(store.composed).toBe('EDUARDO: ok'); - store.name = 'Ed'; - expect(store.composed).toBe('ED: ok'); - }); - - describe('cross used stores', () => { - const useA = defineStore('a', () => { - const n = ref(0); - const double = computed(() => n.value * 2); - const sum = computed(() => n.value + B.n); - - function increment() { - return n.value++; - } - - function incrementB() { - return B.increment(); - } - - return { n, double, sum, increment, incrementB }; - }); - - const useB = defineStore('b', () => { - const n = ref(0); - const double = computed(() => n.value * 2); - - function increment() { - return n.value++; - } - - function incrementA() { - return A.increment(); - } - - return { n, double, incrementA, increment }; - }); - - const A = useA(); - const B = useB(); - - it('keeps getters reactive', () => { - const a = useA(); - const b = useB(); - - expectType<() => number>(a.increment); - expectType<() => number>(b.increment); - expectType<() => number>(a.incrementB); - expectType<() => number>(b.incrementA); - - expect(a.double).toBe(0); - b.incrementA(); - expect(a.double).toBe(2); - a.increment(); - expect(a.double).toBe(4); - - expect(b.double).toBe(0); - a.incrementB(); - expect(b.double).toBe(2); - b.increment(); - expect(b.double).toBe(4); - }); - }); -}); diff --git a/packages/inula-adapter/pinia-adapter/tests/state.test.ts b/packages/inula-adapter/pinia-adapter/tests/state.test.ts deleted file mode 100644 index 18e02e48..00000000 --- a/packages/inula-adapter/pinia-adapter/tests/state.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2020 Huawei Technologies Co.,Ltd. - * - * openInula is licensed under Mulan PSL v2. - * You can use this software according to the terms and conditions of the Mulan PSL v2. - * You may obtain a copy of Mulan PSL v2 at: - * - * http://license.coscl.org.cn/MulanPSL2 - * - * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, - * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, - * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. - * See the Mulan PSL v2 for more details. - */ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck For the compiled code. - -import { beforeEach, describe, it, vi, expect } from 'vitest'; -import { createPinia, defineStore, computed } from '../src/pinia'; - -describe('pinia state', () => { - beforeEach(() => {}); - - const useStore = defineStore('main', { - state: () => ({ - name: 'Eduardo', - counter: 0, - nested: { n: 0 }, - }), - }); - - it('can directly access state at the store level', () => { - const store = useStore(); - expect(store.name).toBe('Eduardo'); - store.name = 'Ed'; - expect(store.name).toBe('Ed'); - }); - - it.skip('state is reactive', () => { - const store = useStore(); - const upperCased = computed(() => store.name.toUpperCase()); - expect(upperCased.value).toBe('EDUARDO'); - store.name = 'Ed'; - expect(upperCased.value).toBe('ED'); - }); - - it('can be nested set on store.$s, not $state', () => { - const store = useStore(); - - store.$s.nested.n = 3; - - expect(store.nested.n).toBe(3); - expect(store.$s.nested.n).toBe(3); - }); -}); diff --git a/packages/inula-adapter/pinia-adapter/tests/store.test.ts b/packages/inula-adapter/pinia-adapter/tests/store.test.ts deleted file mode 100644 index 5469e648..00000000 --- a/packages/inula-adapter/pinia-adapter/tests/store.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2024 Huawei Technologies Co.,Ltd. - * - * openInula is licensed under Mulan PSL v2. - * You can use this software according to the terms and conditions of the Mulan PSL v2. - * You may obtain a copy of Mulan PSL v2 at: - * - * http://license.coscl.org.cn/MulanPSL2 - * - * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, - * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, - * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. - * See the Mulan PSL v2 for more details. - */ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck For the compiled code. - -import { beforeEach, describe, it, vi, expect } from 'vitest'; -import { createPinia, defineStore, computed } from '../src/pinia'; - -describe('pinia state', () => { - beforeEach(() => {}); - - const useStore = defineStore({ - id: 'main', - state: () => ({ - a: true, - nested: { - foo: 'foo', - a: { b: 'string' }, - }, - }), - }); - - it.skip('sets the initial state', () => { - const store = useStore(); - store.$s; - expect(store.$s).toEqual({ - a: true, - nested: { - foo: 'foo', - a: { b: 'string' }, - }, - }); - }); - - it('reuses a store', () => { - const useStore = defineStore({ id: 'main' }); - expect(useStore()).toBe(useStore()); - }); - - it('can replace its state', () => { - const store = useStore(); - const spy = vi.fn(); - store.$s.watch('a', () => { - spy(); - }); - - expect(store.a).toBe(true); - - expect(spy).toHaveBeenCalledTimes(0); - - store.a = false; - - expect(spy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/inula-adapter/pinia-adapter/tsconfig.json b/packages/inula-adapter/pinia-adapter/tsconfig.json deleted file mode 100644 index 76086172..00000000 --- a/packages/inula-adapter/pinia-adapter/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "lib": ["ESNext", "DOM"], - "moduleResolution": "Node", - "strict": true, - "esModuleInterop": true - }, - "ts-node": { - "esm": true - } -} diff --git a/packages/inula-adapter/vue-adapter/scripts/build-types.js b/packages/inula-adapter/scripts/build-types.js similarity index 83% rename from packages/inula-adapter/vue-adapter/scripts/build-types.js rename to packages/inula-adapter/scripts/build-types.js index 99189984..6628f754 100644 --- a/packages/inula-adapter/vue-adapter/scripts/build-types.js +++ b/packages/inula-adapter/scripts/build-types.js @@ -51,14 +51,14 @@ export function cleanUp(folders) { }; } -function buildTypeConfig() { +function buildTypeConfig(name) { return { - input: ['./build/@types/index.d.ts'], + input: [`./build/${name}/@types/${name}/index.d.ts`], output: { - file: './build/@types/index.d.ts', + file: `./build/${name}/@types/index.d.ts`, }, - plugins: [dts(), cleanUp(['./build/@types/'])], + plugins: [dts(), cleanUp([`./build/${name}/@types/`])], }; } -export default [buildTypeConfig()]; +export default [buildTypeConfig('vue'), buildTypeConfig('pinia'), buildTypeConfig('vuex')]; diff --git a/packages/inula-adapter/vue-adapter/scripts/rollup.config.js b/packages/inula-adapter/scripts/rollup.config.js similarity index 52% rename from packages/inula-adapter/vue-adapter/scripts/rollup.config.js rename to packages/inula-adapter/scripts/rollup.config.js index b176cb5f..eb483d36 100644 --- a/packages/inula-adapter/vue-adapter/scripts/rollup.config.js +++ b/packages/inula-adapter/scripts/rollup.config.js @@ -15,7 +15,6 @@ import path from 'path'; import fs from 'fs'; -import { fileURLToPath } from 'url'; import babel from '@rollup/plugin-babel'; import nodeResolve from '@rollup/plugin-node-resolve'; import { terser } from 'rollup-plugin-terser'; @@ -29,16 +28,16 @@ if (!fs.existsSync(outDir)) { fs.mkdirSync(outDir, { recursive: true }); } -const getConfig = mode => { +const getConfig = (mode, name) => { const prod = mode.startsWith('prod'); const outputList = [ { - file: path.join(outDir, `cjs/vue-adapter.${prod ? 'min.' : ''}js`), + file: path.join(outDir, `${name}/cjs/${name}-adapter.${prod ? 'min.' : ''}js`), sourcemap: 'true', format: 'cjs', }, { - file: path.join(outDir, `umd/vue-adapter.${prod ? 'min.' : ''}js`), + file: path.join(outDir, `${name}/umd/${name}-adapter.${prod ? 'min.' : ''}js`), name: 'VueAdapter', sourcemap: 'true', format: 'umd', @@ -46,13 +45,13 @@ const getConfig = mode => { ]; if (!prod) { outputList.push({ - file: path.join(outDir, 'esm/vue-adapter.js'), + file: path.join(outDir, `${name}/esm/${name}-adapter.js`), sourcemap: 'true', format: 'esm', }); } return { - input: path.join(rootDir, '/src/index.ts'), + input: path.join(rootDir, `/src/${name}/index.ts`), output: outputList, plugins: [ nodeResolve({ @@ -66,8 +65,49 @@ const getConfig = mode => { extensions, }), prod && terser(), + name === 'vue' + ? copyFiles([ + { + from: path.join(rootDir, 'package.json'), + to: path.join(outDir, 'package.json'), + }, + { + from: path.join(rootDir, 'README.md'), + to: path.join(outDir, 'README.md'), + }, + ]) + : copyFiles([ + { + from: path.join(rootDir, `npm/${name}/package.json`), + to: path.join(outDir, `${name}/package.json`), + }, + ]), ], }; }; -export default [getConfig('dev'), getConfig('prod')]; +function copyFiles(copyPairs) { + return { + name: 'copy-files', + generateBundle() { + copyPairs.forEach(({ from, to }) => { + const destDir = path.dirname(to); + // 判断目标文件夹是否存在 + if (!fs.existsSync(destDir)) { + // 目标文件夹不存在,创建它 + fs.mkdirSync(destDir, { recursive: true }); + } + fs.copyFileSync(from, to); + }); + }, + }; +} + +export default [ + getConfig('dev', 'vue'), + getConfig('prod', 'vue'), + getConfig('dev', 'pinia'), + getConfig('prod', 'pinia'), + getConfig('dev', 'vuex'), + getConfig('prod', 'vuex'), +]; diff --git a/packages/inula-adapter/pinia-adapter/src/index.ts b/packages/inula-adapter/src/pinia/index.ts similarity index 91% rename from packages/inula-adapter/pinia-adapter/src/index.ts rename to packages/inula-adapter/src/pinia/index.ts index 26730933..74e0da32 100644 --- a/packages/inula-adapter/pinia-adapter/src/index.ts +++ b/packages/inula-adapter/src/pinia/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Huawei Technologies Co.,Ltd. + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. * * openInula is licensed under Mulan PSL v2. * You can use this software according to the terms and conditions of the Mulan PSL v2. diff --git a/packages/inula-adapter/src/pinia/pinia.ts b/packages/inula-adapter/src/pinia/pinia.ts new file mode 100644 index 00000000..dd5d8d44 --- /dev/null +++ b/packages/inula-adapter/src/pinia/pinia.ts @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ +import { createStore, StoreObj, vueReactive } from 'openinula'; +import { + FilterAction, + FilterComputed, + FilterState, + StoreDefinition, + StoreSetup, + Store, + AnyFunction, + ActionType, + StoreToRefsReturn, +} from './types'; + +const { ref, isRef, toRef, isReactive, isReadonly } = vueReactive; + +const storeMap = new Map(); + +export function defineStore< + Id extends string, + S extends Record, + A extends Record, + C extends Record, +>(definition: StoreDefinition): (pinia?: any) => Store; + +export function defineStore< + Id extends string, + S extends Record, + A extends Record, + C extends Record, +>(id: Id, definition: Omit, 'id'>): (pinia?: any) => Store; + +export function defineStore>( + id: Id, + setup: StoreSetup +): (pinia?: any) => Store, FilterAction, FilterComputed>; + +export function defineStore(idOrDef: any, setupOrDef?: any) { + let id: string; + let definition: StoreDefinition | StoreSetup; + let isSetup = false; + + if (typeof idOrDef === 'string') { + isSetup = typeof setupOrDef === 'function'; + id = idOrDef; + definition = setupOrDef; + } else { + id = idOrDef.id; + definition = idOrDef; + } + + if (isSetup) { + return defineSetupStore(id, definition as StoreSetup); + } else { + return defineOptionsStore(id, definition as StoreDefinition); + } +} + +/** + * createStore实现中会给actions增加第一个参数store,pinia不需要,所以需要去掉 + * @param actions + */ +function enhanceActions( + actions?: ActionType, Record, Record> +) { + if (!actions) { + return {}; + } + + return Object.fromEntries( + Object.entries(actions).map(([key, value]) => { + return [ + key, + function (this: StoreObj, state: Record, ...args: any[]) { + return value.bind(this)(...args); + }, + ]; + }) + ); +} + +function defineOptionsStore(id: string, definition: StoreDefinition) { + const state = definition.state ? definition.state() : {}; + const computed = definition.getters || {}; + const actions = enhanceActions(definition.actions) || {}; + + return () => { + if (storeMap.has(id)) { + return storeMap.get(id)!(); + } + + const useStore = createStore({ + id, + state, + actions, + computed, + }); + + storeMap.set(id, useStore); + + return useStore(); + }; +} + +function defineSetupStore>(id: string, storeSetup: StoreSetup) { + return () => { + const data = storeSetup(); + if (!data) { + return {}; + } + + if (storeMap.has(id)) { + return storeMap.get(id)!(); + } + + const state: Record = {}; + const actions: Record = {}; + const getters: Record = {}; + for (const key in data) { + const prop = data[key]; + + if ((isRef(prop) && !isReadonly(prop)) || isReactive(prop)) { + // state + state[key] = prop; + } else if (typeof prop === 'function') { + // action + actions[key] = prop as AnyFunction; + } else if (isRef(prop) && isReadonly(prop)) { + // getters + getters[key] = (prop as any).fn as AnyFunction; + } + } + + const useStore = createStore({ + id, + state, + computed: getters, + actions: enhanceActions(actions), + }); + + storeMap.set(id, useStore); + + return useStore(); + }; +} + +export function mapStores< + S extends Record, + A extends Record, + C extends Record, +>(...stores: (() => Store)[]): { [key: string]: () => Store } { + const result: { [key: string]: () => Store } = {}; + + stores.forEach((store: () => Store) => { + const expandedStore = store(); + result[`${expandedStore.id}Store`] = () => expandedStore; + }); + + return result; +} + +export function storeToRefs< + S extends Record, + A extends Record, + C extends Record, +>(store: Store): StoreToRefsReturn { + const stateRefs = Object.fromEntries( + Object.entries(store.$s || {}).map(([key, value]) => { + return [key, ref(value)]; + }) + ); + + const getterRefs = Object.fromEntries( + Object.entries(store.$config.computed || {}).map(([key, value]) => { + const computeFn = (value as () => any).bind(store, store.$s); + return [key, toRef(computeFn)]; + }) + ); + + return { ...stateRefs, ...getterRefs } as StoreToRefsReturn; +} + +export function createPinia() { + console.warn( + `The pinia-adapter in Horizon does not support the createPinia interface. Please modify your code accordingly.` + ); + + const result = { + install: (app: any) => {}, + use: (plugin: any) => result, + state: {}, + }; + + return result; +} diff --git a/packages/inula-adapter/src/pinia/types.ts b/packages/inula-adapter/src/pinia/types.ts new file mode 100644 index 00000000..eb2eee71 --- /dev/null +++ b/packages/inula-adapter/src/pinia/types.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +// import type {RefType, StoreObj, UnwrapRef, UserActions, UserComputedValues, ComputedImpl} from 'openinula'; +import type { RefType, UnwrapRef, ComputedImpl } from 'openinula'; + +export type StoreSetup> = () => R; + +export type AnyFunction = (...args: any[]) => any; + +// defineStore init type +export interface StoreDefinition< + Id extends string = string, + S extends Record = Record, + A extends Record = Record, + C extends Record = Record, +> { + id?: Id; + state?: () => S; + actions?: ActionType; + getters?: ComputedType; +} + +// defineStore return type +export type Store< + S extends Record, + A extends Record, + C extends Record, +> = { + $s: S; + $state: S; + $a: ActionType; + $c: ComputedType; + $subscribe: (listener: Listener) => void; + $unsubscribe: (listener: Listener) => void; +} & { [K in keyof S]: S[K] } & { [K in keyof ActionType]: ActionType[K] } & { + [K in keyof ComputedType]: ReturnType[K]>; +}; + +export type ActionType = A & ThisType & WithGetters>; + +type ComputedType = { + [K in keyof C]: AddFirstArg; +} & ThisType & WithGetters>; +type AddFirstArg = T extends (...args: infer A) => infer R + ? (state: S, ...args: A) => R + : T extends () => infer R + ? (state: S) => R + : T; + +// In Getter function, make this.xx can refer to other getters +export type WithGetters = { + readonly [k in keyof G]: G[k] extends (...args: any[]) => infer R ? R : UnwrapRef; +}; + +type Listener = (change: any) => void; + +// Filter state properties +export type FilterState> = { + [K in FilterStateProperties]: UnwrapRef; +}; +type FilterStateProperties> = { + [K in keyof T]: T[K] extends ComputedImpl + ? never + : T[K] extends RefType + ? K + : T[K] extends Record // Reactive类型 + ? K + : never; +}[keyof T]; + +// Filter action properties +export type FilterAction> = { + [K in FilterFunctionProperties]: T[K] extends AnyFunction ? T[K] : never; +}; +type FilterFunctionProperties> = { + [K in keyof T]: T[K] extends AnyFunction ? K : never; +}[keyof T]; + +// Filter computed properties +export type FilterComputed> = { + [K in FilterComputedProperties]: T[K] extends ComputedImpl ? (T extends AnyFunction ? T : never) : never; +}; +type FilterComputedProperties> = { + [K in keyof T]: T[K] extends ComputedImpl ? K : never; +}[keyof T]; + +export type StoreToRefsReturn, C extends Record> = { + [K in keyof S]: RefType; +} & { + [K in keyof ComputedType]: Readonly[K]>>>; +}; diff --git a/packages/inula-adapter/vue-adapter/src/index.ts b/packages/inula-adapter/src/vue/index.ts similarity index 100% rename from packages/inula-adapter/vue-adapter/src/index.ts rename to packages/inula-adapter/src/vue/index.ts diff --git a/packages/inula-adapter/vue-adapter/src/lifecycle.ts b/packages/inula-adapter/src/vue/lifecycle.ts similarity index 77% rename from packages/inula-adapter/vue-adapter/src/lifecycle.ts rename to packages/inula-adapter/src/vue/lifecycle.ts index 1e6280ae..fc51ccb2 100644 --- a/packages/inula-adapter/vue-adapter/src/lifecycle.ts +++ b/packages/inula-adapter/src/vue/lifecycle.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Huawei Technologies Co.,Ltd. + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. * * openInula is licensed under Mulan PSL v2. * You can use this software according to the terms and conditions of the Mulan PSL v2. @@ -13,6 +13,7 @@ * See the Mulan PSL v2 for more details. */ import { useEffect, useLayoutEffect, useRef } from 'openinula'; +import { FN } from './types'; // 用于存储组件是否已挂载的状态 const useIsMounted = () => { @@ -26,32 +27,32 @@ const useIsMounted = () => { return isMounted.current; }; -export const onBeforeMount = (fn: () => void) => { +export const onBeforeMount = (fn: FN) => { const isMounted = useIsMounted(); if (!isMounted) { fn?.(); } }; -export function onMounted(fn: () => void) { +export function onMounted(fn: FN) { useEffect(() => { fn?.(); }, []); } -export function onBeforeUpdated(fn: () => void) { +export function onBeforeUpdated(fn: FN) { useEffect(() => { fn?.(); }); } -export function onUpdated(fn: () => void) { +export function onUpdated(fn: FN) { useEffect(() => { fn?.(); }); } -export const onBeforeUnmount = (fn: () => void) => { +export const onBeforeUnmount = (fn: FN) => { useLayoutEffect(() => { return () => { fn?.(); @@ -59,7 +60,7 @@ export const onBeforeUnmount = (fn: () => void) => { }, []); }; -export function onUnmounted(fn: () => void) { +export function onUnmounted(fn: FN) { useEffect(() => { return () => { fn?.(); diff --git a/packages/inula-adapter/pinia-adapter/vitest.config.ts b/packages/inula-adapter/src/vue/types.ts similarity index 81% rename from packages/inula-adapter/pinia-adapter/vitest.config.ts rename to packages/inula-adapter/src/vue/types.ts index ae278123..faa7f014 100644 --- a/packages/inula-adapter/pinia-adapter/vitest.config.ts +++ b/packages/inula-adapter/src/vue/types.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Huawei Technologies Co.,Ltd. + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. * * openInula is licensed under Mulan PSL v2. * You can use this software according to the terms and conditions of the Mulan PSL v2. @@ -13,8 +13,4 @@ * See the Mulan PSL v2 for more details. */ -export default { - test: { - environment: 'jsdom', - }, -}; +export type FN = () => void; diff --git a/packages/inula-adapter/src/vuex/index.ts b/packages/inula-adapter/src/vuex/index.ts new file mode 100644 index 00000000..1b155f00 --- /dev/null +++ b/packages/inula-adapter/src/vuex/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export * from './vuex'; diff --git a/packages/inula-adapter/src/vuex/types.ts b/packages/inula-adapter/src/vuex/types.ts new file mode 100644 index 00000000..d463fb8f --- /dev/null +++ b/packages/inula-adapter/src/vuex/types.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export type AnyFunction = (...args: any[]) => any; + +export interface VuexStoreOptions< + State extends Record = Record, + Mutations extends Record = Record, + Actions extends Record = Record, + Getters extends Record = Record, + RootState extends Record = Record, + RootGetters extends Record = Record, + Modules extends Record> = Record>, +> { + namespaced?: boolean; + state?: State | (() => State); + mutations?: MutationsType; + actions?: ActionsType; + getters?: GettersType; + modules?: { + [k in keyof Modules]: VuexStoreOptions; + }; +} + +type MutationsType = { + [K in keyof Mutations]: AddFirstArg; +}; + +type ActionsType = { + [K in keyof Actions]: AddFirstArg< + Actions[K], + { + commit: CommitType; + dispatch: DispatchType; + state: State; + getters: Getters; + rootState: RootState; + rootGetters: RootGetters; + } + >; +}; + +type AddFirstArg = T extends (arg1: any, ...args: infer A) => infer R + ? (state: S, ...args: A) => R + : T extends () => infer R + ? (state: S) => R + : T; + +type GettersType = { + [K in keyof Getters]: AddArgs; +}; + +type AddArgs = T extends (...args: infer A) => infer R + ? (...args: [...Args, ...A]) => R + : T extends () => infer R + ? (...args: Args) => R + : T; + +export type CommitType = ( + type: string | (Record & { type: string }), + payload?: any, + options?: Record, + moduleName?: string +) => void; + +export type DispatchType = ( + type: string | (Record & { type: string }), + payload?: any, + options?: Record, + moduleName?: string +) => any; + +export type VuexStore< + State extends Record = Record, + Getters extends Record = Record, + Modules extends Record> = Record>, +> = { + state: State & { + [K in keyof Modules]: Modules[K] extends { state: infer ModuleState } ? ModuleState : Modules[K]; + }; + getters: { + [K in keyof Getters]: ReturnType; + }; + commit: CommitType; + dispatch: DispatchType; + subscribe: AnyFunction; + subscribeAction: AnyFunction; + watch: (fn: (state: State, getters: Getters) => void, cb: AnyFunction) => void; + registerModule: (moduleName: string, module: VuexStoreOptions) => void; + unregisterModule: (moduleName: string) => void; + hasModule: (moduleName: string) => boolean; +}; diff --git a/packages/inula-adapter/src/vuex/vuex.ts b/packages/inula-adapter/src/vuex/vuex.ts new file mode 100644 index 00000000..86616677 --- /dev/null +++ b/packages/inula-adapter/src/vuex/vuex.ts @@ -0,0 +1,315 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { createStore as createStoreX, StoreObj, vueReactive } from 'openinula'; +import { VuexStore, VuexStoreOptions } from './types'; +import { AnyFunction } from '../pinia/types'; + +const { watch } = vueReactive; + +const MUTATION_PREFIX = 'm_'; +const GETTER_PREFIX = 'g_'; + +type GettersMap = { + [K in keyof T['$c']]: ReturnType; +}; + +export function createStore< + State extends Record = Record, + Mutations extends Record = Record, + Actions extends Record = Record, + Getters extends Record = Record, + RootState extends Record = Record, + RootGetters extends Record = Record, + Modules extends Record> = Record>, +>( + options: VuexStoreOptions +): VuexStore { + const modules = options.modules || {}; + + const _modules: Record = {}; + + const _getters: GettersMap = {}; + + const vuexStore: VuexStore = { + state: new Proxy( + {}, + { + get: (_, key) => { + if (key in _modules) { + return _modules[key as string].storeX; + } else { + return rootStoreX[key as string]; + } + }, + } + ), + getters: new Proxy( + {}, + { + get: (_, key) => { + if (typeof key === 'string') { + // 如果key包含/,说明是访问模块的getters,进行split + if (key.includes('/')) { + const [moduleName, getterKey] = key.split('/'); + return _modules[moduleName].storeX[`${GETTER_PREFIX}${getterKey}`]; + } else { + return _getters[`${GETTER_PREFIX}${key}`]; + } + } + }, + } + ), + commit: (_type, _payload, _options, moduleName) => { + const { type, payload, options } = prepareTypeParams(_type, _payload, _options); + // 如果options.root为true,调用根store的action + if (options?.root) { + return rootStoreX[`${MUTATION_PREFIX}${type}`](payload); + } + + // 包含/,说明是访问模块的mutation + if (type.includes('/')) { + const [moduleName, key] = type.split('/'); + return _modules[moduleName].storeX[`${MUTATION_PREFIX}${key}`](payload); + } + + if (moduleName != undefined) { + // dispatch到指定的module + return _modules[moduleName].storeX[`${MUTATION_PREFIX}${type}`](payload); + } + + // 调用所有非namespaced的modules的mutation + Object.values(_modules).forEach(module => { + if (!module.namespaced) { + const mutation = module.storeX[`${MUTATION_PREFIX}${type}`]; + if (typeof mutation === 'function') { + mutation(payload); + } + } + }); + + // 调用storeX对象上的方法 + if (rootStoreX[`${MUTATION_PREFIX}${type}`]) { + rootStoreX[`${MUTATION_PREFIX}${type}`](payload); + } + }, + dispatch: (_type, _payload, _options, moduleName) => { + const { type, payload, options } = prepareTypeParams(_type, _payload, _options); + // 如果options.root为true,调用根store的action + if (options?.root) { + return rootStoreX[type](payload); + } + + // 包含/,说明是访问模块的action + if (type.includes('/')) { + const [moduleName, key] = type.split('/'); + return _modules[moduleName].storeX[key](payload); + } + + if (moduleName != undefined) { + // dispatch到指定的module + return _modules[moduleName].storeX[type](payload); + } + + // 把每个action的返回值合并起来,支持then链式调用 + const results: any[] = []; + + // 调用所有非namespaced的modules的action + Object.values(_modules).forEach(module => { + if (!module.namespaced) { + const action = module.storeX[type]; + if (typeof action === 'function') { + results.push(action(payload)); + } + } + }); + + // 调用storeX对象上的方法 + if (typeof rootStoreX[type] === 'function') { + results.push(rootStoreX[type](payload)); + } + + // 返回一个Promise,内容是results,支持then链式调用 + return Promise.all(results); + }, + subscribe(fn) { + return rootStoreX.$subscribe(fn); + }, + subscribeAction(fn) { + return rootStoreX.$subscribe(fn); + }, + watch(fn, cb) { + watch(() => fn(vuexStore.state, vuexStore.getters), cb); + }, + // 动态注册模块 + registerModule(key, module) { + _modules[key] = { storeX: _createStoreX(key, module, vuexStore, rootStoreX), namespaced: !!module.namespaced }; + collectGetters(_modules[key].storeX, _getters); + }, + // 动态注销模块 + unregisterModule(key) { + deleteGetters(_modules[key].storeX, _getters); + delete _modules[key]; + }, + hasModule(path) { + return path in _modules; + }, + }; + + const rootStoreX = _createStoreX(undefined, options as VuexStoreOptions, vuexStore); + collectGetters(rootStoreX, _getters); + + // 递归创建子模块 + for (const [moduleName, moduleOptions] of Object.entries(modules)) { + _modules[moduleName] = { + storeX: _createStoreX(moduleName, moduleOptions as VuexStoreOptions, vuexStore, rootStoreX), + namespaced: !!(moduleOptions as VuexStoreOptions).namespaced, + }; + collectGetters(_modules[moduleName].storeX, _getters); + } + + return vuexStore as VuexStore; +} + +export function prepareTypeParams( + type: string | (Record & { type: string }), + payload?: any, + options?: Record +) { + if (typeof type === 'object' && type.type) { + options = payload; + payload = type; + type = type.type; + } + + return { type, payload, options } as { + type: string; + payload: any; + options: Record; + }; +} + +function _createStoreX( + moduleName: string | undefined, + options: VuexStoreOptions, + store: VuexStore, + rootStoreX?: any +): StoreObj { + const { mutations = {}, actions = {}, getters = {} } = options; + const state = typeof options.state === 'function' ? options.state() : options.state; + + const storeX: StoreObj = createStoreX({ + id: moduleName, + state: state, + actions: { + // 给mutations的key增加一个前缀,避免和actions的key冲突 + ...Object.fromEntries( + Object.entries(mutations).map(([key, mutation]) => { + return [`${MUTATION_PREFIX}${key}`, mutation]; + }) + ), + // 重新定义action的方法,绑定this,修改第一参数 + ...Object.fromEntries( + Object.entries(actions).map(([key, action]) => [ + key, + function (this: StoreObj, state: Record, payload) { + rootStoreX = rootStoreX || storeX; + const argFirst = { + ...store, + // 覆盖commit方法,多传一个参数moduleName + commit: ( + type: string | (Record & { type: string }), + payload?: any, + options?: Record + ) => { + store.commit(type, payload, options, moduleName); + }, + // 覆盖dispatch方法,多传一个参数moduleName + dispatch: ( + type: string | (Record & { type: string }), + payload?: any, + options?: Record + ) => { + return store.dispatch(type, payload, options, moduleName); + }, + state: storeX.$state, + rootState: store.state, + getter: store.getters, + rootGetters: moduleGettersProxy(rootStoreX), + }; + + return action.call(storeX, argFirst, payload); + }, + ]) + ), + }, + computed: { + ...Object.fromEntries( + Object.entries(getters).map(([key, getter]) => { + return [ + // 给getters的key增加一个前缀,避免和actions, mutations的key冲突 + `${GETTER_PREFIX}${key}`, + // 重新定义getter的方法,绑定this,修改参数: state, getters, rootState, rootGetters + function (state: Record) { + rootStoreX = rootStoreX || storeX; + return getter.call( + storeX, + storeX.$state, + store.getters, + rootStoreX.$state, + moduleGettersProxy(rootStoreX) + ); + }, + ]; + }) + ), + }, + })(); + + return storeX; +} + +function collectGetters(storeX: StoreObj, gettersMap: GettersMap): void { + Object.keys(storeX.$config.computed).forEach(type => { + Object.defineProperty(gettersMap, type, { + get: () => storeX.$c[type], + configurable: true, + }); + }); +} + +function deleteGetters(storeX: StoreObj, gettersMap: GettersMap): void { + Object.keys(storeX.$config.computed).forEach(type => { + // 删除Object.defineProperty定义的属性 + Object.defineProperty(gettersMap, type, { + value: undefined, + writable: true, + enumerable: true, + configurable: true, + }); + delete gettersMap[type]; + }); +} + +function moduleGettersProxy(storeX: StoreObj) { + return new Proxy( + {}, + { + get: (_, key) => { + return storeX[`${GETTER_PREFIX}${key as string}`]; + }, + } + ); +} diff --git a/packages/inula-adapter/pinia-adapter/tests/actions.test.ts b/packages/inula-adapter/tests/pinia/pinia.actions.test.ts similarity index 63% rename from packages/inula-adapter/pinia-adapter/tests/actions.test.ts rename to packages/inula-adapter/tests/pinia/pinia.actions.test.ts index e2ab1278..a7982cc1 100644 --- a/packages/inula-adapter/pinia-adapter/tests/actions.test.ts +++ b/packages/inula-adapter/tests/pinia/pinia.actions.test.ts @@ -12,80 +12,66 @@ * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. * See the Mulan PSL v2 for more details. */ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck For the compiled code. -import { describe, it, vi, expect } from 'vitest'; -import { defineStore } from '../src/pinia'; +import { describe, it, vi, expect, beforeEach } from 'vitest'; +import { defineStore } from '../../src/pinia/pinia'; -describe('pinia state', () => { - const useStore = () => { - return defineStore({ - id: 'main', - state: () => ({ - a: true, - nested: { - foo: 'foo', - a: { b: 'string' }, - }, - }), - getters: { - nonA(): boolean { - return !this.a; - }, - otherComputed() { - return this.nonA; - }, +let id = 0; +function createStore() { + return defineStore({ + id: String(id++), + state: () => ({ + a: true, + nested: { + foo: 'foo', + a: { b: 'string' }, }, - actions: { - async getNonA() { - return this.nonA; - }, - simple() { - this.toggle(); - return 'simple'; - }, - - toggle() { - return (this.a = !this.a); - }, - - setFoo(foo: string) { - this.nested = { foo }; - }, - - combined() { - this.toggle(); - this.setFoo('bar'); - }, - - throws() { - throw new Error('fail'); - }, - - async rejects() { - throw 'fail'; - }, + }), + getters: { + nonA(): boolean { + return !this.a; }, - })(); - }; - - const useB = defineStore({ - id: 'B', - state: () => ({ b: 'b' }), - }); - - const useA = defineStore({ - id: 'A', - state: () => ({ a: 'a' }), - actions: { - swap() { - const bStore = useB(); - const b = bStore.$state.b; - bStore.$state.b = this.$state.a; - this.$state.a = b; + otherComputed() { + return this.nonA; }, }, + actions: { + async getNonA() { + return this.nonA; + }, + simple() { + this.toggle(); + return 'simple'; + }, + + toggle() { + return (this.a = !this.a); + }, + + setFoo(foo: string) { + this.nested.foo = foo; + }, + + combined() { + this.toggle(); + this.setFoo('bar'); + }, + + throws() { + throw new Error('fail'); + }, + + async rejects() { + throw 'fail'; + }, + }, + }); +} +describe('pinia state', () => { + let useStore = createStore(); + + beforeEach(() => { + useStore = createStore(); }); it('can use the store as this', () => { @@ -144,7 +130,6 @@ describe('pinia state', () => { // override the function like devtools do expect( { - $id: store.$id, simple, // otherwise it would fail toggle() {}, diff --git a/packages/inula-adapter/tests/pinia/pinia.getters.test.ts b/packages/inula-adapter/tests/pinia/pinia.getters.test.ts new file mode 100644 index 00000000..1fc6db61 --- /dev/null +++ b/packages/inula-adapter/tests/pinia/pinia.getters.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { defineStore } from '../../src/pinia/pinia'; + +let id = 0; +function createStore() { + return defineStore({ + id: String(id++), + state: () => ({ + name: 'Eduardo', + }), + getters: { + upperCaseName(store) { + return store.name.toUpperCase(); + }, + doubleName(): string { + return this.upperCaseName; + }, + composed(): string { + return this.upperCaseName + ': ok'; + }, + arrowUpper: state => { + // @ts-expect-error + state.nope; + return state.name.toUpperCase(); + }, + }, + actions: { + o() { + this.arrowUpper.toUpperCase(); + this.o().toUpperCase(); + return 'a string'; + }, + }, + }); +} + +describe('pinia getters', () => { + let useStore = createStore(); + let useB; + let useA; + beforeEach(() => { + useStore = createStore(); + + useB = defineStore({ + id: 'B', + state: () => ({ b: 'b' }), + }); + }); + + it('adds getters to the store', () => { + const store = useStore(); + expect(store.upperCaseName).toBe('EDUARDO'); + + // @ts-expect-error + store.nope; + + store.name = 'Ed'; + expect(store.upperCaseName).toBe('ED'); + }); + + it('updates the value', () => { + const store = useStore(); + store.name = 'Ed'; + expect(store.upperCaseName).toBe('ED'); + }); + + it('can use other getters', () => { + const store = useStore(); + expect(store.composed).toBe('EDUARDO: ok'); + store.name = 'Ed'; + expect(store.composed).toBe('ED: ok'); + }); + + it('keeps getters reactive when hydrating', () => { + const store = useStore(); + store.name = 'Jack'; + expect(store.name).toBe('Jack'); + expect(store.upperCaseName).toBe('JACK'); + store.name = 'Ed'; + expect(store.upperCaseName).toBe('ED'); + }); +}); diff --git a/packages/inula-adapter/tests/pinia/pinia.state.test.ts b/packages/inula-adapter/tests/pinia/pinia.state.test.ts new file mode 100644 index 00000000..361d8f7d --- /dev/null +++ b/packages/inula-adapter/tests/pinia/pinia.state.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { beforeEach, describe, it, vi, expect } from 'vitest'; +import { createPinia, defineStore } from '../../src/pinia/pinia'; +import { vueReactive } from 'openinula'; + +const { watch, computed, ref, reactive } = vueReactive; + +let id = 0; +function createStore() { + return defineStore(String(id++), { + state: () => ({ + name: 'Eduardo', + counter: 0, + nested: { n: 0 }, + }), + actions: { + increment(state, amount) { + this.counter += amount; + }, + }, + getters: { + upperCased() { + return this.name.toUpperCase(); + }, + }, + }); +} +describe('pinia state', () => { + let useStore = createStore(); + + beforeEach(() => { + useStore = createStore(); + }); + + it('can directly access state at the store level', () => { + const store = useStore(); + expect(store.name).toBe('Eduardo'); + store.name = 'Ed'; + expect(store.name).toBe('Ed'); + }); + + it('state is reactive', () => { + const store = useStore(); + const upperCased = computed(() => store.name.toUpperCase()); + expect(upperCased.value).toBe('EDUARDO'); + store.name = 'Ed'; + expect(upperCased.value).toBe('ED'); + }); + + it('can be set on store', () => { + const pinia = createPinia(); + const store = useStore(pinia); + + store.name = 'a'; + + expect(store.name).toBe('a'); + expect(store.$state.name).toBe('a'); + }); + + it('can be set on store.$state', () => { + const pinia = createPinia(); + const store = useStore(pinia); + + store.$state.name = 'a'; + + expect(store.name).toBe('a'); + expect(store.$state.name).toBe('a'); + }); + + it('can be nested set on store', () => { + const pinia = createPinia(); + const store = useStore(pinia); + + store.nested.n = 3; + + expect(store.nested.n).toBe(3); + expect(store.$state.nested.n).toBe(3); + }); + + it('can be nested set on store.$state', () => { + const pinia = createPinia(); + const store = useStore(pinia); + + store.$state.nested.n = 3; + + expect(store.nested.n).toBe(3); + expect(store.$state.nested.n).toBe(3); + }); + + it('state can be watched', async () => { + const store = useStore(); + const spy = vi.fn(); + watch(() => store.name, spy); + expect(spy).not.toHaveBeenCalled(); + store.name = 'Ed'; + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('state can be watched when a ref is given', async () => { + const store = useStore(); + const spy = vi.fn(); + watch(() => store.name, spy); + expect(spy).not.toHaveBeenCalled(); + const nameRef = ref('Ed'); + // @ts-expect-error + store.$state.name = nameRef; + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('can be given a ref', () => { + const pinia = createPinia(); + const store = useStore(pinia); + + // @ts-expect-error + store.$state.name = ref('Ed'); + + expect(store.name).toBe('Ed'); + expect(store.$state.name).toBe('Ed'); + + store.name = 'Other'; + expect(store.name).toBe('Other'); + expect(store.$state.name).toBe('Other'); + }); +}); diff --git a/packages/inula-adapter/tests/pinia/pinia.store.test.ts b/packages/inula-adapter/tests/pinia/pinia.store.test.ts new file mode 100644 index 00000000..c89910ff --- /dev/null +++ b/packages/inula-adapter/tests/pinia/pinia.store.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { beforeEach, describe, it, vi, expect } from 'vitest'; +import { defineStore } from '../../src/pinia/pinia'; +import { vueReactive } from 'openinula'; + +const { watch } = vueReactive; + +let id = 0; +function createStore() { + return defineStore({ + id: String(id++), + state: () => ({ + a: true, + nested: { + foo: 'foo', + a: { b: 'string' }, + }, + }), + }); +} + +describe('pinia state', () => { + let useStore = createStore(); + + beforeEach(() => { + useStore = createStore(); + }); + + it('reuses a store', () => { + const useStore = defineStore({ id: String(id++) }); + expect(useStore()).toBe(useStore()); + }); + + it('works with id as first argument', () => { + const useStore = defineStore(String(id++), { + state: () => ({ + a: true, + nested: { + foo: 'foo', + a: { b: 'string' }, + }, + }), + }); + expect(useStore()).toBe(useStore()); + const useStoreEmpty = defineStore(String(id++), {}); + expect(useStoreEmpty()).toBe(useStoreEmpty()); + }); + + it('sets the initial state', () => { + const store = useStore(); + expect(store.$state).toEqual({ + a: true, + nested: { + foo: 'foo', + a: { b: 'string' }, + }, + }); + }); + + it.skip('can replace its state', () => { + const store = useStore(); + const spy = vi.fn(); + watch(() => store.a, spy); + expect(store.a).toBe(true); + + expect(spy).toHaveBeenCalledTimes(0); + store.$state = { + a: false, + nested: { + foo: 'bar', + a: { + b: 'hey', + }, + }, + }; + expect(spy).toHaveBeenCalledTimes(1); + + expect(store.$state).toEqual({ + a: false, + nested: { + foo: 'bar', + a: { b: 'hey' }, + }, + }); + }); + + it('can be $unsubscribe', () => { + const useStore = defineStore({ + id: 'main', + state: () => ({ n: 0 }), + }); + + const store = useStore(); + const spy = vi.fn(); + + store.$subscribe(spy); + store.$state.n++; + expect(spy).toHaveBeenCalledTimes(1); + + expect(useStore()).toBe(store); + store.$unsubscribe(spy); + store.$state.n++; + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/inula-adapter/tests/pinia/pinia.storeSetup.test.ts b/packages/inula-adapter/tests/pinia/pinia.storeSetup.test.ts new file mode 100644 index 00000000..1e86c136 --- /dev/null +++ b/packages/inula-adapter/tests/pinia/pinia.storeSetup.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { beforeEach, describe, it, vi, expect } from 'vitest'; +import { defineStore } from '../../src/pinia/pinia'; +import { vueReactive } from 'openinula'; + +const { ref, watch, computed } = vueReactive; + +function expectType(_value: T): void {} +describe('store with setup syntax', () => { + function mainFn() { + const name = ref('Eduardo'); + const counter = ref(0); + + function increment(amount = 1) { + counter.value += amount; + } + + const double = computed(() => counter.value * 2); + + return { name, counter, increment, double }; + } + + let id = 0; + function createStore() { + return defineStore(String(id++), mainFn); + } + + let useStore = createStore(); + + beforeEach(() => { + useStore = createStore(); + }); + + it('should extract the $state', () => { + const store = useStore(); + expectType<{ name: string; counter: number }>(store.$state); + expect(store.$state).toEqual({ name: 'Eduardo', counter: 0 }); + expect(store.name).toBe('Eduardo'); + expect(store.counter).toBe(0); + expect(store.double).toBe(0); + store.increment(); + expect(store.counter).toBe(1); + expect(store.double).toBe(2); + expect(store.$state).toEqual({ name: 'Eduardo', counter: 1 }); + expect(store.$state).not.toHaveProperty('double'); + expect(store.$state).not.toHaveProperty('increment'); + }); + + it('can store a function', () => { + const store = defineStore(String(id++), () => { + const fn = ref(() => {}); + + function action() {} + + return { fn, action }; + })(); + expectType<{ fn: () => void }>(store.$state); + expect(store.$state).toEqual({ fn: expect.any(Function) }); + expect(store.fn).toEqual(expect.any(Function)); + store.action(); + }); + + it('can directly access state at the store level', () => { + const store = useStore(); + + expect(store.name).toBe('Eduardo'); + store.name = 'Ed'; + expect(store.name).toBe('Ed'); + }); + + it('state is reactive', () => { + const store = useStore(); + const upperCased = computed(() => store.name.toUpperCase()); + expect(upperCased.value).toBe('EDUARDO'); + store.name = 'Ed'; + expect(upperCased.value).toBe('ED'); + }); + + it('state can be watched', async () => { + const store = useStore(); + const spy = vi.fn(); + watch(() => store.name, spy); + expect(spy).not.toHaveBeenCalled(); + store.name = 'Ed'; + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('state refs can be watched', async () => { + const store = useStore(); + const spy = vi.fn(); + watch(() => store.name, spy); + expect(spy).not.toHaveBeenCalled(); + const nameRef = ref('Ed'); + store.name = nameRef; + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('unwraps refs', () => { + const name = ref('Eduardo'); + const counter = ref(0); + const double = computed(() => { + return counter.value * 2; + }); + + // const pinia = createPinia(); + // setActivePinia(pinia); + const useStore = defineStore({ + id: String(id++), + state: () => ({ + name, + counter, + double, + }), + }); + + const store = useStore(); + + expect(store.name).toBe('Eduardo'); + expect(store.$state.name).toBe('Eduardo'); + expect(store.$state).toEqual({ + name: 'Eduardo', + counter: 0, + double: 0, + }); + + name.value = 'Ed'; + expect(store.name).toBe('Ed'); + expect(store.$state.name).toBe('Ed'); + + store.$state.name = 'Edu'; + expect(store.name).toBe('Edu'); + + // store.$patch({ counter: 2 }); + store.counter = 2; + expect(store.counter).toBe(2); + expect(counter.value).toBe(2); + }); +}); diff --git a/packages/inula-adapter/tests/pinia/pinia.storeToRefs.test.ts b/packages/inula-adapter/tests/pinia/pinia.storeToRefs.test.ts new file mode 100644 index 00000000..e2c450c3 --- /dev/null +++ b/packages/inula-adapter/tests/pinia/pinia.storeToRefs.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { beforeEach, describe, it, vi, expect } from 'vitest'; +import { defineStore, storeToRefs } from '../../src/pinia/pinia'; +import { vueReactive } from 'openinula'; + +const { ref, computed, reactive } = vueReactive; + +let id = 0; +describe('storeToRefs', () => { + beforeEach(() => {}); + + function objectOfRefs>(o: O) { + return Object.keys(o).reduce((newO, key) => { + // @ts-expect-error: we only need to match + newO[key] = expect.objectContaining({ value: o[key] }); + return newO; + }, {}); + } + + it('empty state', () => { + expect(storeToRefs(defineStore(String(id++), {})())).toEqual({}); + expect(storeToRefs(defineStore({ id: String(id++) })())).toEqual({}); + }); + + it('plain values', () => { + const store = defineStore(String(id++), { + state: () => ({ a: null as null | undefined, b: false, c: 1, d: 'd' }), + })(); + + const { a, b, c, d } = storeToRefs(store); + + expect(a.value).toBe(null); + expect(b.value).toBe(false); + expect(c.value).toBe(1); + expect(d.value).toBe('d'); + + a.value = undefined; + expect(a.value).toBe(undefined); + + b.value = true; + expect(b.value).toBe(true); + + c.value = 2; + expect(c.value).toBe(2); + + d.value = 'e'; + expect(d.value).toBe('e'); + }); + + it('setup store', () => { + const store = defineStore(String(id++), () => { + return { + a: ref(null), + b: ref(false), + c: ref(1), + d: ref('d'), + r: reactive({ n: 1 }), + }; + })(); + + const { a, b, c, d, r } = storeToRefs(store); + + expect(a.value).toBe(null); + expect(b.value).toBe(false); + expect(c.value).toBe(1); + expect(d.value).toBe('d'); + expect(r.value).toEqual({ n: 1 }); + + a.value = undefined; + expect(a.value).toBe(undefined); + + b.value = true; + expect(b.value).toBe(true); + + c.value = 2; + expect(c.value).toBe(2); + + d.value = 'e'; + expect(d.value).toBe('e'); + + r.value.n++; + expect(r.value).toEqual({ n: 2 }); + expect(store.r).toEqual({ n: 2 }); + store.r.n++; + expect(r.value).toEqual({ n: 3 }); + expect(store.r).toEqual({ n: 3 }); + }); + + it('empty getters', () => { + expect( + storeToRefs( + defineStore(String(id++), { + state: () => ({ n: 0 }), + })() + ) + ).toEqual(objectOfRefs({ n: 0 })); + expect( + storeToRefs( + defineStore(String(id++), () => { + return { n: ref(0) }; + })() + ) + ).toEqual(objectOfRefs({ n: 0 })); + }); + + it('contains getters', () => { + const refs = storeToRefs( + defineStore(String(id++), { + state: () => ({ n: 1 }), + getters: { + double: state => state.n * 2, + }, + })() + ); + expect(refs).toEqual(objectOfRefs({ n: 1, double: 2 })); + + const setupRefs = storeToRefs( + defineStore(String(id++), () => { + const n = ref(1); + const double = computed(() => n.value * 2); + return { n, double }; + })() + ); + + expect(setupRefs).toEqual(objectOfRefs({ n: 1, double: 2 })); + }); +}); diff --git a/packages/inula-adapter/vue-adapter/tests/liftcycle.test.tsx b/packages/inula-adapter/tests/vue/liftcycle.test.tsx similarity index 99% rename from packages/inula-adapter/vue-adapter/tests/liftcycle.test.tsx rename to packages/inula-adapter/tests/vue/liftcycle.test.tsx index 2fd1969b..96406228 100644 --- a/packages/inula-adapter/vue-adapter/tests/liftcycle.test.tsx +++ b/packages/inula-adapter/tests/vue/liftcycle.test.tsx @@ -17,7 +17,7 @@ import { describe, it, vi, expect } from 'vitest'; import { render, act, useState } from 'openinula'; -import { onBeforeUnmount, onUnmounted, onMounted, onBeforeMount, onUpdated } from '../src'; +import { onBeforeUnmount, onUnmounted, onMounted, onBeforeMount, onUpdated } from '../../src/vue/lifecycle'; describe('lifecycle', () => { it('should call the onBeforeMount', () => { diff --git a/packages/inula-adapter/tests/vuex/vuex.modules.test.ts b/packages/inula-adapter/tests/vuex/vuex.modules.test.ts new file mode 100644 index 00000000..93841ccd --- /dev/null +++ b/packages/inula-adapter/tests/vuex/vuex.modules.test.ts @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createStore } from '../../src/vuex/vuex'; + +const TEST = 'TEST'; + +describe('Modules', () => { + it('dynamic module registration', () => { + const store = createStore({ + modules: { + foo: { + state: { bar: 1 }, + mutations: { inc: state => state.bar++ }, + actions: { inc: ({ commit }) => commit('inc') }, + getters: { fooBar: state => state.bar }, + }, + one: { + state: { a: 0 }, + mutations: { + aaa(state, n) { + state.a += n; + }, + }, + }, + }, + }); + + store.registerModule('hi', { + state: { a: 1 }, + mutations: { inc: state => state.a++ }, + actions: { incHi: ({ commit }) => commit('inc') }, + getters: { ga: state => state.a }, + }); + + // expect(store._mutations.inc.length).toBe(2); + expect(store.state.hi.a).toBe(1); + expect(store.getters.ga).toBe(1); + + // assert initial modules work as expected after dynamic registration + expect(store.state.foo.bar).toBe(1); + expect(store.getters.fooBar).toBe(1); + + // test dispatching actions defined in dynamic module + store.dispatch('incHi'); + expect(store.state.hi.a).toBe(2); + expect(store.getters.ga).toBe(2); + + expect(store.state.foo.bar).toBe(1); + expect(store.getters.fooBar).toBe(1); + + // unregister + store.unregisterModule('hi'); + expect(store.state.hi).toBeUndefined(); + expect(store.getters.ga).toBeUndefined(); + + // assert initial modules still work as expected after unregister + store.dispatch('inc'); + expect(store.state.foo.bar).toBe(2); + expect(store.getters.fooBar).toBe(2); + }); + + it('dynamic module registration with namespace inheritance', () => { + const store = createStore({ + modules: { + a: { + namespaced: true, + }, + }, + }); + const actionSpy = vi.fn(); + const mutationSpy = vi.fn(); + store.registerModule('b', { + state: { value: 1 }, + getters: { foo: state => state.value }, + actions: { foo: actionSpy }, + mutations: { foo: mutationSpy }, + }); + + expect(store.state.b.value).toBe(1); + expect(store.getters['foo']).toBe(1); + + store.dispatch('foo'); + expect(actionSpy).toHaveBeenCalled(); + + store.commit('foo'); + expect(mutationSpy).toHaveBeenCalled(); + }); + + it('dynamic module existance test', () => { + const store = createStore({}); + + store.registerModule('bonjour', {}); + + expect(store.hasModule('bonjour')).toBe(true); + store.unregisterModule('bonjour'); + expect(store.hasModule('bonjour')).toBe(false); + }); + + it('should keep getters when component gets destroyed', async () => { + const store = createStore({}); + + const spy = vi.fn(); + + const moduleA = { + namespaced: true, + state: () => ({ value: 1 }), + getters: { + getState(state) { + spy(); + return state.value; + }, + }, + mutations: { + increment: state => { + state.value++; + }, + }, + }; + + store.registerModule('moduleA', moduleA); + + expect(store.getters['moduleA/getState']).toBe(1); + expect(spy).toHaveBeenCalledTimes(1); + + store.commit('moduleA/increment'); + + expect(store.getters['moduleA/getState']).toBe(2); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should not fire an unrelated watcher', () => { + const spy = vi.fn(); + const store = createStore({ + modules: { + a: { + state: { value: 1 }, + }, + b: {}, + }, + }); + + store.watch(state => state.a, spy); + store.registerModule('c', { + state: { value: 2 }, + }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('state as function (multiple module in same store)', () => { + const store = createStore({ + modules: { + one: { + state: { a: 0 }, + mutations: { + [TEST](state, n) { + state.a += n; + }, + }, + }, + two: { + state() { + return { a: 0 }; + }, + mutations: { + [TEST](state, n) { + state.a += n; + }, + }, + }, + }, + }); + + expect(store.state.one.a).toBe(0); + expect(store.state.two.a).toBe(0); + + store.commit(TEST, 1); + expect(store.state.one.a).toBe(1); + expect(store.state.two.a).toBe(1); + }); + + it('state as function (same module in multiple stores)', () => { + const storeA = createStore({ + modules: { + foo: { + state() { + return { a: 0 }; + }, + mutations: { + [TEST](state, n) { + state.a += n; + }, + }, + }, + }, + }); + + const storeB = createStore({ + modules: { + bar: { + state() { + return { a: 0 }; + }, + mutations: { + [TEST](state, n) { + state.a += n; + }, + }, + }, + }, + }); + + expect(storeA.state.foo.a).toBe(0); + expect(storeB.state.bar.a).toBe(0); + + storeA.commit(TEST, 1); + expect(storeA.state.foo.a).toBe(1); + expect(storeB.state.bar.a).toBe(0); + + storeB.commit(TEST, 2); + expect(storeA.state.foo.a).toBe(1); + expect(storeB.state.bar.a).toBe(2); + }); + + it('module: mutation', function () { + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST](state, n) { + state.a += n; + }, + }, + modules: { + nested: { + state: { a: 2 }, + mutations: { + [TEST](state, n) { + state.a += n; + }, + }, + }, + four: { + state: { a: 6 }, + mutations: { + [TEST](state, n) { + state.a += n; + }, + }, + }, + }, + }); + store.commit(TEST, 1); + expect(store.state.a).toBe(2); + expect(store.state.nested.a).toBe(3); + expect(store.state.four.a).toBe(7); + }); + + it('module: action', function () { + let calls = 0; + + const store = createStore({ + state: { + a: 1, + }, + actions: { + [TEST]({ state, rootState }) { + calls++; + expect(state.a).toBe(1); + expect(rootState).toBe(store.state); + }, + }, + modules: { + nested: { + state: { a: 2 }, + actions: { + [TEST]({ state, rootState }) { + calls++; + expect(state.a).toBe(2); + expect(rootState).toBe(store.state); + }, + }, + }, + four: { + state: { a: 6 }, + actions: { + [TEST]({ state, rootState }) { + calls++; + expect(state.a).toBe(6); + expect(rootState).toBe(store.state); + }, + }, + }, + }, + }); + store.dispatch(TEST); + expect(calls).toBe(3); + }); + + it('module: getters', function () { + const store = createStore({ + state: { + a: 1, + }, + getters: { + constant: () => 0, + [`getter1`]: (state, getters, rootState) => { + expect(getters.constant).toBe(0); + expect(rootState.a).toBe(store.state.a); + return state.a; + }, + }, + modules: { + nested: { + state: { a: 2 }, + getters: { + [`getter2`]: (state, getters, rootState) => { + expect(getters.constant).toBe(0); + expect(rootState.a).toBe(store.state.a); + return state.a; + }, + }, + }, + four: { + state: { a: 6 }, + getters: { + [`getter6`]: (state, getters, rootState) => { + expect(getters.constant).toBe(0); + expect(rootState.a).toBe(store.state.a); + return state.a; + }, + }, + }, + }, + }); + [1, 2, 6].forEach(n => { + expect(store.getters[`getter${n}`]).toBe(n); + }); + }); + + it('module: namespace', () => { + const actionSpy = vi.fn(); + const mutationSpy = vi.fn(); + + const store = createStore({ + modules: { + a: { + namespaced: true, + state: { + a: 1, + }, + getters: { + b: () => 2, + }, + actions: { + [TEST]: actionSpy, + }, + mutations: { + [TEST]: mutationSpy, + }, + }, + }, + }); + + expect(store.state.a.a).toBe(1); + expect(store.getters['a/b']).toBe(2); + store.dispatch('a/' + TEST); + expect(actionSpy).toHaveBeenCalled(); + store.commit('a/' + TEST); + expect(mutationSpy).toHaveBeenCalled(); + }); + + it('module: getters are namespaced in namespaced module', () => { + const store = createStore({ + state: { value: 'root' }, + getters: { + foo: state => state.value, + }, + modules: { + a: { + namespaced: true, + state: { value: 'module' }, + getters: { + foo: state => { + return state.value; + }, + bar: (state, getters) => { + return getters.foo; + }, + baz: (state, getters, rootState, rootGetters) => rootGetters.foo, + }, + }, + }, + }); + + expect(store.getters['a/foo']).toBe('module'); + expect(store.getters['a/bar']).toBe('module'); + expect(store.getters['a/baz']).toBe('root'); + }); + + it('module: action context is namespaced in namespaced module', () => { + const rootActionSpy = vi.fn(); + const rootMutationSpy = vi.fn(); + const moduleActionSpy = vi.fn(); + const moduleMutationSpy = vi.fn(); + + const store = createStore({ + state: { value: 'root' }, + getters: { foo: state => state.value }, + actions: { foo: rootActionSpy }, + mutations: { foo: rootMutationSpy }, + modules: { + a: { + namespaced: true, + state: { value: 'module' }, + getters: { foo: state => state.value }, + actions: { + foo: moduleActionSpy, + test({ dispatch, commit, getters, rootGetters }) { + expect(getters.foo).toBe('module'); + expect(rootGetters.foo).toBe('root'); + + dispatch('foo'); + expect(moduleActionSpy).toHaveBeenCalledTimes(1); + dispatch('foo', null, { root: true }); + expect(rootActionSpy).toHaveBeenCalledTimes(1); + + commit('foo'); + expect(moduleMutationSpy).toHaveBeenCalledTimes(1); + commit('foo', null, { root: true }); + expect(rootMutationSpy).toHaveBeenCalledTimes(1); + }, + }, + mutations: { foo: moduleMutationSpy }, + }, + }, + }); + + store.dispatch('a/test'); + }); + + it('dispatching multiple actions in different modules', () => { + const store = createStore({ + modules: { + a: { + actions: { + [TEST]() { + return 1; + }, + }, + }, + b: { + actions: { + [TEST]() { + return new Promise(r => r(2)); + }, + }, + }, + }, + }); + store.dispatch(TEST).then(res => { + expect(res[0]).toBe(1); + expect(res[1]).toBe(2); + }); + }); +}); diff --git a/packages/inula-adapter/tests/vuex/vuex.store.test.ts b/packages/inula-adapter/tests/vuex/vuex.store.test.ts new file mode 100644 index 00000000..cf831418 --- /dev/null +++ b/packages/inula-adapter/tests/vuex/vuex.store.test.ts @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createStore } from '../../src/vuex/vuex'; + +const TEST_M = 'TEST_M'; +const TEST_A = 'TEST_A'; + +describe('vuex Store', () => { + it('committing mutations', () => { + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST_M](state, n) { + state.a += n; + }, + }, + }); + store.commit(TEST_M, 2); + expect(store.state.a).toBe(3); + }); + + it('committing with object style', () => { + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST_M](state, payload) { + state.a += payload.amount; + }, + }, + }); + store.commit({ + type: TEST_M, + amount: 2, + }); + expect(store.state.a).toBe(3); + }); + + it('dispatching actions, sync', () => { + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST_M](state, n) { + state.a += n; + }, + }, + actions: { + [TEST_A]({ commit }, n) { + commit(TEST_M, n); + }, + }, + }); + store.dispatch(TEST_A, 2); + expect(store.state.a).toBe(3); + }); + + it('dispatching with object style', () => { + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST_M](state, n) { + state.a += n; + }, + }, + actions: { + [TEST_A]({ commit }, payload) { + commit(TEST_M, payload.amount); + }, + }, + }); + store.dispatch({ + type: TEST_A, + amount: 2, + }); + expect(store.state.a).toBe(3); + }); + + it('dispatching actions, with returned Promise', () => { + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST_M](state, n) { + state.a += n; + }, + }, + actions: { + [TEST_A]({ commit }, n) { + return new Promise(resolve => { + setTimeout(() => { + commit(TEST_M, n); + resolve(''); + }, 0); + }); + }, + }, + }); + expect(store.state.a).toBe(1); + store.dispatch(TEST_A, 2).then(() => { + expect(store.state.a).toBe(3); + }); + }); + + it('composing actions with async/await', () => { + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST_M](state, n) { + state.a += n; + }, + }, + actions: { + [TEST_A]({ commit }, n) { + return new Promise(resolve => { + setTimeout(() => { + commit(TEST_M, n); + resolve(''); + }, 0); + }); + }, + two: async ({ commit, dispatch }, n) => { + await dispatch(TEST_A, 1); + expect(store.state.a).toBe(2); + commit(TEST_M, n); + }, + }, + }); + expect(store.state.a).toBe(1); + store.dispatch('two', 3).then(() => { + expect(store.state.a).toBe(5); + }); + }); + + it('detecting action Promise errors', () => { + const store = createStore({ + actions: { + [TEST_A]() { + return new Promise((resolve, reject) => { + reject('no'); + }); + }, + }, + }); + const thenSpy = vi.fn(); + store + .dispatch(TEST_A) + .then(thenSpy) + .catch((err: string) => { + expect(thenSpy).not.toHaveBeenCalled(); + expect(err).toBe('no'); + }); + }); + + it('getters', () => { + const store = createStore({ + state: { + a: 0, + }, + getters: { + state: state => (state.a > 0 ? 'hasAny' : 'none'), + }, + mutations: { + [TEST_M](state, n) { + state.a += n; + }, + }, + actions: { + check({ getters }, value) { + // check for exposing getters into actions + expect(getters.state).toBe(value); + }, + }, + }); + expect(store.getters.state).toBe('none'); + store.dispatch('check', 'none'); + + store.commit(TEST_M, 1); + + expect(store.getters.state).toBe('hasAny'); + store.dispatch('check', 'hasAny'); + }); + + it('should accept state as function', () => { + const store = createStore({ + state: () => ({ + a: 1, + }), + mutations: { + [TEST_M](state, n) { + state.a += n; + }, + }, + }); + expect(store.state.a).toBe(1); + store.commit(TEST_M, 2); + expect(store.state.a).toBe(3); + }); + + it('subscribe: should handle subscriptions / unsubscriptions', () => { + const subscribeSpy = vi.fn(); + const secondSubscribeSpy = vi.fn(); + const testPayload = 2; + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST_M](state) { + state.a++; + }, + }, + }); + + const unsubscribe = store.subscribe(subscribeSpy); + store.subscribe(secondSubscribeSpy); + store.commit(TEST_M, testPayload); + unsubscribe(); + store.commit(TEST_M, testPayload); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(secondSubscribeSpy).toHaveBeenCalledTimes(2); + }); + + it('subscribe: should handle subscriptions with synchronous unsubscriptions', () => { + const subscribeSpy = vi.fn(); + const testPayload = 2; + const store = createStore({ + state: { + a: 1, + }, + mutations: { + [TEST_M](state) { + state.a++; + }, + }, + }); + + const unsubscribe = store.subscribe(() => unsubscribe()); + store.subscribe(subscribeSpy); + store.commit(TEST_M, testPayload); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + }); + + it('subscribeAction: should handle subscriptions with synchronous unsubscriptions', () => { + const subscribeSpy = vi.fn(); + const testPayload = 2; + const store = createStore({ + state: { + a: 1, + }, + actions: { + [TEST_A]({ state }) { + state.a++; + }, + }, + }); + + const unsubscribe = store.subscribeAction(() => unsubscribe()); + store.subscribeAction(subscribeSpy); + store.dispatch(TEST_A, testPayload); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + }); + + it('watch: with resetting vm', () => { + const store = createStore({ + state: { + count: 0, + }, + mutations: { + [TEST_M]: state => state.count++, + }, + }); + + const spy = vi.fn(); + store.watch(state => state.count, spy); + + store.commit(TEST_M); + expect(store.state.count).toBe(1); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("watch: getter function has access to store's getters object", () => { + const store = createStore({ + state: { + count: 0, + }, + mutations: { + [TEST_M]: state => state.count++, + }, + getters: { + getCount: state => state.count, + }, + }); + + const getter = function getter(state: any) { + return state.count; + }; + const spy = vi.spyOn({ getter }, 'getter'); + const spyCb = vi.fn(); + + store.watch(spy as any, spyCb); + + store.commit(TEST_M); + expect(store.state.count).toBe(1); + + expect(spy).toHaveBeenCalledWith(store.state, store.getters); + }); +}); diff --git a/packages/inula-adapter/vue-adapter/tsconfig.json b/packages/inula-adapter/tsconfig.json similarity index 100% rename from packages/inula-adapter/vue-adapter/tsconfig.json rename to packages/inula-adapter/tsconfig.json diff --git a/packages/inula-adapter/tsconfig.pinia.json b/packages/inula-adapter/tsconfig.pinia.json new file mode 100644 index 00000000..8164046f --- /dev/null +++ b/packages/inula-adapter/tsconfig.pinia.json @@ -0,0 +1,10 @@ +{ + "files": [ + "src/pinia/index.ts" + ], + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "declarationDir": "./build/pinia/@types" + }, +} diff --git a/packages/inula-adapter/vue-adapter/tsconfig.build.json b/packages/inula-adapter/tsconfig.vue.json similarity index 63% rename from packages/inula-adapter/vue-adapter/tsconfig.build.json rename to packages/inula-adapter/tsconfig.vue.json index 07fb391d..aa7ac170 100644 --- a/packages/inula-adapter/vue-adapter/tsconfig.build.json +++ b/packages/inula-adapter/tsconfig.vue.json @@ -1,10 +1,10 @@ { "files": [ - "src/index.ts" + "src/vue/index.ts" ], "extends": "./tsconfig.json", "compilerOptions": { "emitDeclarationOnly": true, - "declarationDir": "./build/@types" + "declarationDir": "./build/vue/@types" }, } diff --git a/packages/inula-adapter/tsconfig.vuex.json b/packages/inula-adapter/tsconfig.vuex.json new file mode 100644 index 00000000..bf7351f2 --- /dev/null +++ b/packages/inula-adapter/tsconfig.vuex.json @@ -0,0 +1,10 @@ +{ + "files": [ + "src/vuex/index.ts" + ], + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "declarationDir": "./build/vuex/@types" + }, +} diff --git a/packages/inula-adapter/vue-adapter/vitest.config.ts b/packages/inula-adapter/vitest.config.ts similarity index 92% rename from packages/inula-adapter/vue-adapter/vitest.config.ts rename to packages/inula-adapter/vitest.config.ts index c68c21d8..01da6bc1 100644 --- a/packages/inula-adapter/vue-adapter/vitest.config.ts +++ b/packages/inula-adapter/vitest.config.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Huawei Technologies Co.,Ltd. + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. * * openInula is licensed under Mulan PSL v2. * You can use this software according to the terms and conditions of the Mulan PSL v2. @@ -15,7 +15,7 @@ import react from '@vitejs/plugin-react'; -let alias = { +const alias = { react: 'openinula', // 新增 'react-dom': 'openinula', // 新增 'react/jsx-dev-runtime': 'openinula/jsx-dev-runtime', diff --git a/packages/inula-adapter/vue-adapter/README.md b/packages/inula-adapter/vue-adapter/README.md deleted file mode 100644 index bfbeeae8..00000000 --- a/packages/inula-adapter/vue-adapter/README.md +++ /dev/null @@ -1,2 +0,0 @@ -## 适配Vue - diff --git a/packages/inula/__tests__/HorizonXTest/ReactiveTest/toRef.test.tsx b/packages/inula/__tests__/HorizonXTest/ReactiveTest/toRef.test.tsx new file mode 100644 index 00000000..a9c293fa --- /dev/null +++ b/packages/inula/__tests__/HorizonXTest/ReactiveTest/toRef.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { vueReactive } from '../../../src'; + +const { ref, isRef, reactive, unref, toRef, toRefs, isReadonly, watchEffect } = vueReactive; + +describe('test toRef, toRefs', () => { + it('toRef', () => { + const a = reactive({ + x: 1, + }); + const x = toRef(a, 'x'); + expect(isRef(x)).toBe(true); + expect(x.value).toBe(1); + + // source -> proxy + a.x = 2; + expect(x.value).toBe(2); + + // proxy -> source + x.value = 3; + expect(a.x).toBe(3); + + // reactivity + let dummyX; + watchEffect(() => { + dummyX = x.value; + }); + expect(dummyX).toBe(x.value); + + // mutating source should trigger effect using the proxy refs + a.x = 4; + expect(dummyX).toBe(4); + + // should keep ref + const r = { x: ref(1) }; + expect(toRef(r, 'x')).toBe(r.x); + }); + + it('toRef on array', () => { + const a = reactive(['a', 'b']); + const r = toRef(a, 1); + expect(r.value).toBe('b'); + r.value = 'c'; + expect(r.value).toBe('c'); + expect(a[1]).toBe('c'); + }); + + it('toRef default value', () => { + const a: { x: number | undefined } = { x: undefined }; + const x = toRef(a, 'x', 1); + expect(x.value).toBe(1); + + a.x = 2; + expect(x.value).toBe(2); + + a.x = undefined; + expect(x.value).toBe(1); + }); + + it('toRef getter', () => { + const x = toRef(() => 1); + expect(x.value).toBe(1); + expect(isRef(x)).toBe(true); + expect(unref(x)).toBe(1); + //@ts-expect-error + expect(() => (x.value = 123)).toThrow(); + + expect(isReadonly(x)).toBe(true); + }); + + it('toRefs', () => { + const a = reactive({ + x: 1, + y: 2, + }); + + const { x, y } = toRefs(a); + + expect(isRef(x)).toBe(true); + expect(isRef(y)).toBe(true); + expect(x.value).toBe(1); + expect(y.value).toBe(2); + + // source -> proxy + a.x = 2; + a.y = 3; + expect(x.value).toBe(2); + expect(y.value).toBe(3); + + // proxy -> source + x.value = 3; + y.value = 4; + expect(a.x).toBe(3); + expect(a.y).toBe(4); + + // reactivity + let dummyX, dummyY; + watchEffect(() => { + dummyX = x.value; + dummyY = y.value; + }); + expect(dummyX).toBe(x.value); + expect(dummyY).toBe(y.value); + + // mutating source should trigger effect using the proxy refs + a.x = 4; + a.y = 5; + expect(dummyX).toBe(4); + expect(dummyY).toBe(5); + }); + + it('toRefs reactive array', () => { + const arr = reactive(['a', 'b', 'c']); + const refs = toRefs(arr); + + expect(Array.isArray(refs)).toBe(true); + + refs[0].value = '1'; + expect(arr[0]).toBe('1'); + + arr[1] = '2'; + expect(refs[1].value).toBe('2'); + }); +}); diff --git a/packages/inula/__tests__/HorizonXTest/StoreFunctionality/dollarAccess.test.tsx b/packages/inula/__tests__/HorizonXTest/StoreFunctionality/dollarAccess.test.tsx index 09a97ea8..fb74a5d5 100644 --- a/packages/inula/__tests__/HorizonXTest/StoreFunctionality/dollarAccess.test.tsx +++ b/packages/inula/__tests__/HorizonXTest/StoreFunctionality/dollarAccess.test.tsx @@ -42,7 +42,7 @@ describe('Dollar store access', () => { function App() { const logStore = useLogStore(); - return
{logStore.$c.length()}
; + return
{logStore.$c.length}
; } Inula.render(, container); @@ -64,7 +64,7 @@ describe('Dollar store access', () => { > add -

{logStore.$c.length()}

+

{logStore.$c.length}

); } diff --git a/packages/inula/package.json b/packages/inula/package.json index 6e5e1ea7..5828ed80 100644 --- a/packages/inula/package.json +++ b/packages/inula/package.json @@ -8,7 +8,7 @@ "homepage": "", "bugs": "", "license": "MulanPSL2", - "main": "./build/index.js", + "main": "./src/index.ts", "repository": {}, "engines": { "node": ">=0.10.0" diff --git a/packages/inula/src/index.ts b/packages/inula/src/index.ts index 5fe2c85c..4572f20c 100644 --- a/packages/inula/src/index.ts +++ b/packages/inula/src/index.ts @@ -57,7 +57,7 @@ import { } from './external/InulaIs'; import { createStore, useStore, clearStore } from './inulax/store/StoreHandler'; import { reactive, useReactive, toRaw } from './inulax/reactive/Reactive'; -import { ref, useReference, isRef, unref, shallowRef } from './inulax/reactive/Ref'; +import { ref, useReference, isRef, unref, shallowRef, toRef, toRefs } from './inulax/reactive/Ref'; import * as reduxAdapter from './inulax/adapters/redux'; import { watch, watchEffect, useWatch } from './inulax/reactive/Watch'; import { computed, useComputed } from './inulax/reactive/Computed'; @@ -73,7 +73,7 @@ import { } from './dom/DOMExternal'; import { syncUpdates as flushSync } from './renderer/TreeBuilder'; -import { isReactive, isShallow } from './inulax/CommonUtils'; +import { isReactive, isShallow, isReadonly } from './inulax/CommonUtils'; const vueReactive = { ref, @@ -81,10 +81,13 @@ const vueReactive = { isRef, unref, shallowRef, + toRef, + toRefs, reactive, useReactive, isReactive, isShallow, + isReadonly, computed, useComputed, watchEffect, @@ -208,5 +211,8 @@ export { export * from './types'; export * from './inulax/types/ReactiveTypes'; export * from './inulax/types/ProxyTypes'; +export * from './inulax/types/StoreTypes'; +export * from './inulax/types/StoreTypes'; +export { ComputedImpl } from './inulax/reactive/Computed'; export default Inula; diff --git a/packages/inula/src/inulax/CommonUtils.ts b/packages/inula/src/inulax/CommonUtils.ts index 956e6ed9..b91eb319 100644 --- a/packages/inula/src/inulax/CommonUtils.ts +++ b/packages/inula/src/inulax/CommonUtils.ts @@ -218,3 +218,7 @@ export function isShallow(value: unknown): boolean { export function isReactive(value: unknown) { return !!(value && !!value[KeyTypes.RAW_VALUE]); } + +export function isReadonly(value: unknown): boolean { + return !!(value && value[ReactiveFlags.IS_READONLY]); +} diff --git a/packages/inula/src/inulax/Constants.ts b/packages/inula/src/inulax/Constants.ts index 465c5349..039f5d0b 100644 --- a/packages/inula/src/inulax/Constants.ts +++ b/packages/inula/src/inulax/Constants.ts @@ -32,4 +32,6 @@ export enum KeyTypes { export enum ReactiveFlags { IS_SHALLOW = '_isShallow', + IS_READONLY = '_isReadonly', + IS_REF = '_isRef', } diff --git a/packages/inula/src/inulax/docs/reactive_feature.md b/packages/inula/src/inulax/docs/reactive_feature.md index 20bd99ea..306ac460 100644 --- a/packages/inula/src/inulax/docs/reactive_feature.md +++ b/packages/inula/src/inulax/docs/reactive_feature.md @@ -161,13 +161,6 @@ const c = computed(() => 1, { }); ``` -5. computed中不支持第二个参数debugOptions。 -```js -const c = computed(() => 1, { - onTrack, // 不支持 -}); -``` - 6. computed.effect.stop 改为 computed.stop。 ```js it('should no longer update when stopped', () => { diff --git a/packages/inula/src/inulax/proxy/ProxyHandler.ts b/packages/inula/src/inulax/proxy/ProxyHandler.ts index 5c0b2913..52c4f28f 100644 --- a/packages/inula/src/inulax/proxy/ProxyHandler.ts +++ b/packages/inula/src/inulax/proxy/ProxyHandler.ts @@ -34,7 +34,7 @@ export function getObserver(rawObj: any): IObserver { return rawObserverMap.get(rawObj) as IObserver; } -function setObserverKey(rawObj: any, observer: IObserver): void { +function setObserver(rawObj: any, observer: IObserver): void { rawObserverMap.set(rawObj, observer); } @@ -59,7 +59,7 @@ export function createProxy(rawObj: any, listener?: CurrentListener, isDeepProxy let observer = getObserver(rawObj); if (!observer) { observer = (isDeepProxy ? new Observer() : new HooklessObserver()) as IObserver; - setObserverKey(rawObj, observer); + setObserver(rawObj, observer); } deepProxyMap.set(rawObj, isDeepProxy); diff --git a/packages/inula/src/inulax/proxy/handlers/BaseObjectHandler.ts b/packages/inula/src/inulax/proxy/handlers/BaseObjectHandler.ts index f01bb729..c656dca2 100644 --- a/packages/inula/src/inulax/proxy/handlers/BaseObjectHandler.ts +++ b/packages/inula/src/inulax/proxy/handlers/BaseObjectHandler.ts @@ -83,10 +83,6 @@ export function baseGetFun | any[]>( return undefined; } - if (key === KeyTypes.VALUE) { - return receiver; - } - const observer = getObserver(rawObj); if (key === KeyTypes.WATCH) { diff --git a/packages/inula/src/inulax/reactive/Computed.ts b/packages/inula/src/inulax/reactive/Computed.ts index 7d6e719c..d47d4ff3 100644 --- a/packages/inula/src/inulax/reactive/Computed.ts +++ b/packages/inula/src/inulax/reactive/Computed.ts @@ -43,6 +43,7 @@ export class ComputedImpl { private readonly observer: Observer = new Observer(); readonly _isRef = true; + readonly _isReadonly = true; constructor(fn: ComputedFN) { this.fn = fn; diff --git a/packages/inula/src/inulax/reactive/Ref.ts b/packages/inula/src/inulax/reactive/Ref.ts index 00164fdd..f560076c 100644 --- a/packages/inula/src/inulax/reactive/Ref.ts +++ b/packages/inula/src/inulax/reactive/Ref.ts @@ -13,14 +13,12 @@ * See the Mulan PSL v2 for more details. */ -import { isObject, isSame, isShallow } from '../CommonUtils'; -import { reactive, toRaw } from './Reactive'; +import { isArray, isObject, isSame, isShallow } from '../CommonUtils'; +import { toRaw } from './Reactive'; import { Observer } from '../proxy/Observer'; -import { KeyTypes, OBSERVER_KEY } from '../Constants'; -import { MaybeRef, RefType, UnwrapRef } from '../types/ReactiveTypes'; +import { KeyTypes, OBSERVER_KEY, ReactiveFlags } from '../Constants'; +import { IfAny, MaybeRef, RefType, ToRef, ToRefs, UnwrapRef } from '../types/ReactiveTypes'; import { registerDestroyFunction } from '../store/StoreHandler'; -import { getProcessingVNode } from '../../renderer/GlobalVar'; -import { FunctionComponent } from '../../renderer/vnode/VNodeTags'; import { useRef } from '../../renderer/hooks/HookExternal'; import { createProxy } from '../proxy/ProxyHandler'; import { Listener } from '../types/ProxyTypes'; @@ -97,7 +95,7 @@ class RefImpl { export function isRef(ref: MaybeRef): ref is RefType; export function isRef(ref: any): ref is RefType { - return Boolean(ref && ref._isRef); + return Boolean(ref && ref[ReactiveFlags.IS_REF]); } export function toReactive(value: T): T { @@ -110,7 +108,6 @@ export function unref(ref: MaybeRef): T { declare const ShallowRefMarker: unique symbol; export type ShallowRef = RefType & { [ShallowRefMarker]?: true }; -export type IfAny = 0 extends 1 & T ? Y : N; export function shallowRef( value: T @@ -119,3 +116,73 @@ export function shallowRef(): ShallowRef; export function shallowRef(value?: unknown) { return createRef(value, true); } + +export function toRef( + value: T +): T extends () => infer R ? Readonly> : T extends RefType ? T : RefType>; +export function toRef, K extends keyof T>(object: T, key: K): ToRef; +export function toRef, K extends keyof T>( + object: T, + key: K, + defaultValue: T[K] +): ToRef>; +export function toRef(source: Record | MaybeRef, key?: string, defaultValue?: unknown): RefType { + if (isRef(source)) { + return source; + } else if (typeof source === 'function') { + return new GetterRefImpl(source) as any; + } else if (isObject(source) && arguments.length > 1) { + return propertyToRef(source, key!, defaultValue); + } else { + return ref(source); + } +} + +class GetterRefImpl { + public readonly _isRef = true; + public readonly _isReadonly = true; + private readonly _getter: () => T; + + constructor(getter: () => T) { + this._getter = getter; + } + + get value() { + return this._getter(); + } +} + +function propertyToRef(source: Record, key: string, defaultValue?: unknown) { + const val = source[key]; + return isRef(val) ? val : (new ObjectRefImpl(source, key, defaultValue) as any); +} + +class ObjectRefImpl, K extends keyof T> { + public readonly _isRef = true; + private readonly _object: T; + private readonly _key: K; + private readonly _defaultValue?: T[K]; + + constructor(object: T, key: K, defaultValue?: T[K]) { + this._object = object; + this._key = key; + this._defaultValue = defaultValue; + } + + get value() { + const val = this._object[this._key]; + return val === undefined ? this._defaultValue! : val; + } + + set value(newVal) { + this._object[this._key] = newVal; + } +} + +export function toRefs>(object: T): ToRefs { + const ret: any = isArray(object) ? new Array(object.length) : {}; + for (const key in object) { + ret[key] = propertyToRef(object, key); + } + return ret; +} diff --git a/packages/inula/src/inulax/store/StoreHandler.ts b/packages/inula/src/inulax/store/StoreHandler.ts index 2b30ecde..271e2232 100644 --- a/packages/inula/src/inulax/store/StoreHandler.ts +++ b/packages/inula/src/inulax/store/StoreHandler.ts @@ -53,14 +53,15 @@ const idGenerator = { }; const storeMap = new Map>(); +const pendingMap = new WeakMap(); // 通过该方法执行store.$queue中的action function tryNextAction(storeObj, proxyObj, config, plannedActions) { if (!plannedActions.length) { - if (proxyObj.$pending) { + if (pendingMap.get(proxyObj)) { const timestamp = Date.now(); - const duration = timestamp - proxyObj.$pending; - proxyObj.$pending = false; + const duration = timestamp - (pendingMap.get(proxyObj) as number); + pendingMap.set(proxyObj, false); devtools.emit(QUEUE_FINISHED, { store: storeObj, endedAt: timestamp, @@ -165,7 +166,7 @@ export function createStore, A extends UserActions const proxyObj = createProxy(config.state, listener, !config.options?.isReduxAdapter); if (proxyObj !== undefined) { - proxyObj.$pending = false; + pendingMap.set(proxyObj, false); } const $a: Partial> = {}; @@ -173,12 +174,13 @@ export function createStore, A extends UserActions const $c: Partial> = {}; const storeObj = { id, + $state: proxyObj, $s: proxyObj, $a: $a as StoreActions, $c: $c as ComputedValues, $queue: $queue as QueuedStoreActions, $config: config, - $listeners: [ + $subscriptions: [ change => { devtools.emit(STATE_CHANGE, { store: storeObj, @@ -188,16 +190,19 @@ export function createStore, A extends UserActions ], $subscribe: listener => { devtools.emit(SUBSCRIBED, { store: storeObj, listener }); - storeObj.$listeners.push(listener); + storeObj.$subscriptions.push(listener); + return () => { + storeObj.$unsubscribe(listener); + }; }, $unsubscribe: listener => { devtools.emit(UNSUBSCRIBED, { store: storeObj }); - storeObj.$listeners = storeObj.$listeners.filter(item => item != listener); + storeObj.$subscriptions = storeObj.$subscriptions.filter(item => item != listener); }, } as unknown as StoreObj; listener.current = (...args) => { - storeObj.$listeners.forEach(listener => listener(...args)); + storeObj.$subscriptions.forEach(listener => listener(...args)); }; const plannedActions: PlannedAction>[] = []; @@ -217,11 +222,11 @@ export function createStore, A extends UserActions fromQueue: true, }); return new Promise(resolve => { - if (!proxyObj.$pending) { - proxyObj.$pending = Date.now(); + if (!pendingMap.get(proxyObj)) { + pendingMap.set(proxyObj, Date.now()); devtools.emit(QUEUE_PENDING, { store: storeObj, - startedAt: proxyObj.$pending, + startedAt: pendingMap.get(proxyObj), }); const result = config.actions![action].bind(storeObj, proxyObj)(...payload); @@ -279,12 +284,15 @@ export function createStore, A extends UserActions if (config.computed) { Object.keys(config.computed).forEach(computeKey => { - // 让store.$c[computeKey]可以访问到computed方法 - ($c as any)[computeKey] = config.computed![computeKey].bind(storeObj, readonlyProxy(proxyObj)); + const computeFn = config.computed![computeKey].bind(storeObj, readonlyProxy(proxyObj)); + // 让store.$c[computeKey]可以访问到computed的值 + Object.defineProperty($c, computeKey, { + get: computeFn as () => any, + }); // 让store[computeKey]可以访问到computed的值 Object.defineProperty(storeObj, computeKey, { - get: $c[computeKey] as () => any, + get: computeFn as () => any, }); }); } diff --git a/packages/inula/src/inulax/types/ReactiveTypes.ts b/packages/inula/src/inulax/types/ReactiveTypes.ts index 42842c64..38c25593 100644 --- a/packages/inula/src/inulax/types/ReactiveTypes.ts +++ b/packages/inula/src/inulax/types/ReactiveTypes.ts @@ -44,3 +44,10 @@ export type ReactiveRet = T extends FnType | BaseTypes | RefType [P in keyof T]: P extends symbol ? T[P] : UnwrapRef; } : T; + +export type IfAny = 0 extends 1 & T ? Y : N; + +export type ToRef = IfAny, [T] extends [RefType] ? T : RefType>; +export type ToRefs = { + [K in keyof T]: ToRef; +}; diff --git a/packages/inula/src/inulax/types/StoreTypes.ts b/packages/inula/src/inulax/types/StoreTypes.ts index 87f5ad2e..2dc6eb0f 100644 --- a/packages/inula/src/inulax/types/StoreTypes.ts +++ b/packages/inula/src/inulax/types/StoreTypes.ts @@ -13,7 +13,7 @@ * See the Mulan PSL v2 for more details. */ -import { AddWatchProp } from './ProxyTypes'; +import { AddWatchProp, Listener } from './ProxyTypes'; export type StoreConfig< S extends Record, @@ -48,17 +48,23 @@ type Action, S extends Record> = ...args: RemoveFirstFromTuple> ) => ReturnType; -export type StoreObj, A extends UserActions, C extends UserComputedValues> = { +export type StoreComputed, C extends UserComputedValues> = { + [K in keyof C]: ReturnType; +}; + +export type StoreObj< + S extends Record = Record, + A extends UserActions = UserActions, + C extends UserComputedValues = UserComputedValues, +> = { $s: AddWatchProp; $state: AddWatchProp; - // $s: S; - // $state: S; $a: StoreActions; - $c: UserComputedValues; + $c: StoreComputed; $queue: QueuedStoreActions; - $listeners; - $subscribe: (listener: (mutation) => void) => void; - $unsubscribe: (listener: (mutation) => void) => void; + $subscriptions: Array; + $subscribe: (listener: Listener) => void; + $unsubscribe: (listener: Listener) => void; } & { [K in keyof AddWatchProp]: AddWatchProp[K] } & { [K in keyof A]: Action } & { [K in keyof C]: ReturnType; }; @@ -76,11 +82,9 @@ type RemoveFirstFromTuple = T['length'] extends 0 : []; export type UserComputedValues> = { - [K: string]: ComputedFunction; + [K: string]: (state: S) => any; }; -type ComputedFunction> = (state: S) => any; - export type AsyncAction, S extends Record> = ( this: StoreObj, ...args: RemoveFirstFromTuple>