From 24e7c9c30ce9bee6e3fab3f9b5287733f40c863d Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 8 Jul 2021 18:42:50 +0200 Subject: [PATCH] feat(store): function wip --- src/createPinia.ts | 9 +- src/mapHelpers.ts | 2 + src/rootStore.ts | 16 +- src/store.ts | 431 ++++++++++++++++++++++++++++++++++++++++++++- src/types.ts | 31 ++++ 5 files changed, 484 insertions(+), 5 deletions(-) diff --git a/src/createPinia.ts b/src/createPinia.ts index 587928aa..aaaee6fd 100644 --- a/src/createPinia.ts +++ b/src/createPinia.ts @@ -4,17 +4,19 @@ import { setActivePinia, piniaSymbol, } from './rootStore' -import { ref, App, markRaw } from 'vue' +import { ref, App, markRaw, effectScope } from 'vue' import { devtoolsPlugin } from './devtools' import { IS_CLIENT } from './env' +import { StateTree, Store } from './types' /** * Creates a Pinia instance to be used by the application */ export function createPinia(): Pinia { + const scope = effectScope(true) // 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 state = scope.run(() => ref>({}))! let localApp: App | undefined let _p: Pinia['_p'] = [] @@ -46,7 +48,8 @@ export function createPinia(): Pinia { _p, // it's actually undefined here _a: localApp!, - + _e: scope, + _s: new Map(), state, }) diff --git a/src/mapHelpers.ts b/src/mapHelpers.ts index 0a6f1ff7..47f97677 100644 --- a/src/mapHelpers.ts +++ b/src/mapHelpers.ts @@ -221,6 +221,7 @@ export function mapState< useStore: StoreDefinition, keyMapper: KeyMapper ): _MapStateObjectReturn + /** * Allows using state and getters from one store without using the composition * API (`setup()`) by generating an object to be spread in the `computed` field @@ -253,6 +254,7 @@ export function mapState< useStore: StoreDefinition, keys: Array ): _MapStateReturn + /** * Allows using state and getters from one store without using the composition * API (`setup()`) by generating an object to be spread in the `computed` field diff --git a/src/rootStore.ts b/src/rootStore.ts index 64eb0396..cd6b0835 100644 --- a/src/rootStore.ts +++ b/src/rootStore.ts @@ -1,4 +1,4 @@ -import { App, InjectionKey, Plugin, Ref, warn } from 'vue' +import { App, EffectScope, InjectionKey, Plugin, Ref, warn } from 'vue' import { StateTree, StoreWithState, @@ -10,6 +10,7 @@ import { GettersTree, ActionsTree, PiniaCustomStateProperties, + GenericStore, } from './types' /** @@ -92,6 +93,19 @@ export interface Pinia { */ _a: App + /** + * Effect scope the pinia is attached to + * + * @internal + */ + _e: EffectScope + + /** + * + * @internal + */ + _s: Map + /** * Added by `createTestingPinia()` to bypass `useStore(pinia)`. * diff --git a/src/store.ts b/src/store.ts index 82218b16..cef3dc9e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -5,7 +5,6 @@ import { inject, getCurrentInstance, reactive, - onUnmounted, InjectionKey, provide, DebuggerEvent, @@ -14,6 +13,13 @@ import { markRaw, isRef, isReactive, + effectScope, + onScopeDispose, + EffectScope, + getCurrentScope, + onUnmounted, + ComputedRef, + toRef, } from 'vue' import { StateTree, @@ -34,6 +40,7 @@ import { UnwrapPromise, ActionsTree, SubscriptionCallbackMutation, + _UnionToTuple, } from './types' import { getActivePinia, @@ -122,6 +129,7 @@ function initStore< pinia.state.value[$id] = initialState || buildState() // const state: Ref = toRef(_p.state.value, $id) + // internal state let isListening = true let subscriptions: SubscriptionCallback[] = markRaw([]) let actionSubscriptions: StoreOnActionListener[] = markRaw([]) @@ -392,8 +400,429 @@ function buildStoreToUse< return store } +type _SetupOfStateActions< + S extends StateTree, + A extends Record +> = (StateTree extends S ? {} : S) & (Record extends A ? {} : A) + +function stateBuilder( + builder: (initial?: S | undefined) => S +) { + return {} as S +} + +const a = stateBuilder<{ n: number; toggle: boolean }>((initial) => ({ + n: initial?.n || 2, + toggle: true, +})) + +export interface DefineSetupStoreOptions< + Id extends string, + S extends StateTree, + G extends ActionsTree, // TODO: naming + A extends ActionsTree +> { + hydrate?(store: Store, initialState: S | undefined): void +} + +function isComputed(o: any) { + return o && o.effect && o.effect.computed +} + +function createSetupStore< + Id extends string, + SS, + S extends StateTree, + G extends ActionsTree, // TODO: naming + A extends ActionsTree +>( + $id: Id, + setup: () => SS, + initialState: S | undefined, + { + // @ts-expect-error + hydrate = innerPatch, + }: DefineSetupStoreOptions = {} +): Store { + const pinia = getActivePinia() + let scope!: EffectScope + + // watcher options for $subscribe + const $subscribeOptions: WatchOptions = { deep: true, flush: 'sync' } + /* istanbul ignore else */ + if (__DEV__) { + $subscribeOptions.onTrigger = (event) => { + if (isListening) { + debuggerEvents = event + } else { + // let patch send all the events together later + /* istanbul ignore else */ + if (Array.isArray(debuggerEvents)) { + debuggerEvents.push(event) + } else { + console.error( + '🍍 debuggerEvents should be an array. This is most likely an internal Pinia bug.' + ) + } + } + } + } + + // internal state + let isListening = true + let subscriptions: SubscriptionCallback[] = markRaw([]) + let actionSubscriptions: StoreOnActionListener[] = markRaw([]) + let debuggerEvents: DebuggerEvent[] | DebuggerEvent + + const triggerSubscriptions: SubscriptionCallback = (mutation, state) => { + subscriptions.forEach((callback) => { + callback(mutation, state) + }) + } + + if (__DEV__ && !pinia._e.active) { + // TODO: warn in dev + throw new Error('Pinia destroyed') + } + + // TODO: idea create skipSerialize that marks properties as non serializable and they are skipped + const setupStore = pinia._e.run(() => { + scope = effectScope() + return scope.run(() => { + const store = setup() + // TODO: extract state and set it to pinia.state.value[$id] + // pinia.state.value[$id] = initialState || buildState() + + watch( + () => pinia.state.value[$id] as UnwrapRef, + (state, oldState) => { + if (isListening) { + triggerSubscriptions( + { + storeId: $id, + type: MutationType.direct, + events: debuggerEvents as DebuggerEvent, + }, + state + ) + } + }, + $subscribeOptions + )! + + return store + }) + })! + + function $patch(stateMutation: (state: UnwrapRef) => void): void + function $patch(partialState: DeepPartial>): void + function $patch( + partialStateOrMutator: + | DeepPartial> + | ((state: UnwrapRef) => void) + ): void { + let subscriptionMutation: SubscriptionCallbackMutation + isListening = false + // reset the debugger events since patches are sync + /* istanbul ignore else */ + if (__DEV__) { + debuggerEvents = [] + } + if (typeof partialStateOrMutator === 'function') { + partialStateOrMutator(pinia.state.value[$id] as UnwrapRef) + subscriptionMutation = { + type: MutationType.patchFunction, + storeId: $id, + events: debuggerEvents as DebuggerEvent[], + } + } else { + innerPatch(pinia.state.value[$id], partialStateOrMutator) + subscriptionMutation = { + type: MutationType.patchObject, + payload: partialStateOrMutator, + storeId: $id, + events: debuggerEvents as DebuggerEvent[], + } + } + isListening = true + // because we paused the watcher, we need to manually call the subscriptions + triggerSubscriptions( + subscriptionMutation, + pinia.state.value[$id] as UnwrapRef + ) + } + + function $subscribe(callback: SubscriptionCallback, detached?: boolean) { + subscriptions.push(callback) + + if (!detached) { + if (getCurrentInstance()) { + onUnmounted(() => { + const idx = subscriptions.indexOf(callback) + if (idx > -1) { + subscriptions.splice(idx, 1) + } + }) + } + } + } + + function $onAction(callback: StoreOnActionListener) { + actionSubscriptions.push(callback) + + const removeSubscription = () => { + const idx = actionSubscriptions.indexOf(callback) + if (idx > -1) { + actionSubscriptions.splice(idx, 1) + } + } + + if (getCurrentInstance()) { + onScopeDispose(removeSubscription) + } + + return removeSubscription + } + + function $reset() { + // TODO: is it worth? probably should be removed + // maybe it can stop the effect and create it again + // pinia.state.value[$id] = buildState() + } + + // overwrite existing actions to support $onAction + for (const key in setupStore) { + const prop = setupStore[key] + + // action + if (typeof prop === 'function') { + // @ts-expect-error: we are overriding the function + setupStore[key] = function () { + setActivePinia(pinia) + const args = Array.from(arguments) + + let afterCallback: (resolvedReturn: any) => void = noop + let onErrorCallback: (error: unknown) => void = noop + function after(callback: typeof afterCallback) { + afterCallback = callback + } + function onError(callback: typeof onErrorCallback) { + onErrorCallback = callback + } + + actionSubscriptions.forEach((callback) => { + // @ts-expect-error + callback({ + args, + name: key, + store, + after, + onError, + }) + }) + + let ret: any + try { + ret = prop.apply(this, args) + Promise.resolve(ret).then(afterCallback).catch(onErrorCallback) + } catch (error) { + onErrorCallback(error) + throw error + } + + return ret + } + } else if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) { + // mark it as a piece of state to be serialized + pinia.state.value[$id] = toRef(setupStore as any, key) + } else if (__DEV__ && IS_CLIENT) { + // add getters for devtools + if (isComputed(prop)) { + const getters: string[] = + // @ts-expect-error: it should be on the store + setupStore._getters || (setupStore._getters = markRaw([])) + getters.push(key) + } + } + } + + const partialStore = { + $id, + $onAction, + $patch, + $reset, + $subscribe, + } + + const store: Store = reactive( + assign( + __DEV__ && IS_CLIENT + ? // devtools custom properties + { + _customProperties: markRaw(new Set()), + } + : {}, + partialStore, + setupStore + ) + ) 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', { + get: () => pinia.state.value[$id], + set: (state) => (pinia.state.value[$id] = state), + }) + + // apply all plugins + pinia._p.forEach((extender) => { + if (__DEV__ && IS_CLIENT) { + // @ts-expect-error: conflict between A and ActionsTree + // TODO: completely different options... + const extensions = extender({ store, app: pinia._a, pinia, options }) + Object.keys(extensions || {}).forEach((key) => + store._customProperties.add(key) + ) + assign(store, extensions) + } else { + // @ts-expect-error: conflict between A and ActionsTree + assign(store, extender({ store, app: pinia._a, pinia, options })) + } + }) + + return store +} + +// const useStore = createSetupStore('cosa', () => { +// return { +// o: 'one', +// } +// }) + +type _SpreadStateFromStore = K extends readonly [ + infer A, + ...infer Rest +] + ? A extends string | number | symbol + ? SS extends Record> + ? _SpreadStateFromStore + : SS extends Record + ? Record> & _SpreadStateFromStore + : never + : {} + : {} + +type _SpreadPropertiesFromObject< + SS, + K extends readonly any[], + T +> = K extends readonly [infer A, ...infer Rest] + ? A extends string | number | symbol + ? SS extends Record + ? Record> & _SpreadPropertiesFromObject + : _SpreadPropertiesFromObject + : {} + : {} + +type _ExtractStateFromSetupStore = _SpreadStateFromStore< + SS, + _UnionToTuple +> + +type _ExtractActionsFromSetupStore = _SpreadPropertiesFromObject< + SS, + _UnionToTuple, + _Method +> + +type _ExtractGettersFromSetupStore = _SpreadPropertiesFromObject< + SS, + _UnionToTuple, + ComputedRef +> + +// type a1 = _ExtractStateFromSetupStore<{ a: Ref; action: () => void }> +// type a2 = _ExtractActionsFromSetupStore<{ a: Ref; action: () => void }> +// type a3 = _ExtractGettersFromSetupStore<{ +// a: Ref +// b: ComputedRef +// action: () => void +// }> + +/** + * Creates a `useStore` function that retrieves the store instance + * + * @param options - options to define the store + */ +export function defineSetupStore( + id: Id, + storeSetup: () => SS, + options?: DefineSetupStoreOptions< + Id, + _ExtractStateFromSetupStore, + _ExtractGettersFromSetupStore, + _ExtractActionsFromSetupStore + > +): StoreDefinition< + Id, + _ExtractStateFromSetupStore, + _ExtractGettersFromSetupStore, + _ExtractActionsFromSetupStore +> { + function useStore( + pinia?: Pinia | null + ): Store< + Id, + _ExtractStateFromSetupStore, + _ExtractGettersFromSetupStore, + _ExtractActionsFromSetupStore + > { + const currentInstance = getCurrentInstance() + pinia = + // in test mode, ignore the argument provided as we can always retrieve a + // pinia instance with getActivePinia() + (__TEST__ && activePinia && activePinia._testing ? null : pinia) || + (currentInstance && inject(piniaSymbol)) + if (pinia) setActivePinia(pinia) + // TODO: worth warning on server if no piniaKey as it can leak data + pinia = getActivePinia() + + if (!pinia._s.has(id)) { + pinia._s.set(id, createSetupStore(id, storeSetup, options)) + } + + const store: Store< + Id, + _ExtractStateFromSetupStore, + _ExtractGettersFromSetupStore, + _ExtractActionsFromSetupStore + > = pinia._s.get(id)! as Store< + Id, + _ExtractStateFromSetupStore, + _ExtractGettersFromSetupStore, + _ExtractActionsFromSetupStore + > + + // save stores in instances to access them devtools + if (__DEV__ && IS_CLIENT && currentInstance && currentInstance.proxy) { + const vm = currentInstance.proxy + const cache = '_pStores' in vm ? vm._pStores! : (vm._pStores = {}) + // @ts-expect-error: still can't cast Store with generics to Store + cache[id] = store + } + + return store + } + + useStore.$id = id + + return useStore +} + /** * Creates a `useStore` function that retrieves the store instance + * * @param options - options to define the store */ export function defineStore< diff --git a/src/types.ts b/src/types.ts index 456e3e37..3598bdac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -500,3 +500,34 @@ export interface DefineStoreOptions< PiniaCustomProperties > } + +export type _UnionToTuple = _UnionToTupleRecursively<[], U> + +type _Overwrite = { + [P in keyof T]: P extends keyof S ? S[P] : never +} +type _TupleUnshift = T extends any + ? ((x: X, ...t: T) => void) extends (...t: infer R) => void + ? R + : never + : never +type TuplePush = T extends any + ? _Overwrite<_TupleUnshift, T & { [x: string]: X }> + : never +type _UnionToIntersection = ( + U extends any ? (k: U) => void : never +) extends (k: infer I) => void + ? I + : never +type _UnionToOvlds = _UnionToIntersection< + U extends any ? (f: U) => void : never +> +type _PopUnion = _UnionToOvlds extends (a: infer A) => void ? A : never +/* end helpers */ +/* main work */ +type _UnionToTupleRecursively = { + 1: T + 0: _PopUnion extends infer SELF + ? _UnionToTupleRecursively, Exclude> + : never +}[[U] extends [never] ? 1 : 0] -- 2.47.2