From a9212e2c5dee6b1fe5cdcf6bbabbcbfe706ed5b7 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 9 Jul 2021 16:16:51 +0200 Subject: [PATCH] refactor: defineStore with setup --- __tests__/actions.spec.ts | 4 +- __tests__/getters.spec.ts | 2 +- __tests__/onAction.spec.ts | 2 +- __tests__/state.spec.ts | 2 +- __tests__/store.patch.spec.ts | 7 +- __tests__/store.spec.ts | 6 + __tests__/storeSetup.spec.ts | 122 ++++++++++ src/store.ts | 441 ++++++---------------------------- 8 files changed, 208 insertions(+), 378 deletions(-) create mode 100644 __tests__/storeSetup.spec.ts diff --git a/__tests__/actions.spec.ts b/__tests__/actions.spec.ts index 746dc011..70f74f02 100644 --- a/__tests__/actions.spec.ts +++ b/__tests__/actions.spec.ts @@ -77,7 +77,9 @@ describe('Actions', () => { it('store is forced as the context', () => { const store = useStore() expect(store.$state.a).toBe(true) - store.toggle.call(null) + expect(() => { + store.toggle.call(null) + }).not.toThrow() expect(store.$state.a).toBe(false) }) diff --git a/__tests__/getters.spec.ts b/__tests__/getters.spec.ts index 270d756c..50fbd8bd 100644 --- a/__tests__/getters.spec.ts +++ b/__tests__/getters.spec.ts @@ -28,7 +28,7 @@ describe('Getters', () => { actions: { o() { // @ts-expect-error it should type getters - this.arrowUper.toUpperCase() + this.arrowUpper.toUpperCase() this.o().toUpperCase() return 'a string' }, diff --git a/__tests__/onAction.spec.ts b/__tests__/onAction.spec.ts index 2789a9ec..a90a09d0 100644 --- a/__tests__/onAction.spec.ts +++ b/__tests__/onAction.spec.ts @@ -147,7 +147,7 @@ describe('Subscriptions', () => { const s1 = useStore() const s2 = useStore() - expect(s2).not.toBe(s1) + expect(s2).toBe(s1) const spy1 = jest.fn() const spy2 = jest.fn() diff --git a/__tests__/state.spec.ts b/__tests__/state.spec.ts index f47836f1..a69460a4 100644 --- a/__tests__/state.spec.ts +++ b/__tests__/state.spec.ts @@ -77,8 +77,8 @@ describe('State', () => { expect(store.$state.name).toBe('Eduardo') expect(pinia.state.value.main).toEqual({ name: 'Eduardo', - counter: 0, double: 0, + counter: 0, }) name.value = 'Ed' diff --git a/__tests__/store.patch.spec.ts b/__tests__/store.patch.spec.ts index 9156f543..8e6c1579 100644 --- a/__tests__/store.patch.spec.ts +++ b/__tests__/store.patch.spec.ts @@ -1,5 +1,10 @@ import { reactive, ref } from 'vue' -import { createPinia, defineStore, setActivePinia } from '../src' +import { + createPinia, + defineSetupStore, + defineStore, + setActivePinia, +} from '../src' describe('store.$patch', () => { const useStore = () => { diff --git a/__tests__/store.spec.ts b/__tests__/store.spec.ts index 667aa092..0b090072 100644 --- a/__tests__/store.spec.ts +++ b/__tests__/store.spec.ts @@ -20,6 +20,12 @@ describe('Store', () => { })() } + it('reuses a store', () => { + setActivePinia(createPinia()) + const useStore = defineStore({ id: 'main' }) + expect(useStore()).toBe(useStore()) + }) + it('sets the initial state', () => { const store = useStore() expect(store.$state).toEqual({ diff --git a/__tests__/storeSetup.spec.ts b/__tests__/storeSetup.spec.ts new file mode 100644 index 00000000..6431f9c4 --- /dev/null +++ b/__tests__/storeSetup.spec.ts @@ -0,0 +1,122 @@ +import { + createPinia, + defineSetupStore, + defineStore, + setActivePinia, +} from '../src' +import { computed, nextTick, ref, watch } from 'vue' + +function expectType(value: T): void {} + +describe('store with setup syntax', () => { + const useStore = defineSetupStore('main', () => { + 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 } + }) + + beforeEach(() => { + setActivePinia(createPinia()) + }) + + 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 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('watch', () => { + // setActivePinia(createPinia()) + // defineStore({ + // id: 'main', + // state: () => ({ + // name: 'Eduardo', + // counter: 0, + // }), + // })() + // }) + + it('state can be watched', async () => { + const store = useStore() + const spy = jest.fn() + watch(() => store.name, spy) + expect(spy).not.toHaveBeenCalled() + store.name = 'Ed' + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('unwraps refs', () => { + const name = ref('Eduardo') + const counter = ref(0) + const double = computed({ + get: () => counter.value * 2, + set(val) { + counter.value = val / 2 + }, + }) + + const pinia = createPinia() + setActivePinia(pinia) + const useStore = defineStore({ + id: 'main', + state: () => ({ + name, + counter, + double, + }), + }) + + const store = useStore() + + expect(store.name).toBe('Eduardo') + expect(store.$state.name).toBe('Eduardo') + expect(pinia.state.value.main).toEqual({ + name: 'Eduardo', + counter: 0, + double: 0, + }) + + name.value = 'Ed' + expect(store.name).toBe('Ed') + expect(store.$state.name).toBe('Ed') + expect(pinia.state.value.main.name).toBe('Ed') + + store.name = 'Edu' + expect(store.name).toBe('Edu') + + store.$patch({ counter: 2 }) + expect(store.counter).toBe(2) + expect(counter.value).toBe(2) + }) +}) diff --git a/src/store.ts b/src/store.ts index 95d0c707..72a95f9a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -5,8 +5,6 @@ import { inject, getCurrentInstance, reactive, - InjectionKey, - provide, DebuggerEvent, WatchOptions, UnwrapRef, @@ -18,24 +16,20 @@ import { onUnmounted, ComputedRef, toRef, + toRefs, } from 'vue' import { StateTree, - StoreWithState, SubscriptionCallback, DeepPartial, isPlainObject, - StoreWithGetters, Store, - StoreWithActions, _Method, - StateDescriptor, DefineStoreOptions, StoreDefinition, GettersTree, MutationType, StoreOnActionListener, - UnwrapPromise, ActionsTree, SubscriptionCallbackMutation, _UnionToTuple, @@ -43,7 +37,6 @@ import { import { getActivePinia, setActivePinia, - storesMap, piniaSymbol, Pinia, activePinia, @@ -102,314 +95,62 @@ function computedFromState( return reactiveObject } -/** - * Creates a store with its state object. This is meant to be augmented with getters and actions - * - * @param id - unique identifier of the store, like a name. eg: main, cart, user - * @param buildState - function to build the initial state - * @param initialState - initial state applied to the store, Must be correctly typed to infer typings - */ -function initStore< +export interface DefineSetupStoreOptions< Id extends string, S extends StateTree, - G extends GettersTree, - A /* extends ActionsTree */ ->( - $id: Id, - buildState: () => S = () => ({} as S), - initialState?: S | undefined -): [ - StoreWithState, - { get: () => S; set: (newValue: S) => void }, - InjectionKey -] { - const pinia = getActivePinia() - pinia.state.value[$id] = initialState || buildState() - // const state: Ref = toRef(_p.state.value, $id) - - // internal state - let isListening = true - let subscriptions: SubscriptionCallback[] = markRaw([]) - let actionSubscriptions: StoreOnActionListener[] = markRaw([]) - let debuggerEvents: DebuggerEvent[] | DebuggerEvent - - function $patch(stateMutation: (state: UnwrapRef) => void): void - function $patch(partialState: DeepPartial>): void - function $patch( - partialStateOrMutator: - | DeepPartial> - | ((state: UnwrapRef) => void) - ): void { - let subscriptionMutation: SubscriptionCallbackMutation - isListening = false - // reset the debugger events since patches are sync - /* istanbul ignore else */ - if (__DEV__) { - debuggerEvents = [] - } - if (typeof partialStateOrMutator === 'function') { - partialStateOrMutator(pinia.state.value[$id] as UnwrapRef) - subscriptionMutation = { - type: MutationType.patchFunction, - storeId: $id, - events: debuggerEvents as DebuggerEvent[], - } - } else { - innerPatch(pinia.state.value[$id], partialStateOrMutator) - subscriptionMutation = { - type: MutationType.patchObject, - payload: partialStateOrMutator, - storeId: $id, - events: debuggerEvents as DebuggerEvent[], - } - } - isListening = true - // because we paused the watcher, we need to manually call the subscriptions - subscriptions.forEach((callback) => { - callback(subscriptionMutation, pinia.state.value[$id] as UnwrapRef) - }) - } - - function $subscribe(callback: SubscriptionCallback) { - subscriptions.push(callback) - - // watch here to link the subscription to the current active instance - // e.g. inside the setup of a component - const options: WatchOptions = { deep: true, flush: 'sync' } - /* istanbul ignore else */ - if (__DEV__) { - options.onTrigger = (event) => { - if (isListening) { - debuggerEvents = event - } else { - // let patch send all the events together later - /* istanbul ignore else */ - if (Array.isArray(debuggerEvents)) { - debuggerEvents.push(event) - } else { - console.error( - '🍍 debuggerEvents should be an array. This is most likely an internal Pinia bug.' - ) - } - } - } - } - const stopWatcher = watch( - () => pinia.state.value[$id] as UnwrapRef, - (state, oldState) => { - if (isListening) { - callback( - { - storeId: $id, - type: MutationType.direct, - events: debuggerEvents as DebuggerEvent, - }, - state - ) - } - }, - options - ) - - const removeSubscription = () => { - const idx = subscriptions.indexOf(callback) - if (idx > -1) { - subscriptions.splice(idx, 1) - stopWatcher() - } - } - - if (getCurrentInstance()) { - onUnmounted(removeSubscription) - } - - return removeSubscription - } - - function $onAction(callback: StoreOnActionListener) { - actionSubscriptions.push(callback) - - const removeSubscription = () => { - const idx = actionSubscriptions.indexOf(callback) - if (idx > -1) { - actionSubscriptions.splice(idx, 1) - } - } - - if (getCurrentInstance()) { - onUnmounted(removeSubscription) - } - - return removeSubscription - } - - function $reset() { - pinia.state.value[$id] = buildState() - } - - const storeWithState: StoreWithState = { - $id, - _p: pinia, - _as: actionSubscriptions as unknown as StoreOnActionListener[], - - // $state is added underneath + G extends ActionsTree, // TODO: naming + A extends ActionsTree +> { + hydrate?(store: Store, initialState: S | undefined): void +} - $patch, - $subscribe, - $onAction, - $reset, - } as StoreWithState - - const injectionSymbol = __DEV__ - ? Symbol(`PiniaStore(${$id})`) - : /* istanbul ignore next */ - Symbol() - - return [ - storeWithState, - { - get: () => pinia.state.value[$id] as S, - set: (newState: S) => { - isListening = false - pinia.state.value[$id] = newState - isListening = true - }, - }, - injectionSymbol, - ] +function isComputed(o: any): o is ComputedRef { + return o && o.effect && o.effect.computed } -const noop = () => {} -/** - * Creates a store bound to the lifespan of where the function is called. This - * means creating the store inside of a component's setup will bound it to the - * lifespan of that component while creating it outside of a component will - * create an ever living store - * - * @param partialStore - store with state returned by initStore - * @param descriptor - descriptor to setup $state property - * @param $id - unique name of the store - * @param getters - getters of the store - * @param actions - actions of the store - */ -function buildStoreToUse< +function createOptionsStore< Id extends string, S extends StateTree, G extends GettersTree, A extends ActionsTree ->( - partialStore: StoreWithState, - descriptor: StateDescriptor, - $id: Id, - getters: G = {} as G, - actions: A = {} as A, - options: DefineStoreOptions -) { - const pinia = getActivePinia() - - const computedGetters: StoreWithGetters = {} as StoreWithGetters - for (const getterName in getters) { - // @ts-ignore: it's only readonly for the users - computedGetters[getterName] = computed(() => { - setActivePinia(pinia) - // eslint-disable-next-line @typescript-eslint/no-use-before-define - // @ts-expect-error: the argument count is correct - return getters[getterName].call(store, store) - }) as StoreWithGetters[typeof getterName] +>(options: DefineStoreOptions, pinia: Pinia): Store { + const { id, state, actions, getters } = options + function $reset() { + pinia.state.value[id] = state ? state() : {} } - const wrappedActions: StoreWithActions = {} as StoreWithActions - for (const actionName in actions) { - wrappedActions[actionName] = function (this: Store) { - setActivePinia(pinia) - const args = Array.from(arguments) as Parameters - const localStore = this || store - - let afterCallback: ( - resolvedReturn: UnwrapPromise> - ) => void = noop - let onErrorCallback: (error: unknown) => void = noop - function after(callback: typeof afterCallback) { - afterCallback = callback - } - function onError(callback: typeof onErrorCallback) { - onErrorCallback = callback - } - - partialStore._as.forEach((callback) => { - // @ts-expect-error - callback({ args, name: actionName, store: localStore, after, onError }) - }) - - let ret: ReturnType - try { - ret = actions[actionName].apply(localStore, args as unknown as any[]) - Promise.resolve(ret).then(afterCallback).catch(onErrorCallback) - } catch (error) { - onErrorCallback(error) - throw error - } + function setup() { + $reset() + // pinia.state.value[id] = state ? state() : {} - return ret - } as StoreWithActions[typeof actionName] - } - - const store: Store = reactive( - assign( - __DEV__ && IS_CLIENT - ? // devtools custom properties - { - _customProperties: markRaw(new Set()), - } - : {}, - partialStore, - // using this means no new properties can be added as state - computedFromState(pinia.state, $id), - computedGetters, - wrappedActions + return assign( + toRefs(pinia.state.value[id]), + actions, + Object.keys(getters || {}).reduce((computedGetters, name) => { + computedGetters[name] = computed(() => { + setActivePinia(pinia) + // @ts-expect-error + return getters![name].call(store, store) + }) + return computedGetters + }, {} as Record) ) - ) as Store - - // use this instead of a computed with setter to be able to create it anywhere - // without linking the computed lifespan to wherever the store is first - // created. - Object.defineProperty(store, '$state', descriptor) - - // add getters for devtools - if (__DEV__ && IS_CLIENT) { - store._getters = markRaw(Object.keys(getters)) } - // apply all plugins - pinia._p.forEach((extender) => { - if (__DEV__ && IS_CLIENT) { - // @ts-expect-error: conflict between A and ActionsTree - const extensions = extender({ store, app: pinia._a, pinia, options }) - Object.keys(extensions || {}).forEach((key) => - store._customProperties.add(key) - ) - assign(store, extensions) - } else { - // @ts-expect-error: conflict between A and ActionsTree - assign(store, extender({ store, app: pinia._a, pinia, options })) - } - }) + const store = createSetupStore( + id, + setup, + // TODO: actual hydrate option to be added to options store + // @ts-expect-error: fixme + options + ) - return store -} + store.$reset = $reset -export interface DefineSetupStoreOptions< - Id extends string, - S extends StateTree, - G extends ActionsTree, // TODO: naming - A extends ActionsTree -> { - hydrate?(store: Store, initialState: S | undefined): void + return store as any } -function isComputed(o: any): o is ComputedRef { - return o && o.effect && o.effect.computed -} +const noop = () => {} function createSetupStore< Id extends string, @@ -420,13 +161,13 @@ function createSetupStore< >( $id: Id, setup: () => SS, - { - // @ts-expect-error - hydrate = innerPatch, - }: DefineSetupStoreOptions = {} + options: DefineSetupStoreOptions = {} ): Store { const pinia = getActivePinia() let scope!: EffectScope + const hydrate = options.hydrate || innerPatch + // @ts-expect-error + const state = options.state // watcher options for $subscribe const $subscribeOptions: WatchOptions = { deep: true, flush: 'sync' } @@ -585,8 +326,14 @@ function createSetupStore< for (const key in setupStore) { const prop = setupStore[key] - // action - if (typeof prop === 'function') { + if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) { + // @ts-expect-error: fixme + if (!options.state) { + // mark it as a piece of state to be serialized + pinia.state.value[$id][key] = toRef(setupStore as any, key) + } + // action + } else if (typeof prop === 'function') { // @ts-expect-error: we are overriding the function setupStore[key] = function () { setActivePinia(pinia) @@ -614,7 +361,7 @@ function createSetupStore< let ret: any try { - ret = prop.apply(this, args) + ret = prop.apply(this || store, args) Promise.resolve(ret).then(afterCallback).catch(onErrorCallback) } catch (error) { onErrorCallback(error) @@ -623,9 +370,6 @@ function createSetupStore< return ret } - } else if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) { - // mark it as a piece of state to be serialized - pinia.state.value[$id][key] = toRef(setupStore as any, key) } else if (__DEV__ && IS_CLIENT) { // add getters for devtools if (isComputed(prop)) { @@ -695,11 +439,10 @@ function createSetupStore< return store } -// const useStore = createSetupStore('cosa', () => { -// return { -// o: 'one', -// } -// }) +// export function disposeStore(store: Store) { +// store._e + +// } type _SpreadStateFromStore = K extends readonly [ infer A, @@ -833,13 +576,10 @@ export function defineStore< // cannot extends ActionsTree because we loose the typings A /* extends ActionsTree */ >(options: DefineStoreOptions): StoreDefinition { - const { id, state, getters, actions } = options + const { id } = options - function useStore(pinia?: Pinia | null): Store { + function useStore(pinia?: Pinia | null) { const currentInstance = getCurrentInstance() - // only run provide when pinia hasn't been manually passed - const shouldProvide = currentInstance && !pinia - // avoid injecting if `useStore` when not possible pinia = // in test mode, ignore the argument provided as we can always retrieve a // pinia instance with getActivePinia() @@ -848,76 +588,31 @@ export function defineStore< if (pinia) setActivePinia(pinia) // TODO: worth warning on server if no piniaKey as it can leak data pinia = getActivePinia() - let storeCache = storesMap.get(pinia) - if (!storeCache) storesMap.set(pinia, (storeCache = new Map())) - - let storeAndDescriptor = storeCache.get(id) as - | [ - StoreWithState, - StateDescriptor, - InjectionKey> - ] - | undefined - - let store: Store - - if (!storeAndDescriptor) { - storeAndDescriptor = initStore(id, state, pinia.state.value[id]) - - // @ts-expect-error: annoying to type - storeCache.set(id, storeAndDescriptor) - - store = buildStoreToUse< - Id, - S, - G, - // @ts-expect-error: A without extends - A - >( - storeAndDescriptor[0], - storeAndDescriptor[1], - id, - getters, - actions, - options - ) - // allow children to reuse this store instance to avoid creating a new - // store for each child - if (shouldProvide) { - provide(storeAndDescriptor[2], store) - } - } else { - store = - (currentInstance && inject(storeAndDescriptor[2], null)) || - buildStoreToUse< - Id, - S, - G, - // @ts-expect-error: cannot extends ActionsTree - A - >( - storeAndDescriptor[0], - storeAndDescriptor[1], - id, - getters, - actions, - options + if (!pinia._s.has(id)) { + pinia._s.set( + id, + createOptionsStore( + // @ts-expect-error: bad actions + options, + pinia ) + ) } + const store: Store = pinia._s.get(id)! as Store + // save stores in instances to access them devtools if (__DEV__ && IS_CLIENT && currentInstance && currentInstance.proxy) { const vm = currentInstance.proxy const cache = '_pStores' in vm ? vm._pStores! : (vm._pStores = {}) // @ts-expect-error: still can't cast Store with generics to Store - cache[store.$id] = store + cache[id] = store } return store } - // needed by map helpers useStore.$id = id return useStore -- 2.47.2