]> git.ipfire.org Git - thirdparty/vuejs/pinia.git/commitdiff
refactor: wip to migrate to createPinia() like v2
authorEduardo San Martin Morote <posva13@gmail.com>
Wed, 3 Mar 2021 14:28:47 +0000 (15:28 +0100)
committerEduardo San Martin Morote <posva@users.noreply.github.com>
Thu, 4 Mar 2021 15:43:47 +0000 (16:43 +0100)
__tests__/store.spec.ts
jest.config.js
src/index.ts
src/rootStore.ts
src/ssrPlugin.ts
src/store.ts
src/types.ts
yarn.lock

index a3dbc301b600d4ae785bc40741345c57b4483719..ae2cb77e9a7050dc2b375aa17c7b2cc3e2c6e2ab 100644 (file)
@@ -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()
 
index 9672b3532d01cb60d255c85f389544d2b816dd38..05e74d0696bdbabdbf6274743ec116b1fcf62d87 100644 (file)
@@ -5,6 +5,9 @@ module.exports = {
   testMatch: ['<rootDir>/__tests__/**/*.spec.ts'],
   setupFilesAfterEnv: ['./__tests__/setup.ts'],
   globals: {
+    __DEV__: true,
+    __TEST__: true,
+    __BROWSER__: true,
     'ts-jest': {
       diagnostics: {
         warnOnly: true,
index 4bc908529f1e506202783845e13eb7862e491078..b086109d16752d0609cfc35c90dc4bfca1c1253e 100644 (file)
@@ -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,
index 09ed006c99c4693a5a33239ae8dea2ed15707ef5..9577a983cf01d73c223264518f59519352da45a3 100644 (file)
@@ -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<NonNullObject, Map<string, GenericStore>>()
+export const storesMap = new WeakMap<
+  Pinia,
+  Map<string, [StoreWithState<string, StateTree>, StateDescriptor<StateTree>]>
+>()
 
 /**
  * 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<string, StateTree> {
 
   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<Pinia>
+
+/**
+ * Plugin to extend every store
+ */
+export interface PiniaStorePlugin {
+  (pinia: Pinia): Partial<PiniaCustomProperties>
+}
+
+/**
+ * Every application must own its own pinia to be able to create stores
+ */
+export interface Pinia {
+  install: PluginFunction<void>
+
+  /**
+   * root state
+   */
+  state: Ref<Record<string, StateTree>>
+
+  /**
+   * 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<PiniaCustomProperties>>
+
+  /**
+   * Vue constructor retrieved when installing the pinia.
+   */
+  Vue: VueConstructor<Vue>
+}
+
+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!
+}
index 83f984314338adbbb6f0c6aff1882e5770e4df0a..a0d9006467ca806f35a7a41b275f39e7dc46b29e 100644 (file)
@@ -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'
 
index 7ecc7fff1e1f68788cbb8f5d72bf75ac74f58438..13a5fb4f957d6f0620509dda6dcbee2a86c1ba96 100644 (file)
@@ -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<T extends StateTree>(
   return target
 }
 
+/**
+ * Create an object of computed properties referring to
+ *
+ * @param rootStateRef - pinia.state
+ * @param id - unique name
+ */
+function computedFromState<T, Id extends string>(
+  rootStateRef: Ref<Record<Id, T>>,
+  id: Id
+) {
+  // let asComputed = computed<T>()
+  const reactiveObject = {} as {
+    [k in keyof T]: Ref<T[k]>
+  }
+  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<T>(refObject: Ref<T>) {
   // let asComputed = computed<T>()
   const reactiveObject = {} as {
@@ -56,120 +92,164 @@ function toComputed<T>(refObject: Ref<T>) {
 }
 
 /**
- * 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<string, Method>,
-  A extends Record<string, Method>
->(
+function initStore<Id extends string, S extends StateTree>(
   $id: Id,
-  buildState = () => ({} as S),
-  getters: G = {} as G,
-  actions: A = {} as A,
+  buildState: () => S = () => ({} as S),
   initialState?: S | undefined
-): Store<Id, S, G, A> {
-  const $state: Ref<S> = ref(initialState || buildState())
-  const _r = getActiveReq()
+): [StoreWithState<Id, S>, { get: () => S; set: (newValue: S) => void }] {
+  const pinia = getActivePinia()
+  pinia.Vue.set(pinia.state.value, $id, initialState || buildState())
+  // const state: Ref<S> = toRef(_p.state.value, $id)
 
   let isListening = true
   let subscriptions: SubscriptionCallback<S>[] = []
 
-  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<S>): 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<S>) {
     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, S> = {
     $id,
-    _r,
-    // @ts-ignore, `reactive` unwraps this making it of type S
-    $state: computed<S>({
-      get: () => $state.value,
-      set: (newState) => {
-        isListening = false
-        $state.value = newState
-        isListening = true
-      },
-    }),
+    _p: markRaw(pinia),
+
+    // $state is added underneath
 
     $patch,
     $subscribe,
     $reset,
-  }
+  } as StoreWithState<Id, S>
+
+  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<string, Method>,
+  A extends Record<string, Method>
+>(
+  partialStore: StoreWithState<Id, S>,
+  descriptor: StateDescriptor<S>,
+  $id: Id,
+  getters: G = {} as G,
+  actions: A = {} as A
+) {
+  const pinia = getActivePinia()
 
   const computedGetters: StoreWithGetters<G> = {} as StoreWithGetters<G>
   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<G>[typeof getterName]
   }
 
-  // const reactiveGetters = reactive(computedGetters)
-
   const wrappedActions: StoreWithActions<A> = {} as StoreWithActions<A>
   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<A>[typeof actionName]
   }
 
+  const extensions = pinia._p.reduce(
+    (extended, extender) => ({
+      ...extended,
+      ...extender(),
+    }),
+    {} as PiniaCustomProperties
+  )
+
   const store: Store<Id, S, G, A> = 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<Id, S, G, A>
 
+  // 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<Id, S, G, A> {
-    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<Id, S, G, A> {
+    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<Id, S, G, A>
-    if (!store) {
-      stores.set(
+    // let store = stores.get(id) as Store<Id, S, G, A>
+    let storeAndDescriptor = stores.get(id) as
+      | [StoreWithState<Id, S>, StateDescriptor<S>]
+      | 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<string, Method> | undefined,
+        actions as Record<string, Method> | undefined
       )
 
       if (isClient) useStoreDevtools(store)
+
+      return store
     }
 
-    return store
+    return buildStoreToUse(
+      storeAndDescriptor[0],
+      storeAndDescriptor[1],
+      id,
+      getters as Record<string, Method> | undefined,
+      actions as Record<string, Method> | undefined
+    )
   }
 }
index bebea62c830dcd94f4716516aa4509f31e918cf8..eb0c342db20054305059cf0668d9c6558422b1ab 100644 (file)
@@ -1,5 +1,15 @@
+import { Pinia } from './rootStore'
+
 export type StateTree = Record<string | number | symbol, any>
 
+/**
+ * Object descriptor for Object.defineProperty
+ */
+export interface StateDescriptor<S extends StateTree> {
+  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<Id extends string, S extends StateTree> {
   $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
index f7ccc3a3e5120c932044d7e0e262f4c0dd27ec07..f24eadd7ecb378767d3622be504b99b35ea316c9 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   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==