From 1ae22b36cc273896eafe67ab9598b213f3f11101 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 3 Mar 2021 15:28:47 +0100 Subject: [PATCH] refactor: wip to migrate to createPinia() like v2 --- __tests__/store.spec.ts | 19 ++-- jest.config.js | 3 + src/index.ts | 12 +- src/rootStore.ts | 170 +++++++++++++++++++++++++++- src/ssrPlugin.ts | 3 +- src/store.ts | 238 ++++++++++++++++++++++++++++------------ src/types.ts | 16 ++- yarn.lock | 2 +- 8 files changed, 381 insertions(+), 82 deletions(-) diff --git a/__tests__/store.spec.ts b/__tests__/store.spec.ts index a3dbc301..ae2cb77e 100644 --- a/__tests__/store.spec.ts +++ b/__tests__/store.spec.ts @@ -1,11 +1,14 @@ -import { defineStore, setActiveReq, setStateProvider } from '../src' +import Vue from 'vue' +import { createPinia, defineStore, Pinia, setActivePinia } from '../src' describe('Store', () => { - let req: object + let pinia: Pinia const useStore = () => { // create a new store - req = {} - setActiveReq(req) + pinia = createPinia() + // this is done by Vue.install(pinia) + pinia.Vue = Vue + setActivePinia(pinia) return defineStore({ id: 'main', state: () => ({ @@ -53,7 +56,9 @@ describe('Store', () => { }) it('can hydrate the state', () => { - setActiveReq({}) + const pinia = createPinia() + pinia.Vue = Vue + setActivePinia(pinia) const useStore = defineStore({ id: 'main', state: () => ({ @@ -65,7 +70,7 @@ describe('Store', () => { }), }) - setStateProvider(() => ({ + pinia.state.value = { main: { a: false, nested: { @@ -73,7 +78,7 @@ describe('Store', () => { a: { b: 'string' }, }, }, - })) + } const store = useStore() diff --git a/jest.config.js b/jest.config.js index 9672b353..05e74d06 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,9 @@ module.exports = { testMatch: ['/__tests__/**/*.spec.ts'], setupFilesAfterEnv: ['./__tests__/setup.ts'], globals: { + __DEV__: true, + __TEST__: true, + __BROWSER__: true, 'ts-jest': { diagnostics: { warnOnly: true, diff --git a/src/index.ts b/src/index.ts index 4bc90852..b086109d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,15 @@ export { defineStore } from './store' -export { setActiveReq, setStateProvider, getRootState } from './rootStore' +export { + setActiveReq, + setStateProvider, + getRootState, + Pinia, + PiniaStorePlugin, + PiniaCustomProperties, + createPinia, + setActivePinia, + getActivePinia, +} from './rootStore' export { StateTree, Store, diff --git a/src/rootStore.ts b/src/rootStore.ts index 09ed006c..9577a983 100644 --- a/src/rootStore.ts +++ b/src/rootStore.ts @@ -1,4 +1,12 @@ -import { NonNullObject, StateTree, GenericStore } from './types' +import { InjectionKey, ref, Ref } from '@vue/composition-api' +import { + NonNullObject, + StateTree, + GenericStore, + StoreWithState, + StateDescriptor, +} from './types' +import Vue, { PluginFunction, VueConstructor } from 'vue' /** * setActiveReq must be called to handle SSR at the top of functions like `fetch`, `setup`, `serverPrefetch` and others @@ -15,7 +23,10 @@ export const getActiveReq = () => activeReq * be able to reset the store instance between requests on the server */ -export const storesMap = new WeakMap>() +export const storesMap = new WeakMap< + Pinia, + Map, StateDescriptor]> +>() /** * A state provider allows to set how states are stored for hydration. e.g. setting a property on a context, getting a property from window @@ -55,3 +66,158 @@ export function getRootState(req: NonNullObject): Record { return rootState } + +// ---------------------------------- + +/** + * Properties that are added to every store by `pinia.use()` + */ +// eslint-disable-next-line +export interface PiniaCustomProperties {} + +export const piniaSymbol = (__DEV__ + ? Symbol('pinia') + : Symbol()) as InjectionKey + +/** + * Plugin to extend every store + */ +export interface PiniaStorePlugin { + (pinia: Pinia): Partial +} + +/** + * Every application must own its own pinia to be able to create stores + */ +export interface Pinia { + install: PluginFunction + + /** + * root state + */ + state: Ref> + + /** + * Adds a store plugin to extend every store + * + * @param plugin - store plugin to add + */ + use(plugin: PiniaStorePlugin): void + + /** + * Installed store plugins + * + * @internal + */ + _p: Array<() => Partial> + + /** + * Vue constructor retrieved when installing the pinia. + */ + Vue: VueConstructor +} + +export const IS_CLIENT = typeof window !== 'undefined' + +/** + * Creates a Pinia instance to be used by the application + */ +export function createPinia(): Pinia { + // NOTE: here we could check the window object for a state and directly set it + // if there is anything like it with Vue 3 SSR + const state = ref({}) + + const _p: Pinia['_p'] = [] + // plugins added before calling app.use(pinia) + const toBeInstalled: PiniaStorePlugin[] = [] + + const pinia: Pinia = { + // this one is set in install + Vue: {} as any, + install(Vue) { + // localApp = app + this.Vue = Vue + + // Equivalent of + // app.config.globalProperties.$pinia = pinia + Vue.mixin({ + beforeCreate() { + const options = this.$options as any + // Make pinia accessible everywhere through this.$pinia + // FIXME: typings + if (options.pinia) { + ;(this as any).$pinia = options.pinia + // HACK: taken from provide(): https://github.com/vuejs/composition-api/blob/master/src/apis/inject.ts#L25 + // TODO: check if necessary + // if (!(this as any)._provided) { + // const provideCache = {} + // Object.defineProperty(this, '_provided', { + // get: () => provideCache, + // set: (v) => Object.assign(provideCache, v), + // }) + // } + // ;(this as any)._provided[piniaSymbol as any] = options.pinia + } else if (options.parent && options.parent.$pinia) { + ;(this as any).$pinia = options.parent.$pinia + } + }, + }) + + // only set the app on client for devtools + if (__BROWSER__ && IS_CLIENT) { + // setClientApp(app) + // this allows calling useStore() outside of a component setup after + // installing pinia's plugin + setActivePinia(pinia) + } + + toBeInstalled.forEach((plugin) => _p.push(plugin.bind(null, pinia))) + }, + + use(plugin) { + if (!pinia.Vue) { + toBeInstalled.push(plugin) + } else { + _p.push(plugin.bind(null, pinia)) + } + }, + + _p, + + state, + } + + return pinia +} + +/** + * setActivePinia must be called to handle SSR at the top of functions like + * `fetch`, `setup`, `serverPrefetch` and others + */ +export let activePinia: Pinia | undefined + +/** + * Sets or unsets the active pinia. Used in SSR and internally when calling + * actions and getters + * + * @param pinia - Pinia instance + */ +export const setActivePinia = (pinia: Pinia | undefined) => + (activePinia = pinia) + +/** + * Get the currently active pinia + */ +export const getActivePinia = () => { + if (__DEV__ && !activePinia) { + console.warn( + `[🍍]: getActivePinia was called with no active Pinia. Did you forget to install pinia and inject it?\n\n` + + `const pinia = createPinia()\n` + + `Vue.use(pinia)\n` + + `new Vue({ el: '#app', pinia })\n\n` + + `This will fail in production.` + ) + } + + return activePinia! +} diff --git a/src/ssrPlugin.ts b/src/ssrPlugin.ts index 83f98431..a0d90064 100644 --- a/src/ssrPlugin.ts +++ b/src/ssrPlugin.ts @@ -1,4 +1,5 @@ -import { VueConstructor } from 'vue/types' +import { VueConstructor } from 'vue' +// FIXME: migrate to setActivePinia import { setActiveReq } from './rootStore' import { SetupContext } from '@vue/composition-api' diff --git a/src/store.ts b/src/store.ts index 7ecc7fff..13a5fb4f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,4 +1,11 @@ -import { ref, watch, computed, reactive, Ref } from '@vue/composition-api' +import { + watch, + computed, + reactive, + Ref, + getCurrentInstance, + markRaw, +} from '@vue/composition-api' import { StateTree, StoreWithState, @@ -9,14 +16,17 @@ import { Store, StoreWithActions, Method, + StateDescriptor, } from './types' import { useStoreDevtools } from './devtools' import { - getActiveReq, - setActiveReq, storesMap, - getInitialState, + Pinia, + setActivePinia, + getActivePinia, + PiniaCustomProperties, } from './rootStore' +import Vue from 'vue' const isClient = typeof window != 'undefined' @@ -39,6 +49,32 @@ function innerPatch( return target } +/** + * Create an object of computed properties referring to + * + * @param rootStateRef - pinia.state + * @param id - unique name + */ +function computedFromState( + rootStateRef: Ref>, + id: Id +) { + // let asComputed = computed() + const reactiveObject = {} as { + [k in keyof T]: Ref + } + const state = rootStateRef.value[id] + for (const key in state) { + // @ts-ignore: the key matches + reactiveObject[key] = computed({ + get: () => rootStateRef.value[id][key as keyof T], + set: (value) => (rootStateRef.value[id][key as keyof T] = value), + }) + } + + return reactiveObject +} + function toComputed(refObject: Ref) { // let asComputed = computed() const reactiveObject = {} as { @@ -56,120 +92,164 @@ function toComputed(refObject: Ref) { } /** - * Creates a store instance - * @param id unique identifier of the store, like a name. eg: main, cart, user - * @param initialState initial state applied to the store, Must be correctly typed to infer typings + * 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 */ -export function buildStore< - Id extends string, - S extends StateTree, - G extends Record, - A extends Record ->( +function initStore( $id: Id, - buildState = () => ({} as S), - getters: G = {} as G, - actions: A = {} as A, + buildState: () => S = () => ({} as S), initialState?: S | undefined -): Store { - const $state: Ref = ref(initialState || buildState()) - const _r = getActiveReq() +): [StoreWithState, { get: () => S; set: (newValue: S) => void }] { + const pinia = getActivePinia() + pinia.Vue.set(pinia.state.value, $id, initialState || buildState()) + // const state: Ref = toRef(_p.state.value, $id) let isListening = true let subscriptions: SubscriptionCallback[] = [] - watch( - () => $state.value, - (state) => { - if (isListening) { - subscriptions.forEach((callback) => { - callback({ storeName: $id, type: '🧩 in place', payload: {} }, state) - }) - } - }, - { - deep: true, - flush: 'sync', - } - ) - function $patch(partialState: DeepPartial): void { isListening = false - innerPatch($state.value, partialState) + innerPatch(pinia.state.value[$id], partialState) isListening = true // because we paused the watcher, we need to manually call the subscriptions subscriptions.forEach((callback) => { callback( { storeName: $id, type: '⤵️ patch', payload: partialState }, - $state.value + pinia.state.value[$id] ) }) } 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 stopWatcher = watch( + () => pinia.state.value[$id], + (state) => { + if (isListening) { + subscriptions.forEach((callback) => { + callback( + { storeName: $id, type: '🧩 in place', payload: {} }, + state + ) + }) + } + }, + { + deep: true, + flush: 'sync', + } + ) + return () => { const idx = subscriptions.indexOf(callback) if (idx > -1) { subscriptions.splice(idx, 1) + stopWatcher() } } } function $reset() { subscriptions = [] - $state.value = buildState() + pinia.state.value[$id] = buildState() } const storeWithState: StoreWithState = { $id, - _r, - // @ts-ignore, `reactive` unwraps this making it of type S - $state: computed({ - get: () => $state.value, - set: (newState) => { - isListening = false - $state.value = newState - isListening = true - }, - }), + _p: markRaw(pinia), + + // $state is added underneath $patch, $subscribe, $reset, - } + } as StoreWithState + + return [ + storeWithState, + { + get: () => pinia.state.value[$id] as S, + set: (newState: S) => { + isListening = false + pinia.state.value[$id] = newState + isListening = true + }, + }, + ] +} + +/** + * 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< + Id extends string, + S extends StateTree, + G extends Record, + A extends Record +>( + partialStore: StoreWithState, + descriptor: StateDescriptor, + $id: Id, + getters: G = {} as G, + actions: A = {} as A +) { + const pinia = getActivePinia() const computedGetters: StoreWithGetters = {} as StoreWithGetters for (const getterName in getters) { computedGetters[getterName] = computed(() => { - setActiveReq(_r) - // we could also pass state.value instead of the store as the first - // argument but the later is more flexible for JS users while TS users - // will like to use `this` to get all correct typings + setActivePinia(pinia) // eslint-disable-next-line @typescript-eslint/no-use-before-define return getters[getterName].call(store, store) }) as StoreWithGetters[typeof getterName] } - // const reactiveGetters = reactive(computedGetters) - const wrappedActions: StoreWithActions = {} as StoreWithActions for (const actionName in actions) { wrappedActions[actionName] = function () { - setActiveReq(_r) + setActivePinia(pinia) // eslint-disable-next-line return actions[actionName].apply(store, (arguments as unknown) as any[]) } as StoreWithActions[typeof actionName] } + const extensions = pinia._p.reduce( + (extended, extender) => ({ + ...extended, + ...extender(), + }), + {} as PiniaCustomProperties + ) + const store: Store = reactive({ - ...storeWithState, + ...extensions, + ...partialStore, // using this means no new properties can be added as state - ...toComputed($state), + ...computedFromState(pinia.state, $id), ...computedGetters, ...wrappedActions, }) 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) + return store } @@ -193,23 +273,45 @@ export function defineStore< }) { const { id, state, getters, actions } = options - return function useStore(reqKey?: object): Store { - if (reqKey) setActiveReq(reqKey) - const req = getActiveReq() - let stores = storesMap.get(req) - if (!stores) storesMap.set(req, (stores = new Map())) + return function useStore(pinia?: Pinia | null): Store { + const vm = getCurrentInstance() + pinia = pinia || (vm && ((vm as any).$pinia as Pinia)) + + if (pinia) setActivePinia(pinia) + + pinia = getActivePinia() + let stores = storesMap.get(pinia) + if (!stores) storesMap.set(pinia, (stores = new Map())) - let store = stores.get(id) as Store - if (!store) { - stores.set( + // let store = stores.get(id) as Store + let storeAndDescriptor = stores.get(id) as + | [StoreWithState, StateDescriptor] + | undefined + + if (!storeAndDescriptor) { + storeAndDescriptor = initStore(id, state, pinia.state.value[id]) + + stores.set(id, storeAndDescriptor) + + const store = buildStoreToUse( + storeAndDescriptor[0], + storeAndDescriptor[1], id, - // @ts-ignore - (store = buildStore(id, state, getters, actions, getInitialState(id))) + getters as Record | undefined, + actions as Record | undefined ) if (isClient) useStoreDevtools(store) + + return store } - return store + return buildStoreToUse( + storeAndDescriptor[0], + storeAndDescriptor[1], + id, + getters as Record | undefined, + actions as Record | undefined + ) } } diff --git a/src/types.ts b/src/types.ts index bebea62c..eb0c342d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,15 @@ +import { Pinia } from './rootStore' + export type StateTree = Record +/** + * Object descriptor for Object.defineProperty + */ +export interface StateDescriptor { + get(): S + set(newValue: S): void +} + export function isPlainObject( // eslint-disable-next-line @typescript-eslint/no-explicit-any o: any @@ -34,9 +44,11 @@ export interface StoreWithState { $state: S /** - * Private property defining the request key for this store + * Private property defining the pinia the store is attached to. + * + * @internal */ - _r: NonNullObject + _p: Pinia /** * Applies a state patch to current state. Allows passing nested values diff --git a/yarn.lock b/yarn.lock index f7ccc3a3..f24eadd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1169,7 +1169,7 @@ dependencies: tslib "^2.1.0" -"@vue/test-utils@^1.1.3": +"@vue/test-utils@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.1.3.tgz#747f5683d8d4633c85a385fe2e02c1bb35bec153" integrity sha512-BAY1Cwe9JpkJseimC295EW3YlAmgIJI9OPkg2FSP62+PHZooB0B+wceDi9TYyU57oqzL0yLbcP73JKFpKiLc9A== -- 2.47.3