]> git.ipfire.org Git - thirdparty/vuejs/pinia.git/commitdiff
feat(types): infer args and returned value for onAction
authorEduardo San Martin Morote <posva13@gmail.com>
Wed, 12 May 2021 14:54:25 +0000 (16:54 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Wed, 12 May 2021 14:54:25 +0000 (16:54 +0200)
__tests__/onAction.spec.ts
src/devtools/plugin.ts
src/mapHelpers.ts
src/rootStore.ts
src/store.ts
src/types.ts
test-dts/onAction.test-d.ts [new file with mode: 0644]

index dd3d3cc6792747e19fbd9ed0da964bb71f984e61..2789a9ec691ce239887faccde4c5d428b74ab3e7 100644 (file)
@@ -68,16 +68,16 @@ describe('Subscriptions', () => {
 
   it('calls after with the returned value', async () => {
     const spy = jest.fn()
-    store.$onAction(({ after, name, store }) => {
-      name
-      if (name === 'upperName') {
-        after((ret) => {
+    // Cannot destructure because of https://github.com/microsoft/TypeScript/issues/38020
+    store.$onAction((context) => {
+      if (context.name === 'upperName') {
+        context.after((ret) => {
           // @ts-expect-error
           ret * 2
           ret.toUpperCase()
         })
       }
-      after(spy)
+      context.after(spy)
     })
     expect(store.upperName()).toBe('EDUARDO')
     await nextTick()
index 90cfcd955d7210046028a6373b52d2f972055f88..d1c8dabdb1158aa6ec7880d57a505afdc8f166ed 100644 (file)
@@ -6,7 +6,7 @@ import {
   GettersTree,
   MutationType,
   StateTree,
-  _Method,
+  ActionsTree,
 } from '../types'
 import {
   formatEventData,
@@ -259,7 +259,7 @@ export function devtoolsPlugin<
   Id extends string = string,
   S extends StateTree = StateTree,
   G extends GettersTree<S> = GettersTree<S>,
-  A = Record<string, _Method>
+  A /* extends ActionsTree */ = ActionsTree
 >({ app, store, options, pinia }: PiniaPluginContext<Id, S, G, A>) {
   const wrappedActions = {} as Pick<typeof store, keyof A>
 
@@ -295,7 +295,8 @@ export function devtoolsPlugin<
     }
   }
 
-  addDevtools(app, store)
+  // FIXME: can this be fixed?
+  addDevtools(app, store as unknown as Store)
 
   return { ...wrappedActions }
 }
index 47c5c9b0885c1218b1fb06b7b3e64464c9866375..419f4a9af63506410732f004ccff0541f7a9ce74 100644 (file)
@@ -5,6 +5,7 @@ import {
   StateTree,
   Store,
   StoreDefinition,
+  ActionsTree,
 } from './types'
 
 /**
@@ -55,14 +56,20 @@ function getCachedStore<
   Id extends string = string,
   S extends StateTree = StateTree,
   G extends GettersTree<S> = GettersTree<S>,
-  A = Record<string, _Method>
+  A /* extends ActionsTree */ = ActionsTree
 >(
   vm: ComponentPublicInstance,
   useStore: StoreDefinition<Id, S, G, A>
 ): Store<Id, S, G, A> {
   const cache = '_pStores' in vm ? vm._pStores! : (vm._pStores = {})
   const id = useStore.$id
-  return (cache[id] || (cache[id] = useStore(vm.$pinia))) as Store<Id, S, G, A>
+  return (cache[id] ||
+    (cache[id] = useStore(vm.$pinia) as unknown as Store)) as unknown as Store<
+    Id,
+    S,
+    G,
+    A
+  >
 }
 
 export let mapStoreSuffix = 'Store'
index b1d85d9dc84a01e110f8a7aa537beb27d9056fce..e991852bbe7bcee00a8af13d80cfa0d4edd54ff9 100644 (file)
@@ -8,6 +8,7 @@ import {
   DefineStoreOptions,
   Store,
   GettersTree,
+  ActionsTree,
 } from './types'
 
 /**
@@ -119,7 +120,7 @@ export interface PiniaPluginContext<
   Id extends string = string,
   S extends StateTree = StateTree,
   G extends GettersTree<S> = GettersTree<S>,
-  A = Record<string, _Method>
+  A /* extends ActionsTree */ = ActionsTree
 > {
   /**
    * pinia instance.
index 6ed81b7378e6163b4a2b95bd4bcaaa8382c5d47e..f927028fbec8c13d0c36f9130dfabf1a6d4ab6a3 100644 (file)
@@ -28,6 +28,8 @@ import {
   GettersTree,
   MutationType,
   StoreOnActionListener,
+  UnwrapPromise,
+  ActionsTree,
 } from './types'
 import {
   getActivePinia,
@@ -92,12 +94,17 @@ function computedFromState<T, Id extends string>(
  * @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<Id extends string, S extends StateTree>(
+function initStore<
+  Id extends string,
+  S extends StateTree,
+  G extends GettersTree<S>,
+  A /* extends ActionsTree */
+>(
   $id: Id,
   buildState: () => S = () => ({} as S),
   initialState?: S | undefined
 ): [
-  StoreWithState<Id, S>,
+  StoreWithState<Id, S, G, A>,
   { get: () => S; set: (newValue: S) => void },
   InjectionKey<Store>
 ] {
@@ -107,7 +114,7 @@ function initStore<Id extends string, S extends StateTree>(
 
   let isListening = true
   let subscriptions: SubscriptionCallback<S>[] = []
-  let actionSubscriptions: StoreOnActionListener[] = []
+  let actionSubscriptions: StoreOnActionListener<Id, S, G, A>[] = []
   let debuggerEvents: DebuggerEvent[] | DebuggerEvent
 
   function $patch(stateMutation: (state: S) => void): void
@@ -199,7 +206,7 @@ function initStore<Id extends string, S extends StateTree>(
     return removeSubscription
   }
 
-  function $onAction(callback: StoreOnActionListener) {
+  function $onAction(callback: StoreOnActionListener<Id, S, G, A>) {
     actionSubscriptions.push(callback)
 
     const removeSubscription = () => {
@@ -220,7 +227,7 @@ function initStore<Id extends string, S extends StateTree>(
     pinia.state.value[$id] = buildState()
   }
 
-  const storeWithState: StoreWithState<Id, S> = {
+  const storeWithState: StoreWithState<Id, S, G, A> = {
     $id,
     _p: pinia,
     _as: actionSubscriptions,
@@ -231,7 +238,7 @@ function initStore<Id extends string, S extends StateTree>(
     $subscribe,
     $onAction,
     $reset,
-  } as StoreWithState<Id, S>
+  } as StoreWithState<Id, S, G, A>
 
   const injectionSymbol = __DEV__
     ? Symbol(`PiniaStore(${$id})`)
@@ -269,9 +276,9 @@ function buildStoreToUse<
   Id extends string,
   S extends StateTree,
   G extends GettersTree<S>,
-  A extends Record<string, _Method>
+  A extends ActionsTree
 >(
-  partialStore: StoreWithState<Id, S>,
+  partialStore: StoreWithState<Id, S, G, A>,
   descriptor: StateDescriptor<S>,
   $id: Id,
   getters: G = {} as G,
@@ -295,11 +302,11 @@ function buildStoreToUse<
   for (const actionName in actions) {
     wrappedActions[actionName] = function (this: Store<Id, S, G, A>) {
       setActivePinia(pinia)
-      const args = Array.from(arguments)
+      const args = Array.from(arguments) as Parameters<A[typeof actionName]>
       const localStore = this || store
 
       let afterCallback: (
-        resolvedReturn: ReturnType<typeof actions[typeof actionName]>
+        resolvedReturn: UnwrapPromise<ReturnType<A[typeof actionName]>>
       ) => void = noop
       let onErrorCallback: (error: unknown) => void = noop
       function after(callback: typeof afterCallback) {
@@ -310,13 +317,17 @@ function buildStoreToUse<
       }
 
       partialStore._as.forEach((callback) => {
+        // @ts-expect-error
         callback({ args, name: actionName, store: localStore, after, onError })
       })
 
-      let ret: ReturnType<typeof actions[typeof actionName]>
+      let ret: ReturnType<A[typeof actionName]>
       try {
         ret = actions[actionName].apply(localStore, args as unknown as any[])
-        Promise.resolve(ret).then(afterCallback).catch(onErrorCallback)
+        Promise.resolve(ret)
+          // @ts-expect-error: can't work this out
+          .then(afterCallback)
+          .catch(onErrorCallback)
       } catch (error) {
         onErrorCallback(error)
         throw error
@@ -348,6 +359,7 @@ function buildStoreToUse<
 
   // apply all plugins
   pinia._p.forEach((extender) => {
+    // @ts-expect-error: conflict between A and ActionsTree
     assign(store, extender({ store, app: pinia._a, pinia, options }))
   })
 
@@ -362,7 +374,8 @@ export function defineStore<
   Id extends string,
   S extends StateTree,
   G extends GettersTree<S>,
-  A /* extends Record<string, StoreAction> */
+  // cannot extends ActionsTree because we loose the typings
+  A /* extends ActionsTree */
 >(options: DefineStoreOptions<Id, S, G, A>): StoreDefinition<Id, S, G, A> {
   const { id, state, getters, actions } = options
 
@@ -380,7 +393,7 @@ export function defineStore<
 
     let storeAndDescriptor = stores.get(id) as
       | [
-          StoreWithState<Id, S>,
+          StoreWithState<Id, S, G, A>,
           StateDescriptor<S>,
           InjectionKey<Store<Id, S, G, A>>
         ]
@@ -388,15 +401,21 @@ export function defineStore<
     if (!storeAndDescriptor) {
       storeAndDescriptor = initStore(id, state, pinia.state.value[id])
 
-      stores.set(id, storeAndDescriptor)
+      // annoying to type
+      stores.set(id, storeAndDescriptor as any)
 
-      const store = buildStoreToUse(
+      const store = buildStoreToUse<
+        Id,
+        S,
+        G,
+        // @ts-expect-error: A without extends
+        A
+      >(
         storeAndDescriptor[0],
         storeAndDescriptor[1],
         id,
         getters as GettersTree<S> | undefined,
-        actions as Record<string, _Method> | undefined,
-        // @ts-expect-error: because of the extend on Actions
+        actions as A | undefined,
         options
       )
 
@@ -412,13 +431,18 @@ export function defineStore<
     return (
       // null avoids the warning for not found injection key
       (hasInstance && inject(storeAndDescriptor[2], null)) ||
-      buildStoreToUse(
+      buildStoreToUse<
+        Id,
+        S,
+        G,
+        // @ts-expect-error: A without extends
+        A
+      >(
         storeAndDescriptor[0],
         storeAndDescriptor[1],
         id,
         getters as GettersTree<S> | undefined,
-        actions as Record<string, _Method> | undefined,
-        // @ts-expect-error: because of the extend on Actions
+        actions as A | undefined,
         options
       )
     )
index 1f75c0d8be4aac89b92f4baadcf143ad9e40b292..8aaed3616f059b1e58574caee9d0abcddb689cfa 100644 (file)
@@ -59,44 +59,59 @@ export enum MutationType {
   // maybe reset? for $state = {} and $reset
 }
 
+export type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
+
 /**
  * Context object passed to callbacks of `store.$onAction(context => {})`
  */
-export interface StoreOnActionListenerContext {
-  /**
-   * Sets up a hook once the action is finished. It receives the return value of
-   * the action, if it's a Promise, it will be unwrapped.
-   */
-  after: (callback: (resolvedReturn: unknown) => void) => void
+export type StoreOnActionListenerContext<
+  Id extends string,
+  S extends StateTree,
+  G extends GettersTree<S>,
+  A /* extends ActionsTree */
+> = {
+  [Name in keyof A]: {
+    /**
+     * Name of the action
+     */
+    name: Name
 
-  /**
-   * Sets up a hook if the action fails.
-   */
-  onError: (callback: (error: unknown) => void) => void
+    /**
+     * Store that is invoking the action
+     */
+    store: Store<Id, S, G, A>
 
-  // TODO: pass generics
-  /**
-   * Store that is invoking the action
-   */
-  store: GenericStore
+    /**
+     * Parameters passed to the action
+     */
+    args: A[Name] extends _Method ? Parameters<A[Name]> : unknown[]
 
-  /**
-   * Name of the action
-   */
-  name: string
+    /**
+     * Sets up a hook once the action is finished. It receives the return value of
+     * the action, if it's a Promise, it will be unwrapped.
+     */
+    after: (
+      callback: A[Name] extends _Method
+        ? (resolvedReturn: UnwrapPromise<ReturnType<A[Name]>>) => void
+        : () => void
+    ) => void
 
-  /**
-   * Parameters passed to the action
-   */
-  args: any[]
-}
+    /**
+     * Sets up a hook if the action fails.
+     */
+    onError: (callback: (error: unknown) => void) => void
+  }
+}[keyof A]
 
 /**
  * Argument of `store.$onAction()`
  */
-export type StoreOnActionListener = (
-  context: StoreOnActionListenerContext
-) => void
+export type StoreOnActionListener<
+  Id extends string,
+  S extends StateTree,
+  G extends GettersTree<S>,
+  A /* extends ActionsTree */
+> = (context: StoreOnActionListenerContext<Id, S, G, A>) => void
 
 /**
  * Callback of a subscription
@@ -122,7 +137,12 @@ export type SubscriptionCallback<S> = (
  * Base store with state and functions
  * @internal
  */
-export interface StoreWithState<Id extends string, S extends StateTree> {
+export interface StoreWithState<
+  Id extends string,
+  S extends StateTree,
+  G extends GettersTree<S> = GettersTree<S>,
+  A /* extends ActionsTree */ = ActionsTree
+> {
   /**
    * Unique identifier of the store
    */
@@ -192,7 +212,7 @@ export interface StoreWithState<Id extends string, S extends StateTree> {
    *
    * @internal
    */
-  _as: StoreOnActionListener[]
+  _as: StoreOnActionListener<Id, S, G, A>[]
 
   /**
    * @alpha Please send feedback at https://github.com/posva/pinia/issues/240
@@ -228,7 +248,7 @@ export interface StoreWithState<Id extends string, S extends StateTree> {
    * @param callback - callback called before every action
    * @returns function that removes the watcher
    */
-  $onAction(callback: StoreOnActionListener): () => void
+  $onAction(callback: StoreOnActionListener<Id, S, G, A>): () => void
 }
 
 /**
@@ -276,27 +296,25 @@ export type StoreWithGetters<G> = {
  * Store type to build a store
  */
 export type Store<
-  Id extends string,
-  S extends StateTree,
-  G extends GettersTree<S>,
+  Id extends string = string,
+  S extends StateTree = StateTree,
+  G extends GettersTree<S> = GettersTree<S>,
   // has the actions without the context (this) for typings
-  A
-> = StoreWithState<Id, S> &
+  A /* extends ActionsTree */ = ActionsTree
+> = StoreWithState<Id, S, G, A> &
   UnwrapRef<S> &
   StoreWithGetters<G> &
   StoreWithActions<A> &
   PiniaCustomProperties<Id, S, G, A>
 
-// TODO: check if it's possible to add = to StoreDefinition and Store and cleanup GenericStore and the other one
-
 /**
  * Return type of `defineStore()`. Function that allows instantiating a store.
  */
 export interface StoreDefinition<
-  Id extends string,
-  S extends StateTree,
-  G extends GettersTree<S>,
-  A /* extends Record<string, StoreAction> */
+  Id extends string = string,
+  S extends StateTree = StateTree,
+  G extends GettersTree<S> = GettersTree<S>,
+  A /* extends ActionsTree */ = ActionsTree
 > {
   /**
    * Returns a store, creates it if necessary.
@@ -324,7 +342,7 @@ export interface PiniaCustomProperties<
   Id extends string = string,
   S extends StateTree = StateTree,
   G extends GettersTree<S> = GettersTree<S>,
-  A = Record<string, _Method>
+  A /* extends ActionsTree */ = ActionsTree
 > {}
 
 /**
@@ -337,6 +355,13 @@ export type GettersTree<S extends StateTree> = Record<
   ((state: UnwrapRef<S>) => any) | (() => any)
 >
 
+/**
+ * Type of an object of Actions
+ *
+ * @internal
+ */
+export type ActionsTree = Record<string, _Method>
+
 /**
  * Options parameter of `defineStore()`. Can be extended to augment stores with
  * the plugin API.
@@ -367,7 +392,7 @@ export interface DefineStoreOptions<
     ThisType<
       A &
         UnwrapRef<S> &
-        StoreWithState<Id, S> &
+        StoreWithState<Id, S, G, A> &
         StoreWithGetters<G> &
         PiniaCustomProperties
     >
diff --git a/test-dts/onAction.test-d.ts b/test-dts/onAction.test-d.ts
new file mode 100644 (file)
index 0000000..483f9ff
--- /dev/null
@@ -0,0 +1,58 @@
+import { defineStore, expectType } from '.'
+
+const useStore = defineStore({
+  id: 'main',
+  state: () => ({
+    user: 'Eduardo',
+  }),
+  actions: {
+    direct(name: string) {
+      this.user = name
+    },
+    patchObject(user: string) {
+      this.$patch({ user })
+    },
+    patchFn(name: string) {
+      this.$patch((state) => {
+        state.user = name
+      })
+    },
+    async asyncUpperName() {
+      return this.user.toUpperCase()
+    },
+    upperName() {
+      return this.user.toUpperCase()
+    },
+    throws(e: any) {
+      throw e
+    },
+    async rejects(e: any) {
+      throw e
+    },
+  },
+})
+
+let store = useStore()
+
+store.$onAction((context) => {
+  expectType<{ user: string }>(context.store.$state)
+  expectType<(name: string) => void>(context.store.direct)
+
+  if (context.name === 'upperName') {
+    expectType<[]>(context.args)
+    context.after((ret) => {
+      expectType<string>(ret)
+    })
+  } else if (context.name === 'asyncUpperName') {
+    context.after((ret) => {
+      expectType<string>(ret)
+    })
+  } else if (context.name === 'throws') {
+    context.after((ret) => {
+      expectType<never>(ret)
+    })
+    expectType<[any]>(context.args)
+  } else if (context.name === 'direct') {
+    expectType<[string]>(context.args)
+  }
+})