vue-singleton-store.ts
edited
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}