From f3b3bcf52a8ba86bc0927f98f53045842905c216 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 12 May 2021 16:54:25 +0200 Subject: [PATCH] feat(types): infer args and returned value for onAction --- __tests__/onAction.spec.ts | 10 ++-- src/devtools/plugin.ts | 7 ++- src/mapHelpers.ts | 11 +++- src/rootStore.ts | 3 +- src/store.ts | 66 ++++++++++++++------- src/types.ts | 111 ++++++++++++++++++++++-------------- test-dts/onAction.test-d.ts | 58 +++++++++++++++++++ 7 files changed, 191 insertions(+), 75 deletions(-) create mode 100644 test-dts/onAction.test-d.ts diff --git a/__tests__/onAction.spec.ts b/__tests__/onAction.spec.ts index dd3d3cc6..2789a9ec 100644 --- a/__tests__/onAction.spec.ts +++ b/__tests__/onAction.spec.ts @@ -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() diff --git a/src/devtools/plugin.ts b/src/devtools/plugin.ts index 90cfcd95..d1c8dabd 100644 --- a/src/devtools/plugin.ts +++ b/src/devtools/plugin.ts @@ -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 = GettersTree, - A = Record + A /* extends ActionsTree */ = ActionsTree >({ app, store, options, pinia }: PiniaPluginContext) { const wrappedActions = {} as Pick @@ -295,7 +295,8 @@ export function devtoolsPlugin< } } - addDevtools(app, store) + // FIXME: can this be fixed? + addDevtools(app, store as unknown as Store) return { ...wrappedActions } } diff --git a/src/mapHelpers.ts b/src/mapHelpers.ts index 47c5c9b0..419f4a9a 100644 --- a/src/mapHelpers.ts +++ b/src/mapHelpers.ts @@ -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 = GettersTree, - A = Record + A /* extends ActionsTree */ = ActionsTree >( vm: ComponentPublicInstance, useStore: StoreDefinition ): Store { const cache = '_pStores' in vm ? vm._pStores! : (vm._pStores = {}) const id = useStore.$id - return (cache[id] || (cache[id] = useStore(vm.$pinia))) as Store + return (cache[id] || + (cache[id] = useStore(vm.$pinia) as unknown as Store)) as unknown as Store< + Id, + S, + G, + A + > } export let mapStoreSuffix = 'Store' diff --git a/src/rootStore.ts b/src/rootStore.ts index b1d85d9d..e991852b 100644 --- a/src/rootStore.ts +++ b/src/rootStore.ts @@ -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 = GettersTree, - A = Record + A /* extends ActionsTree */ = ActionsTree > { /** * pinia instance. diff --git a/src/store.ts b/src/store.ts index 6ed81b73..f927028f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -28,6 +28,8 @@ import { GettersTree, MutationType, StoreOnActionListener, + UnwrapPromise, + ActionsTree, } from './types' import { getActivePinia, @@ -92,12 +94,17 @@ function computedFromState( * @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( +function initStore< + Id extends string, + S extends StateTree, + G extends GettersTree, + A /* extends ActionsTree */ +>( $id: Id, buildState: () => S = () => ({} as S), initialState?: S | undefined ): [ - StoreWithState, + StoreWithState, { get: () => S; set: (newValue: S) => void }, InjectionKey ] { @@ -107,7 +114,7 @@ function initStore( let isListening = true let subscriptions: SubscriptionCallback[] = [] - let actionSubscriptions: StoreOnActionListener[] = [] + let actionSubscriptions: StoreOnActionListener[] = [] let debuggerEvents: DebuggerEvent[] | DebuggerEvent function $patch(stateMutation: (state: S) => void): void @@ -199,7 +206,7 @@ function initStore( return removeSubscription } - function $onAction(callback: StoreOnActionListener) { + function $onAction(callback: StoreOnActionListener) { actionSubscriptions.push(callback) const removeSubscription = () => { @@ -220,7 +227,7 @@ function initStore( pinia.state.value[$id] = buildState() } - const storeWithState: StoreWithState = { + const storeWithState: StoreWithState = { $id, _p: pinia, _as: actionSubscriptions, @@ -231,7 +238,7 @@ function initStore( $subscribe, $onAction, $reset, - } as StoreWithState + } as StoreWithState const injectionSymbol = __DEV__ ? Symbol(`PiniaStore(${$id})`) @@ -269,9 +276,9 @@ function buildStoreToUse< Id extends string, S extends StateTree, G extends GettersTree, - A extends Record + A extends ActionsTree >( - partialStore: StoreWithState, + partialStore: StoreWithState, descriptor: StateDescriptor, $id: Id, getters: G = {} as G, @@ -295,11 +302,11 @@ function buildStoreToUse< for (const actionName in actions) { wrappedActions[actionName] = function (this: Store) { setActivePinia(pinia) - const args = Array.from(arguments) + const args = Array.from(arguments) as Parameters const localStore = this || store let afterCallback: ( - resolvedReturn: ReturnType + resolvedReturn: UnwrapPromise> ) => 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 + let ret: ReturnType 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, - A /* extends Record */ + // cannot extends ActionsTree because we loose the typings + A /* extends ActionsTree */ >(options: DefineStoreOptions): StoreDefinition { const { id, state, getters, actions } = options @@ -380,7 +393,7 @@ export function defineStore< let storeAndDescriptor = stores.get(id) as | [ - StoreWithState, + StoreWithState, StateDescriptor, InjectionKey> ] @@ -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 | undefined, - actions as Record | 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 | undefined, - actions as Record | undefined, - // @ts-expect-error: because of the extend on Actions + actions as A | undefined, options ) ) diff --git a/src/types.ts b/src/types.ts index 1f75c0d8..8aaed361 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,44 +59,59 @@ export enum MutationType { // maybe reset? for $state = {} and $reset } +export type UnwrapPromise = T extends Promise ? 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, + 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 - // TODO: pass generics - /** - * Store that is invoking the action - */ - store: GenericStore + /** + * Parameters passed to the action + */ + args: A[Name] extends _Method ? Parameters : 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>) => 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, + A /* extends ActionsTree */ +> = (context: StoreOnActionListenerContext) => void /** * Callback of a subscription @@ -122,7 +137,12 @@ export type SubscriptionCallback = ( * Base store with state and functions * @internal */ -export interface StoreWithState { +export interface StoreWithState< + Id extends string, + S extends StateTree, + G extends GettersTree = GettersTree, + A /* extends ActionsTree */ = ActionsTree +> { /** * Unique identifier of the store */ @@ -192,7 +212,7 @@ export interface StoreWithState { * * @internal */ - _as: StoreOnActionListener[] + _as: StoreOnActionListener[] /** * @alpha Please send feedback at https://github.com/posva/pinia/issues/240 @@ -228,7 +248,7 @@ export interface StoreWithState { * @param callback - callback called before every action * @returns function that removes the watcher */ - $onAction(callback: StoreOnActionListener): () => void + $onAction(callback: StoreOnActionListener): () => void } /** @@ -276,27 +296,25 @@ export type StoreWithGetters = { * Store type to build a store */ export type Store< - Id extends string, - S extends StateTree, - G extends GettersTree, + Id extends string = string, + S extends StateTree = StateTree, + G extends GettersTree = GettersTree, // has the actions without the context (this) for typings - A -> = StoreWithState & + A /* extends ActionsTree */ = ActionsTree +> = StoreWithState & UnwrapRef & StoreWithGetters & StoreWithActions & PiniaCustomProperties -// 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, - A /* extends Record */ + Id extends string = string, + S extends StateTree = StateTree, + G extends GettersTree = GettersTree, + 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 = GettersTree, - A = Record + A /* extends ActionsTree */ = ActionsTree > {} /** @@ -337,6 +355,13 @@ export type GettersTree = Record< ((state: UnwrapRef) => any) | (() => any) > +/** + * Type of an object of Actions + * + * @internal + */ +export type ActionsTree = Record + /** * Options parameter of `defineStore()`. Can be extended to augment stores with * the plugin API. @@ -367,7 +392,7 @@ export interface DefineStoreOptions< ThisType< A & UnwrapRef & - StoreWithState & + StoreWithState & StoreWithGetters & PiniaCustomProperties > diff --git a/test-dts/onAction.test-d.ts b/test-dts/onAction.test-d.ts new file mode 100644 index 00000000..483f9ffa --- /dev/null +++ b/test-dts/onAction.test-d.ts @@ -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(ret) + }) + } else if (context.name === 'asyncUpperName') { + context.after((ret) => { + expectType(ret) + }) + } else if (context.name === 'throws') { + context.after((ret) => { + expectType(ret) + }) + expectType<[any]>(context.args) + } else if (context.name === 'direct') { + expectType<[string]>(context.args) + } +}) -- 2.47.3