/** * Store creation logic. */ import { isPromise } from '../core/utils/type-guards'; import { batch, computed, signal, untrack, type ReadonlySignal, type Signal, } from '../reactive/index'; import { notifyDevtoolsStateChange, registerDevtoolsStore } from './devtools'; import { applyPlugins } from './plugins'; import { getStore, hasStore, registerStore } from './registry'; import type { ActionContext, Getters, OnActionCallback, Store, StoreDefinition, StoreSubscriber, } from './types'; import { deepClone, detectNestedMutations, isDev } from './utils'; /** * Creates a reactive store with state, getters, and actions. * * @template S - State type * @template G - Getters type * @template A - Actions type * @param definition - Store definition * @returns The reactive store instance */ export const createStore = < S extends Record, G extends Record = Record, // eslint-disable-next-line @typescript-eslint/no-explicit-any A extends Record any> = Record, >( definition: StoreDefinition ): Store => { const { id, state: stateFactory, getters = {} as Getters, actions = {} as A } = definition; // Check for duplicate store IDs if (hasStore(id)) { console.warn(`bQuery store: Store "${id}" already exists. Returning existing instance.`); return getStore(id) as Store; } // Create initial state const initialState = stateFactory(); // Create signals for each state property const stateSignals = new Map>(); for (const key of Object.keys(initialState) as Array) { stateSignals.set(key, signal(initialState[key])); } // Subscribers for $subscribe const subscribers: Array> = []; // Action lifecycle hooks for $onAction const actionListeners: Array> = []; const reportOnActionError = ( phase: 'listener' | 'after' | 'onError', actionName: string, error: unknown ): void => { if (!isDev() || typeof console === 'undefined' || typeof console.error !== 'function') return; console.error( `[bQuery store "${id}"] Error in $onAction ${phase} for action "${actionName}"`, error ); }; const warnedAsyncOnActionListeners = new WeakSet>(); const warnAsyncOnActionListener = ( listener: OnActionCallback, actionName: string ): void => { if (!isDev() || typeof console === 'undefined' || typeof console.warn !== 'function') return; if (warnedAsyncOnActionListeners.has(listener)) return; warnedAsyncOnActionListeners.add(listener); console.warn( `[bQuery store "${id}"] Async $onAction listener detected for action "${actionName}". If it awaits, register after()/onError() before the first await; late registrations will not affect the current action.` ); }; /** * Executes an action observer callback without allowing observer failures to * affect the action result. Handles both synchronous exceptions and async * rejections, routing all failures through the standard $onAction logger. * * @internal */ const runOnActionCallback = ( phase: 'listener' | 'after' | 'onError', actionName: string, callback: () => unknown, listener?: OnActionCallback ): void => { try { const result = callback(); if (isPromise(result)) { if (phase === 'listener' && listener) { warnAsyncOnActionListener(listener, actionName); } void result.catch((error) => { reportOnActionError(phase, actionName, error); }); } } catch (error) { reportOnActionError(phase, actionName, error); } }; /** * Gets the current state. * * For subscriber notifications (where a plain object snapshot is needed), * this creates a shallow copy. For internal reads, use stateProxy directly. * * **Note:** Returns a shallow snapshot. Nested object mutations will NOT * trigger reactive updates. This differs from frameworks like Pinia that * use deep reactivity. To update nested state, replace the entire object. * * Uses `untrack()` to prevent accidental dependency tracking when called * from within reactive contexts (e.g., `effect()` or `computed()`). * * @internal */ const getCurrentState = (): S => untrack(() => { return { ...stateProxy }; }); /** * Notifies subscribers of state changes. * Short-circuits if there are no subscribers and devtools aren't active * to avoid unnecessary snapshot overhead. * @internal */ const notifySubscribers = (): void => { // Early return if no subscribers and no devtools hook const hasDevtools = typeof window !== 'undefined' && typeof window.__BQUERY_DEVTOOLS__?.onStateChange === 'function'; if (subscribers.length === 0 && !hasDevtools) { return; } const currentState = getCurrentState(); for (const callback of subscribers) { callback(currentState); } notifyDevtoolsStateChange(id, currentState); }; /** * Cached state proxy that lazily reads signal values. * Uses a Proxy to avoid creating new objects on each access. * * **Note:** This returns a shallow snapshot of the state. Nested object * mutations will NOT trigger reactive updates. For nested reactivity, * replace the entire object or use signals for nested properties. * * @internal */ const stateProxy = new Proxy({} as S, { get: (_, prop: string | symbol) => { const key = prop as keyof S; if (stateSignals.has(key)) { return stateSignals.get(key)!.value; } return undefined; }, ownKeys: () => Array.from(stateSignals.keys()) as string[], getOwnPropertyDescriptor: (_, prop) => { if (stateSignals.has(prop as keyof S)) { return { enumerable: true, configurable: true }; } return undefined; }, has: (_, prop) => stateSignals.has(prop as keyof S), }); // Create computed getters const getterComputed = new Map>(); // Build the store proxy const store = {} as Store; // Define state properties with getters/setters for (const key of Object.keys(initialState) as Array) { Object.defineProperty(store, key, { get: () => stateSignals.get(key)!.value, set: (value: unknown) => { stateSignals.get(key)!.value = value; notifySubscribers(); }, enumerable: true, configurable: false, }); } // Define getters as computed properties for (const key of Object.keys(getters) as Array) { const getterFn = getters[key]; // Create computed that reads from state signals via proxy (more efficient) const computedGetter = computed(() => { const state = stateProxy; // For getter dependencies, pass a proxy that reads from computed getters const getterProxy = new Proxy({} as G, { get: (_, prop: string | symbol) => { const propKey = prop as keyof G; if (getterComputed.has(propKey)) { return getterComputed.get(propKey)!.value; } return undefined; }, }); return getterFn(state, getterProxy); }); getterComputed.set(key, computedGetter as unknown as ReadonlySignal); Object.defineProperty(store, key, { get: () => computedGetter.value, enumerable: true, configurable: false, }); } // Bind actions to the store context, with $onAction lifecycle support for (const key of Object.keys(actions) as Array) { const actionFn = actions[key]; const actionName = key as keyof A & string; // Wrap action to enable 'this' binding and $onAction hooks (store as Record)[actionName] = function (...args: unknown[]) { // Create a context that allows 'this.property' access const context = new Proxy(store, { get: (target, prop) => { if (typeof prop === 'string' && stateSignals.has(prop as keyof S)) { return stateSignals.get(prop as keyof S)!.value; } return (target as Record)[prop as string]; }, set: (target, prop, value) => { if (typeof prop === 'string' && stateSignals.has(prop as keyof S)) { stateSignals.get(prop as keyof S)!.value = value; notifySubscribers(); return true; } // Allow non-state property assignments (e.g., temporary variables in actions) // by delegating to the target object rather than returning false return Reflect.set(target, prop, value); }, }); // Run $onAction hooks if any listeners are registered if (actionListeners.length === 0) { return actionFn.apply(context, args); } const afterHooks: Array<(result: unknown) => void> = []; const errorHooks: Array<(error: unknown) => void> = []; const listenerSnapshot = [...actionListeners]; const listenerContext = { name: actionName, store, args: args as Parameters, after: (callback: (result: Awaited>) => void) => { afterHooks.push((result) => callback(result as Awaited>) ); }, onError: (callback: (error: unknown) => void) => { errorHooks.push(callback); }, } satisfies ActionContext; // Notify all action listeners (before phase) for (const listener of listenerSnapshot) { runOnActionCallback('listener', actionName, () => listener(listenerContext), listener); } let result: unknown; try { result = actionFn.apply(context, args); } catch (error) { for (const hook of errorHooks) { runOnActionCallback('onError', actionName, () => hook(error)); } throw error; } // Handle async actions (promises) if (isPromise(result)) { return result.then( (resolved) => { for (const hook of afterHooks) { runOnActionCallback('after', actionName, () => hook(resolved)); } return resolved; }, (error) => { for (const hook of errorHooks) { runOnActionCallback('onError', actionName, () => hook(error)); } throw error; } ); } // Sync action — run after hooks immediately for (const hook of afterHooks) { runOnActionCallback('after', actionName, () => hook(result)); } return result; }; } // Add store utility methods Object.defineProperties(store, { $id: { value: id, writable: false, enumerable: false, }, $reset: { value: () => { const fresh = stateFactory(); batch(() => { for (const [key, sig] of stateSignals) { sig.value = fresh[key]; } }); notifySubscribers(); }, writable: false, enumerable: false, }, $subscribe: { value: (callback: StoreSubscriber) => { subscribers.push(callback); return () => { const index = subscribers.indexOf(callback); if (index > -1) subscribers.splice(index, 1); }; }, writable: false, enumerable: false, }, $onAction: { value: (callback: OnActionCallback) => { actionListeners.push(callback); return () => { const index = actionListeners.indexOf(callback); if (index > -1) actionListeners.splice(index, 1); }; }, writable: false, enumerable: false, }, $patch: { value: (partial: Partial | ((state: S) => void)) => { batch(() => { if (typeof partial === 'function') { // Capture state before mutation for nested mutation detection const devMode = isDev(); const stateBefore = devMode ? deepClone(getCurrentState()) : null; const signalValuesBefore = devMode ? new Map(Array.from(stateSignals.entries()).map(([k, s]) => [k, s.value])) : null; // Mutation function const state = getCurrentState(); partial(state); // Detect nested mutations in development mode if (devMode && stateBefore && signalValuesBefore) { const mutatedKeys = detectNestedMutations(stateBefore, state, signalValuesBefore); if (mutatedKeys.length > 0) { console.warn( `[bQuery store "${id}"] Nested mutation detected in $patch() for keys: ${mutatedKeys .map(String) .join(', ')}.\n` + 'Nested object mutations do not trigger reactive updates because the store uses shallow reactivity.\n' + 'To fix this, either:\n' + ' 1. Replace the entire object: state.user = { ...state.user, name: "New" }\n' + ' 2. Use $patchDeep() for automatic deep cloning\n' + 'See: https://bquery.dev/guide/store#deep-reactivity' ); } } for (const [key, value] of Object.entries(state) as Array<[keyof S, unknown]>) { if (stateSignals.has(key)) { stateSignals.get(key)!.value = value; } } } else { // Partial object for (const [key, value] of Object.entries(partial) as Array<[keyof S, unknown]>) { if (stateSignals.has(key)) { stateSignals.get(key)!.value = value; } } } }); notifySubscribers(); }, writable: false, enumerable: false, }, $patchDeep: { value: (partial: Partial | ((state: S) => void)) => { batch(() => { if (typeof partial === 'function') { // Deep clone state before mutation to ensure new references const state = deepClone(getCurrentState()); partial(state); for (const [key, value] of Object.entries(state) as Array<[keyof S, unknown]>) { if (stateSignals.has(key)) { stateSignals.get(key)!.value = value; } } } else { // Deep clone each value in partial to ensure new references for (const [key, value] of Object.entries(partial) as Array<[keyof S, unknown]>) { if (stateSignals.has(key)) { stateSignals.get(key)!.value = deepClone(value); } } } }); notifySubscribers(); }, writable: false, enumerable: false, }, $state: { get: () => getCurrentState(), enumerable: false, }, }); // Register store registerStore(id, store); // Apply plugins applyPlugins(store, definition); // Notify devtools registerDevtoolsStore(id, store); return store; };