]> git.ipfire.org Git - thirdparty/vuejs/pinia.git/commitdiff
fix: correct lifespan of stores
authorEduardo San Martin Morote <posva13@gmail.com>
Thu, 31 Dec 2020 14:18:07 +0000 (15:18 +0100)
committerEduardo San Martin Morote <posva13@gmail.com>
Thu, 31 Dec 2020 14:18:07 +0000 (15:18 +0100)
Fix #255

BREAKING CHANGE: `setActiveReq()` has been renamed to
`setActivePinia()`. And now receives the application's pinia as the
first parameter instead of an arbitrary object (like a Node http
    request). **This affects particularily users doing SSR** but also
enables them to write universal code.

16 files changed:
README.md
__tests__/actions.spec.ts
__tests__/getters.spec.ts
__tests__/lifespan.spec.ts [new file with mode: 0644]
__tests__/rootState.spec.ts
__tests__/state.spec.ts
__tests__/store.patch.spec.ts
__tests__/store.spec.ts
__tests__/subscriptions.spec.ts
old test ssr/app/main.ts
src/devtools.ts
src/index.ts
src/rootStore.ts
src/store.ts
src/types.ts
src/withScope.ts [deleted file]

index a82ff2434d5379a2ddac21ed6155de4e33d4bf4f..e4b938eadcf96ba83d6d70eea9c10445677ce103 100644 (file)
--- a/README.md
+++ b/README.md
@@ -110,7 +110,7 @@ export const useMainStore = defineStore({
     // use getters in other getters
     doubleCountPlusOne() {
       return this.doubleCount * 2
-    }
+    },
   },
   // optional actions
   actions: {
@@ -143,9 +143,9 @@ export default defineComponent({
 })
 ```
 
-Note: the SSR implementation is yet to be decided on Pinia, but if you intend having SSR on your application, you should avoid using `useStore` functions at the root level of a file to make sure the correct store is retrieved for your request. Here is an example:
+Note: the SSR implementation on Pinia might change, but if you intend having SSR on your application, you should avoid using `useStore` functions at the root level of a file to make sure the correct store is retrieved for your currently running application instance. Here is an example:
 
-**Avoid doing this\***:
+**Avoid doing this**:
 
 ```ts
 import { createRouter } from 'vue-router'
@@ -175,10 +175,13 @@ export default defineComponent({
 })
 
 // In a different file...
+const pinia = createPinia()
+app.use(pinia)
 
 router.beforeEach((to) => {
-  // ✅ This will work (requires an extra param for SSR, see below)
-  const main = useMainStore()
+  // ✅ This will work (requires pinia param when outside of setup on both
+  // Client and Server. See the SSR section below for more information)
+  const main = useMainStore(pinia)
 
   if (to.meta.requiresAuth && !main.isLoggedIn) return '/login'
 })
index f1fdd32478714237ac3360dca62aba888a4c529d..7e90bdec5eeec4fb9366ab41f711e3e3555dcbcc 100644 (file)
@@ -1,9 +1,9 @@
-import { defineStore, setActiveReq } from '../src'
+import { createPinia, defineStore, setActivePinia } from '../src'
 
 describe('Actions', () => {
   const useStore = () => {
     // create a new store
-    setActiveReq({})
+    setActivePinia(createPinia())
     return defineStore({
       id: 'main',
       state: () => ({
@@ -82,14 +82,14 @@ describe('Actions', () => {
     expect(store.$state.nested.foo).toBe('bar')
   })
 
-  it('supports being called between requests', () => {
-    const req1 = {}
-    const req2 = {}
-    setActiveReq(req1)
+  it('supports being called between piniauests', () => {
+    const pinia1 = createPinia()
+    const pinia2 = createPinia()
+    setActivePinia(pinia1)
     const aStore = useA()
 
-    // simulate a different request
-    setActiveReq(req2)
+    // simulate a different piniauest
+    setActivePinia(pinia2)
     const bStore = useB()
     bStore.$state.b = 'c'
 
@@ -99,19 +99,19 @@ describe('Actions', () => {
     expect(bStore.$state.b).toBe('c')
   })
 
-  it('can force the req', () => {
-    const req1 = {}
-    const req2 = {}
-    const aStore = useA(req1)
+  it('can force the pinia', () => {
+    const pinia1 = createPinia()
+    const pinia2 = createPinia()
+    const aStore = useA(pinia1)
 
-    let bStore = useB(req2)
+    let bStore = useB(pinia2)
     bStore.$state.b = 'c'
 
     aStore.swap()
     expect(aStore.$state.a).toBe('b')
     // a different instance of b store was used
     expect(bStore.$state.b).toBe('c')
-    bStore = useB(req1)
+    bStore = useB(pinia1)
     expect(bStore.$state.b).toBe('a')
   })
 })
index 29aac184eebd41e2edc378921e8e5ec543c74099..2e066a679cc003b6cae1df9e43346c3251420675 100644 (file)
@@ -1,9 +1,9 @@
-import { defineStore, setActiveReq } from '../src'
+import { createPinia, defineStore, setActivePinia } from '../src'
 
 describe('Getters', () => {
   const useStore = () => {
     // create a new store
-    setActiveReq({})
+    setActivePinia(createPinia())
     return defineStore({
       id: 'main',
       state: () => ({
@@ -52,14 +52,14 @@ describe('Getters', () => {
     expect(store.upperCaseName).toBe('ED')
   })
 
-  it('supports changing between requests', () => {
-    const req1 = {}
-    const req2 = {}
-    setActiveReq(req1)
+  it('supports changing between piniauests', () => {
+    const pinia1 = createPinia()
+    const pinia2 = createPinia()
+    setActivePinia(pinia1)
     const aStore = useA()
 
-    // simulate a different request
-    setActiveReq(req2)
+    // simulate a different piniauest
+    setActivePinia(pinia2)
     const bStore = useB()
     bStore.b = 'c'
 
diff --git a/__tests__/lifespan.spec.ts b/__tests__/lifespan.spec.ts
new file mode 100644 (file)
index 0000000..0e25e93
--- /dev/null
@@ -0,0 +1,105 @@
+import { createPinia, defineStore, setActivePinia } from '../src'
+import { mount } from '@vue/test-utils'
+import { watch, nextTick, ref } from 'vue'
+
+describe('Store Lifespan', () => {
+  function defineMyStore() {
+    return defineStore({
+      id: 'main',
+      state: () => ({
+        a: true,
+        n: 0,
+        nested: {
+          foo: 'foo',
+          a: { b: 'string' },
+        },
+      }),
+      getters: {
+        double() {
+          return this.n * 2
+        },
+        notA() {
+          return !this.a
+        },
+      },
+    })
+  }
+
+  const pinia = createPinia()
+  // let pinia: object
+
+  // const useStore = () => {
+  //   // create a new store
+  //   pinia = {}
+  //   setActivePinia(pinia)
+  //   return defineMyStore()()
+  // }
+
+  it('bug report', async () => {
+    const inComponentWatch = jest.fn()
+
+    const n = ref(0)
+
+    const wrapper = mount(
+      {
+        render: () => null,
+        setup() {
+          watch(() => n.value, inComponentWatch)
+          n.value++
+        },
+      },
+      {
+        global: {
+          plugins: [pinia],
+        },
+      }
+    )
+
+    await wrapper.unmount()
+
+    expect(inComponentWatch).toHaveBeenCalledTimes(1)
+
+    // store!.n++
+    n.value++
+    await nextTick()
+    expect(inComponentWatch).toHaveBeenCalledTimes(1)
+  })
+
+  it('state reactivity outlives component life', async () => {
+    const useStore = defineMyStore()
+    setActivePinia(createPinia())
+
+    const inComponentWatch = jest.fn()
+
+    let store: ReturnType<typeof useStore>
+
+    const n = ref(0)
+
+    const wrapper = mount(
+      {
+        render: () => null,
+        setup() {
+          store = useStore()
+          // watch(() => store.n, inComponentWatch)
+          watch(() => n.value, inComponentWatch)
+          store.n++
+          n.value++
+        },
+      },
+      {
+        global: {
+          plugins: [pinia],
+        },
+      }
+    )
+
+    await wrapper.unmount()
+
+    expect(inComponentWatch).toHaveBeenCalledTimes(1)
+
+    // store!.n++
+    n.value++
+    await nextTick()
+    expect(inComponentWatch).toHaveBeenCalledTimes(1)
+  })
+})
index 5c9519ae67bd314ee00d95b4c99c5f89c2c2368c..6a8e056fed08ba5bd9a7e4ea0fdd485d55fdc853 100644 (file)
@@ -1,4 +1,4 @@
-import { defineStore, getRootState } from '../src'
+import { createPinia, defineStore, getRootState } from '../src'
 
 describe('Root State', () => {
   const useA = defineStore({
@@ -12,35 +12,35 @@ describe('Root State', () => {
   })
 
   it('works with no stores', () => {
-    expect(getRootState({})).toEqual({})
+    expect(getRootState(createPinia())).toEqual({})
   })
 
   it('retrieves the root state of one store', () => {
-    const req = {}
-    useA(req)
-    expect(getRootState(req)).toEqual({
+    const pinia = createPinia()
+    useA(pinia)
+    expect(getRootState(pinia)).toEqual({
       a: { a: 'a' },
     })
   })
 
-  it('does not mix up different requests', () => {
-    const req1 = {}
-    const req2 = {}
-    useA(req1)
-    useB(req2)
-    expect(getRootState(req1)).toEqual({
+  it('does not mix up different piniauests', () => {
+    const pinia1 = createPinia()
+    const pinia2 = createPinia()
+    useA(pinia1)
+    useB(pinia2)
+    expect(getRootState(pinia1)).toEqual({
       a: { a: 'a' },
     })
-    expect(getRootState(req2)).toEqual({
+    expect(getRootState(pinia2)).toEqual({
       b: { b: 'b' },
     })
   })
 
   it('can hold multiple stores', () => {
-    const req1 = {}
-    useA(req1)
-    useB(req1)
-    expect(getRootState(req1)).toEqual({
+    const pinia1 = createPinia()
+    useA(pinia1)
+    useB(pinia1)
+    expect(getRootState(pinia1)).toEqual({
       a: { a: 'a' },
       b: { b: 'b' },
     })
index 3a3ba4978bf90648d8967a8c9f8102c786069303..5734913404b7c21670bfc7b9dc701b3c2b34828f 100644 (file)
@@ -1,10 +1,10 @@
-import { defineStore, setActiveReq } from '../src'
-import { computed } from 'vue'
+import { createPinia, defineStore, setActivePinia } from '../src'
+import { computed, nextTick, watch } from 'vue'
 
 describe('State', () => {
   const useStore = () => {
     // create a new store
-    setActiveReq({})
+    setActivePinia(createPinia())
     return defineStore({
       id: 'main',
       state: () => ({
@@ -28,4 +28,25 @@ describe('State', () => {
     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)
+  })
 })
index b3a1abdb0c8fd2c4a86567c0daa5154f5721cd2d..4469a45b325d018b2fec5a7742a1fffc0fa3d8a7 100644 (file)
@@ -1,9 +1,9 @@
-import { defineStore, setActiveReq } from '../src'
+import { createPinia, defineStore, setActivePinia } from '../src'
 
 describe('store.$patch', () => {
   const useStore = () => {
     // create a new store
-    setActiveReq({})
+    setActivePinia(createPinia())
     return defineStore({
       id: 'main',
       state: () => ({
index dde45999cb56b4cf63a7de970cf29cf51e87cbc1..601c16f6e57b38e25ac4b004b46becfcc27bee1d 100644 (file)
@@ -1,18 +1,19 @@
 import {
   createPinia,
   defineStore,
-  setActiveReq,
+  setActivePinia,
   setStateProvider,
+  Pinia,
 } from '../src'
 import { mount } from '@vue/test-utils'
-import { getCurrentInstance } from 'vue'
+import { getCurrentInstance, nextTick, watch } from 'vue'
 
 describe('Store', () => {
-  let req: object
+  let pinia: Pinia
   const useStore = () => {
     // create a new store
-    req = {}
-    setActiveReq(req)
+    pinia = createPinia()
+    setActivePinia(pinia)
     return defineStore({
       id: 'main',
       state: () => ({
@@ -60,7 +61,7 @@ describe('Store', () => {
   })
 
   it('can hydrate the state', () => {
-    setActiveReq({})
+    setActivePinia(createPinia())
     const useStore = defineStore({
       id: 'main',
       state: () => ({
@@ -156,43 +157,50 @@ describe('Store', () => {
     )
   })
 
-  it('should outlive components', () => {
-    let store: ReturnType<typeof useStore> | undefined
+  it('should outlive components', async () => {
+    const pinia = createPinia()
+    const useStore = defineStore({
+      id: 'main',
+      state: () => ({ n: 0 }),
+    })
 
     const wrapper = mount(
       {
         setup() {
-          store = useStore()
+          const store = useStore()
 
           return { store }
         },
 
-        template: `a: {{ store.a }}`,
+        template: `n: {{ store.n }}`,
       },
       {
         global: {
-          plugins: [createPinia()],
+          plugins: [pinia],
         },
       }
     )
 
-    expect(wrapper.html()).toBe('a: true')
+    expect(wrapper.html()).toBe('n: 0')
 
-    if (!store) throw new Error('no store')
+    const store = useStore(pinia)
 
     const spy = jest.fn()
-    store.$subscribe(spy)
+    watch(() => store.n, spy)
 
     expect(spy).toHaveBeenCalledTimes(0)
-    store.a = !store.a
+    store.n++
+    await nextTick()
     expect(spy).toHaveBeenCalledTimes(1)
+    expect(wrapper.html()).toBe('n: 1')
 
-    wrapper.unmount()
-    store.a = !store.a
+    await wrapper.unmount()
+    store.n++
+    await nextTick()
     expect(spy).toHaveBeenCalledTimes(2)
   })
 
-  it.skip('should not break getCurrentInstance', () => {
+  it('should not break getCurrentInstance', () => {
     let store: ReturnType<typeof useStore> | undefined
 
     let i1: any = {}
index 859f3cc29473f08102a4dd18a88d0c40592cafa1..e1bd2b807e2fb8e51880d513f85d97fcd081d647 100644 (file)
@@ -1,9 +1,9 @@
-import { defineStore, setActiveReq } from '../src'
+import { createPinia, defineStore, setActivePinia } from '../src'
 
 describe('Subscriptions', () => {
   const useStore = () => {
     // create a new store
-    setActiveReq({})
+    setActivePinia(createPinia())
     return defineStore({
       id: 'main',
       state: () => ({
index ea8fa3621610aae918dc4dfac5d28641e440f4cf..dab1f19994de74fa108f03dd5f6cd7ac48c20ac6 100644 (file)
@@ -3,20 +3,21 @@ import Vue from 'vue'
 import App from './App'
 import { useStore } from './store'
 import { setActiveReq } from '../../../src'
+import { createPinia } from '../../src'
 
 // Done in setup.ts
 // Vue.use(VueCompositionApi)
 
 export function createApp() {
   // create router and store instances
-  setActiveReq({})
+  setActiveReq(createPinia())
   const store = useStore()
 
   store.state.counter++
 
   // create the app instance, injecting both the router and the store
   const app = new Vue({
-    render: h => h(App),
+    render: (h) => h(App),
   })
 
   // expose the app, the router and the store.
index 29f86c9b327c6421daf2afba21922e404d672327..28d7f7a960f978bd44593c064d9cd81b7aa833e1 100644 (file)
@@ -5,7 +5,7 @@ import {
 } from '@vue/devtools-api'
 import { App } from 'vue'
 import { getRegisteredStores, registerStore } from './rootStore'
-import { GenericStore, NonNullObject } from './types'
+import { GenericStore } from './types'
 
 function formatDisplay(display: string) {
   return {
@@ -17,7 +17,7 @@ function formatDisplay(display: string) {
 
 let isAlreadyInstalled: boolean | undefined
 
-export function addDevtools(app: App, store: GenericStore, req: NonNullObject) {
+export function addDevtools(app: App, store: GenericStore) {
   registerStore(store)
   setupDevtoolsPlugin(
     {
index e14d3744073db2dc437c4917356523eb09b9a9a4..2a82b6010a124fdc8613b8d16289f7374411e37f 100644 (file)
@@ -1,8 +1,9 @@
 export {
-  setActiveReq,
+  setActivePinia,
   setStateProvider,
   getRootState,
   createPinia,
+  Pinia,
 } from './rootStore'
 export { defineStore } from './store'
 export { createStore } from './deprecated'
index f99d17b26ac7e915def242e5376cde93bfa2022f..9ad60984bd4f591b65b8167f7989fb5cc60239a6 100644 (file)
@@ -1,23 +1,43 @@
-import { App, InjectionKey, Plugin } from 'vue'
+import { App, InjectionKey, Plugin, Ref, ref, warn } from 'vue'
 import { IS_CLIENT } from './env'
-import { NonNullObject, StateTree, GenericStore } from './types'
+import {
+  StateTree,
+  GenericStore,
+  StoreWithState,
+  StateDescriptor,
+} from './types'
 
 /**
- * setActiveReq must be called to handle SSR at the top of functions like `fetch`, `setup`, `serverPrefetch` and others
+ * setActivePinia must be called to handle SSR at the top of functions like
+ * `fetch`, `setup`, `serverPrefetch` and others
  */
-export let activeReq: NonNullObject = {}
-export const setActiveReq = (req: NonNullObject | undefined) =>
-  req && (activeReq = req)
+export let activePinia: Pinia | undefined
+export const setActivePinia = (pinia: Pinia | undefined) =>
+  (activePinia = pinia)
+
+export const getActivePinia = () => {
+  if (__DEV__ && !activePinia) {
+    warn(
+      `[🍍]: getActivePinia was called with no active Pinia. Did you forget to install pinia?\n\n` +
+        `const pinia = createPinia()\n` +
+        `app.use(pinia)\n\n` +
+        `This will fail in production.`
+    )
+  }
 
-export const getActiveReq = () => activeReq
+  return activePinia!
+}
 
 /**
  * The api needs more work we must be able to use the store easily in any
  * function by calling `useStore` to get the store Instance and we also need to
- * be able to reset the store instance between requests on the server
+ * be able to reset the store instance between piniauests 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
@@ -29,33 +49,24 @@ interface StateProvider {
 /**
  * Map of initial states used for hydration
  */
-export const stateProviders = new WeakMap<NonNullObject, StateProvider>()
+export const stateProviders = new WeakMap<Pinia, StateProvider>()
 
 export function setStateProvider(stateProvider: StateProvider) {
-  stateProviders.set(getActiveReq(), stateProvider)
+  stateProviders.set(getActivePinia(), stateProvider)
 }
 
 export function getInitialState(id: string): StateTree | undefined {
-  const provider = stateProviders.get(getActiveReq())
+  const provider = stateProviders.get(getActivePinia())
   return provider && provider()[id]
 }
 
 /**
  * Gets the root state of all active stores. This is useful when reporting an application crash by
  * retrieving the problematic state and send it to your error tracking service.
- * @param req - request key
+ * @param pinia - piniauest key
  */
-export function getRootState(req: NonNullObject): Record<string, StateTree> {
-  const stores = storesMap.get(req)
-  if (!stores) return {}
-  const rootState = {} as Record<string, StateTree>
-
-  // forEach is the only one that also works on IE11
-  stores.forEach((store) => {
-    rootState[store.$id] = store.$state
-  })
-
-  return rootState
+export function getRootState(pinia: Pinia): Record<string, StateTree> {
+  return pinia.state.value
 }
 
 /**
@@ -67,15 +78,19 @@ export const getClientApp = () => clientApp
 
 export interface Pinia {
   install: Exclude<Plugin['install'], undefined>
-  store<F extends (...args: any[]) => any>(useStore: F): ReturnType<F>
+
+  /**
+   * root state
+   */
+  state: Ref<any>
 }
 
 declare module '@vue/runtime-core' {
   export interface ComponentCustomProperties {
     /**
-     * Instantiate a store anywhere
+     * Access to the application's Pinia
      */
-    $pinia: Pinia['store']
+    $pinia: Pinia
   }
 }
 
@@ -84,21 +99,20 @@ export const piniaSymbol = (__DEV__
   : Symbol()) as InjectionKey<Pinia>
 
 export function createPinia(): Pinia {
+  const state = ref({})
+
   const pinia: Pinia = {
     install(app: App) {
       app.provide(piniaSymbol, pinia)
-      app.config.globalProperties.$pinia = pinia.store
+      app.config.globalProperties.$pinia = pinia
       // TODO: write test
       // only set the app on client
       if (__BROWSER__ && IS_CLIENT) {
         setClientApp(app)
       }
     },
-    store<F extends (req?: NonNullObject) => GenericStore>(
-      useStore: F
-    ): ReturnType<F> {
-      return useStore(pinia) as ReturnType<F>
-    },
+
+    state,
   }
 
   return pinia
index 2cfbb43ddd9c04abd48b8df19fb8dbf15c6d0788..f9648d2bde955dd92ffce427e87420743bee647b 100644 (file)
@@ -1,12 +1,4 @@
-import {
-  ref,
-  watch,
-  computed,
-  Ref,
-  reactive,
-  inject,
-  getCurrentInstance,
-} from 'vue'
+import { watch, computed, Ref, inject, getCurrentInstance, reactive } from 'vue'
 import {
   StateTree,
   StoreWithState,
@@ -16,19 +8,20 @@ import {
   StoreWithGetters,
   Store,
   StoreWithActions,
+  StateDescriptor,
   Method,
 } from './types'
 import {
-  getActiveReq,
-  setActiveReq,
+  getActivePinia,
+  setActivePinia,
   storesMap,
   getInitialState,
   getClientApp,
   piniaSymbol,
+  Pinia,
 } from './rootStore'
 import { addDevtools } from './devtools'
 import { IS_CLIENT } from './env'
-import { withScope } from './withScope'
 
 function innerPatch<T extends StateTree>(
   target: T,
@@ -49,16 +42,26 @@ function innerPatch<T extends StateTree>(
   return target
 }
 
-function toComputed<T>(refObject: Ref<T>) {
+/**
+ * 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]>
   }
-  for (const key in refObject.value) {
+  const state = rootStateRef.value[id]
+  for (const key in state) {
     // @ts-ignore: the key matches
     reactiveObject[key] = computed({
-      get: () => refObject.value[key as keyof T],
-      set: (value) => (refObject.value[key as keyof T] = value),
+      get: () => rootStateRef.value[id][key as keyof T],
+      set: (value) => (rootStateRef.value[id][key as keyof T] = value),
     })
   }
 
@@ -66,118 +69,155 @@ function toComputed<T>(refObject: Ref<T>) {
 }
 
 /**
- * Creates a store instance
+ * 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: () => S = () => ({} as S),
-  getters: G = {} as G,
-  actions: A = {} as A,
   initialState?: S | undefined
-): Store<Id, S, G, A> {
-  const state: Ref<S> = ref(initialState || buildState())
-  // TODO: remove req part?
-  const _r = getActiveReq()
+): [StoreWithState<Id, S>, { get: () => S; set: (newValue: S) => void }] {
+  const _p = getActivePinia()
+  _p.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(_p.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
+        _p.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(
+      () => _p.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()
+    _p.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,
+
+    // $state is added underneath
 
     $patch,
     $subscribe,
     $reset,
-  }
+  } as StoreWithState<Id, S>
+
+  return [
+    storeWithState,
+    {
+      get: () => _p.state.value[$id] as S,
+      set: (newState: S) => {
+        isListening = false
+        _p.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 _p = getActivePinia()
 
   const computedGetters: StoreWithGetters<G> = {} as StoreWithGetters<G>
   for (const getterName in getters) {
     computedGetters[getterName] = computed(() => {
-      setActiveReq(_r)
+      setActivePinia(_p)
       // 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(_p)
       // eslint-disable-next-line
       return actions[actionName].apply(store, (arguments as unknown) as any[])
     } as StoreWithActions<A>[typeof actionName]
   }
 
   const store: Store<Id, S, G, A> = reactive({
-    ...storeWithState,
+    ...partialStore,
     // using this means no new properties can be added as state
-    ...toComputed(state),
+    ...computedFromState(_p.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
 }
 
@@ -202,29 +242,29 @@ export function defineStore<
 }) {
   const { id, state, getters, actions } = options
 
-  return function useStore(reqKey?: object | null): Store<Id, S, G, A> {
+  return function useStore(pinia?: Pinia | null): Store<Id, S, G, A> {
     // avoid injecting if `useStore` when not possible
-    reqKey = reqKey || (getCurrentInstance() && inject(piniaSymbol))
-    if (reqKey) setActiveReq(reqKey)
-    // TODO: worth warning on server if no reqKey as it can leak data
-    const req = getActiveReq()
-    let stores = storesMap.get(req)
-    if (!stores) storesMap.set(req, (stores = new Map()))
-
-    let store = stores.get(id) as Store<Id, S, G, A>
-    if (!store) {
-      stores.set(
+    pinia = pinia || (getCurrentInstance() && inject(piniaSymbol))
+    if (pinia) setActivePinia(pinia)
+    // TODO: worth warning on server if no piniaKey as it can leak data
+    pinia = getActivePinia()
+    let stores = storesMap.get(pinia)
+    if (!stores) storesMap.set(pinia, (stores = new Map()))
+
+    let storeAndDescriptor = stores.get(id) as
+      | [StoreWithState<Id, S>, StateDescriptor<S>]
+      | undefined
+    if (!storeAndDescriptor) {
+      storeAndDescriptor = initStore(id, state, getInitialState(id))
+
+      stores.set(id, storeAndDescriptor)
+
+      const store = buildStoreToUse(
+        storeAndDescriptor[0],
+        storeAndDescriptor[1],
         id,
-        (store = withScope(
-          () =>
-            buildStore(
-              id,
-              state,
-              getters as Record<string, Method> | undefined,
-              actions as Record<string, Method> | undefined,
-              getInitialState(id)
-            ) as Store<Id, S, G, A>
-        ))
+        getters as Record<string, Method> | undefined,
+        actions as Record<string, Method> | undefined
       )
 
       if (
@@ -234,7 +274,7 @@ export function defineStore<
       ) {
         const app = getClientApp()
         if (app) {
-          addDevtools(app, store, req)
+          addDevtools(app, store)
         } else if (!isDevWarned && !__TEST__) {
           isDevWarned = true
           console.warn(
@@ -246,8 +286,16 @@ export function defineStore<
           )
         }
       }
+
+      return store
     }
 
-    return store
+    return buildStoreToUse(
+      storeAndDescriptor[0],
+      storeAndDescriptor[1],
+      id,
+      getters as Record<string, Method> | undefined,
+      actions as Record<string, Method> | undefined
+    )
   }
 }
index cb5e70fc5c1941f504464885f4304b8dbee596a3..a8a418edac467931f21b55a13f8ad4f4b94720a2 100644 (file)
@@ -1,7 +1,16 @@
 import { Ref } from 'vue'
+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
@@ -14,8 +23,6 @@ export function isPlainObject(
   )
 }
 
-export type NonNullObject = Record<any, any>
-
 export interface StoreGetter<S extends StateTree, T = any> {
   (state: S, getters: Record<string, Ref<any>>): T
 }
@@ -40,11 +47,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
diff --git a/src/withScope.ts b/src/withScope.ts
deleted file mode 100644 (file)
index 8631513..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import { createApp } from 'vue'
-import { IS_CLIENT } from './env'
-
-export function withScope<T>(factory: () => T): T {
-  if (__BROWSER__ && IS_CLIENT) {
-    let store: T
-    createApp({
-      setup() {
-        store = factory()
-        return () => null
-      },
-    }).mount(document.createElement('div'))
-    // TODO: collect apps to be unmounted when the main app is unmounted
-    return store!
-  } else {
-    // no need to wrap with an app on SSR
-    return factory()
-  }
-}