import React from 'react' import { isEqualSubsetShallow } from './comparators' import { configureOpts } from './configureUseStore' import { UNWRAP_PROXY, defaultOptions } from './constants' import { UNWRAP_STORE_INFO, cache, getStoreDescriptors, getStoreUid, simpleStr, } from './helpers' import type { Selector, Store, StoreInfo, UseStoreOptions } from './interfaces' import { DebugStores, shouldDebug, useCurrentComponent } from './useStoreDebug' const idFn = (_) => _ // no singleton, just react export function useStore( StoreKlass: (new (props: B) => A) | (new () => A) | null | undefined, props?: B | null, options: UseStoreOptions = defaultOptions ): A { const info = getOrCreateStoreInfo(StoreKlass, props, options) return useStoreFromInfo(info, options.selector, options) } export function useStoreDebug( StoreKlass: (new (props: B) => A) | (new () => A), props?: B ): A { return useStore(StoreKlass, props, { debug: true }) } // singleton export function createStore( StoreKlass: new (props: B) => A | (new () => A) | null | undefined, props?: B, options?: UseStoreOptions ): A { return getOrCreateStoreInfo(StoreKlass, props, options)?.store as any } // use singleton with react // TODO selector support with types... export function useGlobalStore(instance: A, debug?: boolean): A { const store = instance[UNWRAP_PROXY] const uid = getStoreUid(store.constructor, store.props) const info = cache.get(uid) if (!info) { throw new Error(`This store not created using createStore()`) } return useStoreFromInfo(info, undefined, { debug }) } export function useGlobalStoreSelector any>( instance: A, selector: Selector, debug?: boolean ): Selector extends (a: A) => infer C ? C : unknown { const store = instance[UNWRAP_PROXY] const uid = getStoreUid(store.constructor, store.props) const info = cache.get(uid) if (!info) { throw new Error(`This store not created using createStore()`) } return useStoreFromInfo(info, selector, { debug }) } // for creating a usable store hook export function createUseStore( StoreKlass: (new (props: Props) => Store) | (new () => Store) ) { return , Props extends object>( props?: Props, options?: UseStoreOptions ): C extends Selector ? (B extends object ? B : Store) : Store => useStore(StoreKlass as any, props, options) } // for creating a usable selector hook export function createUseStoreSelector< A extends Store, Props extends object, Selected, >( StoreKlass: (new (props: Props) => A) | (new () => A), selector: Selector ): (props?: Props) => Selected { return (props?: Props) => { return useStore(StoreKlass, props, { selector }) as any } } // selector hook export function useStoreSelector>( StoreKlass: (new (props: B) => A) | (new () => A), selector: S, props?: B ): S extends Selector ? R : unknown { return useStore(StoreKlass, props, { selector }) as any } type StoreAccessTracker = (store: StoreInfo) => void const storeAccessTrackers = new Set() export function trackStoresAccess(cb: StoreAccessTracker) { storeAccessTrackers.add(cb) return () => { storeAccessTrackers.delete(cb) } } export function getStore( StoreKlass: (new (props: B) => A) | (new () => A) | null | undefined, props?: B ): A { return getStoreInfo(StoreKlass, props)?.store as any } export function getOrCreateStore( StoreKlass: (new (props: B) => A) | (new () => A) | null | undefined, props?: B ): A { return getOrCreateStoreInfo(StoreKlass, props, { refuseCreation: false, })?.store as any } // just like getOrCreateStoreInfo but refuses to create export function getStoreInfo(StoreKlass: any, props: any) { return getOrCreateStoreInfo(StoreKlass, props, { refuseCreation: true, }) } export type CreateStoreListener = (storeInfo: StoreInfo) => void const onCreateListeners = new Set() export function onCreateStore(cb: CreateStoreListener) { onCreateListeners.add(cb) return () => { onCreateListeners.delete(cb) } } function getOrCreateStoreInfo( StoreKlass: any, props: any, options?: UseStoreOptions & { avoidCache?: boolean; refuseCreation?: boolean }, propsKeyCalculated?: string ) { if (!StoreKlass) { return null } const uid = getStoreUid(StoreKlass, propsKeyCalculated ?? props) if (!options?.avoidCache && cache.has(uid)) { return cache.get(uid)! } if (options?.refuseCreation) { throw new Error(`No store exists (${StoreKlass.name}) with props: ${props}`) } // init const storeInstance = new StoreKlass(props!) // add props storeInstance.props = props const getters = {} const actions = {} const stateKeys = new Set() const descriptors = getStoreDescriptors(storeInstance) for (const key in descriptors) { const descriptor = descriptors[key] if (typeof descriptor.value === 'function') { // actions actions[key] = descriptor.value } else if (typeof descriptor.get === 'function') { getters[key] = descriptor.get } else { if (key !== 'props' && key[0] !== '_') { stateKeys.add(key) } } } const keyComparators = storeInstance['_comparators'] const listeners = new Set() const storeInfo = { uid, keyComparators, storeInstance, getters, stateKeys, props, actions, debug: options?.debug, disableTracking: false, gettersState: { getCache: new Map(), depsToGetter: new Map>(), curGetKeys: new Set(), isGetting: false, }, listeners, trackers: new Set(), version: 0, subscribe: (onChanged: Function) => { listeners.add(onChanged) return () => { listeners.delete(onChanged) } }, triggerUpdate: () => { storeInfo.version = (storeInfo.version + 1) % Number.MAX_SAFE_INTEGER for (const cb of listeners) { cb() } }, } satisfies Omit const store = createProxiedStore( // we assign store right after and proxiedStore never accesses it until later on storeInfo as any as StoreInfo ) // uses more memory when on if (process.env.NODE_ENV === 'development') { allStores[StoreKlass.name + uid] = store } // if has a mount function call it store.mount?.() // @ts-ignore storeInfo.store = store const result = storeInfo as any as StoreInfo // still set even when avoidCache is true (hmr) cache.set(uid, result) onCreateListeners.forEach((cb) => cb(result)) return result } export const allStores = {} if (process.env.NODE_ENV === 'development') { globalThis['Store'] ||= allStores } const emptyObj = {} const selectKeys = (obj: any, keys: string[]) => { if (!keys.length) { return emptyObj } const res = {} for (const key of keys) { res[key] = obj[key] } return res } let isInReaction = false export const setIsInReaction = (val: boolean) => { isInReaction = val } function useStoreFromInfo( info: StoreInfo | null | undefined, userSelector?: Selector | undefined, options?: UseStoreOptions ): any { const store = info?.store const internal = React.useRef(undefined) const component = useCurrentComponent() if (!internal.current) { internal.current = { component, tracked: new Set(), last: null, lastKeys: null, } } const curInternal = internal.current! const shouldPrintDebug = options?.debug const getSnapshot = React.useCallback(() => { if (!info || !store) return const curInternal = internal.current! const isTracking = curInternal.tracked.size const keys = [...(!isTracking ? info.stateKeys : curInternal.tracked)] const nextKeys = `${info.version}${keys.join('')}${userSelector || ''}` const lastKeys = curInternal.lastKeys // avoid updates if (nextKeys === curInternal.lastKeys) { return curInternal.last } curInternal.lastKeys = nextKeys let snap: any // dont track during selector info.disableTracking = true const last = curInternal.last if (userSelector) { snap = userSelector(store) } else { snap = selectKeys(store, keys) } info.disableTracking = false // this wasn't updating in AnimationsStore const isUnchanged = (!userSelector && !isTracking && last) || (typeof last !== 'undefined' && isEqualSubsetShallow(last, snap, { keyComparators: info.keyComparators, })) if (shouldPrintDebug) { console.info('🌑 getSnapshot', { storeState: selectKeys(store, Object.keys(store)), userSelector, info, isUnchanged, component, keys, last, snap, curInternal, nextKeys, lastKeys, }) } if (isUnchanged) { return last } curInternal.last = snap return snap }, [store]) // sync by default const state = React.useSyncExternalStore( info?.subscribe || idFn, getSnapshot, getSnapshot ) if (!info || !store || !state) { return state } if (userSelector) { return state } return new Proxy(store, { get(target, key) { // be sure to touch the value for tracking purposes const curVal = Reflect.get(target, key) // while in reactions, don't proxy to old state as they aren't inside of react render if (isInReaction) { return curVal } const keyString = key as string // fine for our uses if (info.stateKeys.has(keyString) || keyString in info.getters) { if (shouldPrintDebug) { console.info('👀 tracking', keyString) } curInternal.tracked.add(keyString) } if (Reflect.has(state, key)) { return Reflect.get(state, key) } return curVal }, }) } let setters = new Set() const logStack = new Set | 'end'>() function createProxiedStore(storeInfo: StoreInfo) { const { actions, storeInstance, getters, gettersState } = storeInfo const { getCache, curGetKeys, depsToGetter } = gettersState const constr = storeInstance.constructor const shouldDebug = storeInfo.debug ?? DebugStores.has(constr) let didSet = false const wrappedActions = {} // pre-setup actions for (const key in actions) { if (key === 'subscribe') { continue } // wrap action and call didSet after const actionFn = actions[key] // yes its odd but bug in router needs to be figured out to fix // and all it does is deopt slightly and silence get const isGetFn = key.startsWith('get') // wrap actions for tracking wrappedActions[key] = function useStoreAction(...args: any[]) { let res: any if (isGetFn || gettersState.isGetting) { return Reflect.apply(actionFn, proxiedStore, args) } if (process.env.NODE_ENV === 'development' && shouldDebug) { console.info('(debug) startAction', key) } res = Reflect.apply(actionFn, proxiedStore, args) if (res instanceof Promise) { return res.then(finishAction) } finishAction() return res } // dev mode do nice logging if (process.env.NODE_ENV === 'development') { if (!key.startsWith('get') && !key.startsWith('_') && key !== 'subscribe') { const ogAction = wrappedActions[key] wrappedActions[key] = new Proxy(ogAction, { apply(target, thisArg, args) { const isDebugging = shouldDebug || storeInfo.debug const shouldLog = process.env.LOG_LEVEL !== '0' && (isDebugging || configureOpts.logLevel !== 'error') if (!shouldLog) { return Reflect.apply(target, thisArg, args) } setters = new Set() const curSetters = setters const isTopLevelLogger = logStack.size == 0 const logs = new Set() logStack.add(logs) let res const id = counter++ try { // 🏃‍♀️ run action here now res = Reflect.apply(target, thisArg, args) } catch (err) { console.error('Error', err) throw err } finally { logStack.add('end') const name = constr.name const color = strColor(name) const simpleArgs = args.map(simpleStr) logs.add([ `%c 🌑 ${id} ${name.padStart( isTopLevelLogger ? 8 : 4 )}%c.${key}(${simpleArgs.join(', ')})${ isTopLevelLogger && logStack.size > 1 ? ` (+${logStack.size - 1})` : '' }`, `color: ${color};`, 'color: black;', ]) if (curSetters.size) { curSetters.forEach(({ key, value }) => { if ( typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ) { logs.add([` SET ${key} ${value}`, value]) } else { logs.add([` SET ${key}`, value]) } }) } if (isTopLevelLogger) { let error = null try { for (const item of logStack) { if (item === 'end') { console.groupEnd() continue } const [head, ...rest] = item if (head) { console.groupCollapsed(...head) console.groupCollapsed('...') console.info('args', args) console.info('response', res) console.groupCollapsed('trace') console.trace() console.groupEnd() console.groupEnd() for (const [name, ...log] of rest) { console.groupCollapsed(name) console.info(...log) console.groupEnd() } } else { console.info('Weird log', head, ...rest) } } } catch (err: any) { error = err } for (const _ of logStack) { console.groupEnd() } if (error) { console.error(`error loggin`, error) } logStack.clear() } return res } }, }) } function hashCode(str: string) { let hash = 0 for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash) } return hash } function strColor(str: string) { return `hsl(${hashCode(str) % 360}, 90%, 40%)` } } } const finishAction = (val?: any) => { if (process.env.NODE_ENV === 'development' && shouldDebug) { console.info('(debug) finishAction', { didSet }) } if (didSet) { storeInfo.triggerUpdate() didSet = false } return val } let isTriggering = false const proxiedStore = new Proxy(storeInstance, { // GET get(_, key) { // setup action one time if (key in wrappedActions) { return wrappedActions[key] } if (key in passThroughKeys) { return Reflect.get(storeInstance, key) } if (key === UNWRAP_PROXY) { return storeInstance } if (key === UNWRAP_STORE_INFO) { return storeInfo } if (storeAccessTrackers.size) { storeAccessTrackers.forEach((cb) => cb(storeInfo)) } if (typeof key !== 'string') { return Reflect.get(storeInstance, key) } // non-actions... if (!storeInfo.disableTracking) { if (gettersState.isGetting) { gettersState.curGetKeys.add(key) } else { // storeInstance[TRACK](key, shouldDebug) } } if (key in getters) { if (getCache.has(key)) { return getCache.get(key) } // track get deps curGetKeys.clear() const isSubGetter = gettersState.isGetting gettersState.isGetting = true const res = getters[key].call(proxiedStore) if (!isSubGetter) { gettersState.isGetting = false } // store inverse lookup for (const gk of curGetKeys) { if (!depsToGetter.has(gk)) { depsToGetter.set(gk, new Set()) } const cur = depsToGetter.get(gk)! cur.add(key) } // TODO i added this !isSubGetter, seems logical but haven't validated // has diff performance tradeoffs, not sure whats desirable // if (!isSubGetter) { getCache.set(key, res) // } return res } return Reflect.get(storeInstance, key) }, // SET set(target, key, value, receiver) { const cur = Reflect.get(target, key) const res = Reflect.set(target, key, value, receiver) // only update if changed, simple compare if (res && cur !== value) { // clear getters cache that rely on this if (typeof key === 'string') { clearGetterCache(key) if (shouldDebug) { setters.add({ key, value }) if (getShouldDebug(storeInfo)) { console.info('(debug) SET', res, key, value) } } if (process.env.NODE_ENV === 'development' && shouldDebug) { console.info('SET...', { key, value }) } } if (!isTriggering) { // trigger only once per event loop isTriggering = true waitForEventLoop(() => { storeInfo.triggerUpdate() isTriggering = false }) } } return res }, }) function clearGetterCache(setKey: string) { const parentGetters = depsToGetter.get(setKey) getCache.delete(setKey) if (!parentGetters) { return } for (const gk of parentGetters) { getCache.delete(gk) if (depsToGetter.has(gk)) { clearGetterCache(gk) } } } return proxiedStore } const waitForEventLoop = process.env.NODE_ENV === 'test' || process.env.TAMAGUI_TARGET === 'native' ? (cb: Function) => cb() : queueMicrotask let counter = 0 const passThroughKeys = { subscribe: true, _version: true, _trackers: true, $$typeof: true, _listeners: true, _enableTracking: true, } export type StoreTracker = { tracked: Set component?: any last?: any lastKeys?: any } function getShouldDebug(storeInfo: StoreInfo) { const info = { storeInstance: storeInfo.store } const trackers = storeInfo.trackers return [...trackers].some( (tracker) => tracker.component && shouldDebug(tracker.component, info) ) }