import { useLayoutEffect, useRef, useState as useStateRN } from 'react'; import { Huds0nError } from '@huds0n/error'; import { EventRegister } from './EventRegister'; import { StateCache } from './StateCache'; import type { Types } from './types'; export class SharedState { private _debugLabel: string | undefined; private _eventRegister: EventRegister; private _stateCache: StateCache; private _componentUnregister: symbol; private _componentUpdateId: symbol; constructor(defaultState: S, options: Types.Options = {}) { const { debugLabel } = options; this._debugLabel = debugLabel; this._stateCache = new StateCache(defaultState); this._eventRegister = new EventRegister(this._stateCache); this._componentUnregister = Symbol('shared_state_component_unregister'); this._componentUpdateId = Symbol('shared_state_component_update_id'); this.debugger(this); this.addListener = this.addListener.bind(this); this.debugger = this.debugger.bind(this); this.refresh = this.refresh.bind(this); this.register = this.register.bind(this); this.removeAllListeners = this.removeAllListeners.bind(this); this.reset = this.reset.bind(this); this.setProp = this.setProp.bind(this); this.setState = this.setState.bind(this); this.toString = this.toString.bind(this); this.unregister = this.unregister.bind(this); this.useProp = this.useProp.bind(this); this.useState = this.useState.bind(this); } get state() { return this._stateCache.current; } set state(object) { throw Huds0nError.create({ name: 'State Error', code: 'UPDATE_STATE_ERROR', message: 'State cannot be mutated directly, use the setState() method instead.', severity: 'WARN', }); } get prevState() { return this._stateCache.prev; } set prevState(object) { throw Huds0nError.create({ name: 'State Error', code: 'UPDATE_PREV_STATE_ERROR', message: 'Prev state is read only.', severity: 'WARN', }); } setState(partialState: Partial) { try { const updatedState = this._stateCache.update(partialState); // Only send if a change has occured if (updatedState) { this._eventRegister.triggerUpdated(updatedState); this.debugger({ send: updatedState }); } return updatedState; } catch (error) { throw Huds0nError.create({ name: 'State Error', code: 'UPDATE_STATE_ERROR', message: 'Update state error', severity: 'ERROR', from: error, }); } } setProp(propName: K, newValue: S[K]) { this.setState({ [propName]: newValue } as any); } refresh(refreshKey?: Types.UpdateKeys) { try { this._eventRegister.triggerRefresh(refreshKey || true); } catch (error) { throw Huds0nError.create({ name: 'State Error', code: 'REFRESH_STATE_ERROR', message: 'Refresh state error', severity: 'ERROR', from: error, }); } } reset(resetData?: S) { try { const updatedState = this._stateCache.reset(resetData); if (updatedState) { this._eventRegister.triggerUpdated(updatedState); } this.debugger({ resetState: this._stateCache.current, }); } catch (error) { throw Huds0nError.create({ name: 'State Error', code: 'RESET_STATE_ERROR', message: 'Reset state error', severity: 'ERROR', from: error, }); } } // EVENT REGISTRATION addListener( callback: (current: S, prev: Partial) => void, trigger?: Types.UpdateKeys, ) { return this._eventRegister.add(callback, trigger || true); } removeAllListeners() { this._eventRegister.removeAll(); } // CLASS COMPONENT REGISTRATION register(component: React.Component, updateKeys?: Types.UpdateKeys) { const removeListener = this.addListener(() => { component.setState({ [this._componentUpdateId]: Symbol('shared_state_component_updater'), }); }, updateKeys); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore component[this._componentUnregister] = removeListener; this.debugger({ register: { component, updateKeys } }); } unregister(component: React.Component) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore component[this._componentUnregister]?.(); this.debugger({ unregister: { component } }); } // FUNCTIONAL COMPONENT REGISTRATION useState( updateKeys?: Types.UpdateKeys, shouldUpdate?: Types.ShouldUpdate, ): [S, Types.SetState] { // Prevents updating unmounted component const isMounted = useRef(false); const [, setReRender] = useStateRN({}); const reRenderComponent = () => { if (!shouldUpdate || shouldUpdate(this.state, this.prevState)) { // Required to stop React Warning: Cannot update a component from inside the function body of a different component setImmediate(() => { isMounted.current && setReRender({}); }); } }; useLayoutEffect(() => { isMounted.current = true; const removeListener = this.addListener(reRenderComponent, updateKeys); this.debugger({ registerHook: { updateKeys } }); return () => { isMounted.current = false; removeListener(); }; }, []); const setValue = (partialState: Partial) => { this.setState(partialState); }; return [this.state, setValue]; } useProp(updateKey: K): [S[K], Types.SetProp] { this.useState(updateKey); return [this.state[updateKey], (value) => this.setProp(updateKey, value)]; } // DEBUGGING debugger(...log: any[]) { if (this._debugLabel) console.log(this._debugLabel, ...log); } toString() { return JSON.stringify(this.state, undefined, 2); } } export type { Types as SharedStateTypes } from './types';