singleton stores in Vue, just like Pinia, but much simpler
vue-singleton-store.ts edited
143 lines 3.2 kB view raw
1import { 2 effectScope, 3 hasInjectionContext, 4 inject, 5 type App, 6 type EffectScope, 7 type InjectionKey, 8 type Plugin, 9} from 'vue'; 10 11interface Store { 12 scope: EffectScope; 13 value: unknown; 14 setup: () => unknown; 15} 16 17export interface Depot { 18 stores: Map<string, Store>; 19 initializing: Set<string>; 20} 21 22/** 23 * function type for retrieving a store instance 24 * @param depot optional depot instance 25 * @returns the store value 26 */ 27export type UseStoreFunction<T> = (depot?: Depot) => T; 28 29/** 30 * error thrown when circular dependencies are detected during store initialization 31 */ 32export class CircularDependencyError extends Error { 33 override readonly name = 'CircularDependencyError'; 34 35 /** 36 * creates a new circular dependency error 37 * @param path array of store ids that form the circular dependency 38 */ 39 constructor(path: string[]) { 40 super(`circular dependency detected: ${path.join(' -> ')}`); 41 } 42} 43 44const DEPOT_KEY: InjectionKey<Depot> = Symbol(); 45 46let activeDepot: Depot | undefined; 47 48/** 49 * sets the active depot for use outside of vue's injection context 50 * @param depot the depot to set as active, or undefined to clear 51 */ 52export const setActiveDepot = (depot: Depot | undefined): void => { 53 activeDepot = depot; 54}; 55 56/** 57 * gets the currently active depot 58 * @returns the active depot or undefined 59 */ 60/*#__NO_SIDE_EFFECTS__*/ 61export const getActiveDepot = (): Depot | undefined => { 62 if (hasInjectionContext()) { 63 return inject(DEPOT_KEY, activeDepot); 64 } 65 66 return activeDepot; 67}; 68 69/** 70 * creates a new depot instance 71 * @returns a depot 72 */ 73/*#__NO_SIDE_EFFECTS__*/ 74export const createDepot = (): Depot & Plugin => { 75 return { 76 stores: new Map(), 77 initializing: new Set(), 78 install(app: App) { 79 app.provide(DEPOT_KEY, this); 80 setActiveDepot(this); 81 }, 82 }; 83}; 84 85/** 86 * disposes all stores in a depot and cleans up resources 87 * @param depot the depot to dispose 88 */ 89export const disposeDepot = (depot: Depot): void => { 90 for (const store of depot.stores.values()) { 91 store.scope.stop(); 92 } 93 94 depot.stores.clear(); 95 depot.initializing.clear(); 96 97 if (activeDepot === depot) { 98 activeDepot = undefined; 99 } 100}; 101 102/** 103 * defines a new store with the given id and setup function 104 * @param id unique identifier for the store 105 * @param setup function that returns the store value 106 * @returns a function to retrieve the store instance 107 */ 108/*#__NO_SIDE_EFFECTS__*/ 109export const defineStore = <T>(id: string, setup: () => T): UseStoreFunction<T> => { 110 const useStore: UseStoreFunction<T> = (depot = getActiveDepot()) => { 111 if (!depot) { 112 throw new Error(`missing depot`); 113 } 114 115 let instance = depot.stores.get(id); 116 if (instance === undefined) { 117 if (depot.initializing.has(id)) { 118 throw new CircularDependencyError([...depot.initializing, id]); 119 } 120 121 depot.initializing.add(id); 122 setActiveDepot(depot); 123 124 try { 125 const scope = effectScope(true); 126 const value = scope.run(setup)!; 127 128 depot.stores.set(id, (instance = { scope, setup, value })); 129 } finally { 130 depot.initializing.delete(id); 131 } 132 } 133 134 return instance.value as T; 135 }; 136 137 return useStore; 138}; 139 140if (import.meta.hot) { 141 const hot = import.meta.hot; 142 hot.accept(() => hot.invalidate()); 143}