UNPKG

mobx-view-model

Version:

⚡ Clean MVVM for React + MobX | Powerful ViewModels made simple ⚡

831 lines (830 loc) 25.3 kB
import { createCounter, createGlobalConfig, createPubSub } from "yummies/complex"; import { applyObservable as applyObservable$1 } from "yummies/mobx"; import { observable, computed, action, comparer, runInAction } from "mobx"; import { isShallowEqual } from "yummies/data"; import { startViewTransitionSafety } from "yummies/html"; import { createContext, useEffect, useLayoutEffect, useRef, useMemo, useContext, forwardRef } from "react"; import { jsx, Fragment } from "react/jsx-runtime"; import { observer } from "mobx-react-lite"; import { loadable } from "react-simple-loadable"; import { unpackAsyncModule } from "yummies/imports"; const staticCounter = createCounter((counter) => counter.toString(16)); const generateVmId = (ctx) => { if (!ctx.generateId) { const staticId = staticCounter(); const counter = createCounter( (counter2) => counter2.toString().padStart(5, "0") ); ctx.generateId = () => `${staticId}_${counter().toString().padStart(5, "0")}`; } if (process.env.NODE_ENV === "production") { return ctx.generateId(); } else { const viewModelName = ctx.VM?.name ?? ""; if (viewModelName) { return `${viewModelName}_${ctx.generateId()}`; } else { return ctx.generateId(); } } }; const mergeVMConfigs = (...configs) => { const result = { ...viewModelsConfig, startViewTransitions: structuredClone(viewModelsConfig.startViewTransitions), observable: { viewModels: { ...viewModelsConfig.observable.viewModels }, viewModelStores: { ...viewModelsConfig.observable.viewModelStores } } }; configs.forEach((config) => { if (!config) { return; } const { startViewTransitions, comparePayload, observable: observable2, generateId, ...otherConfigUpdates } = config; if (generateId) { result.generateId = generateId; } if (startViewTransitions) { const startViewTransitonsUpdate = typeof startViewTransitions === "boolean" ? { mount: startViewTransitions, payloadChange: startViewTransitions, unmount: startViewTransitions } : startViewTransitions; Object.assign(result.startViewTransitions, startViewTransitonsUpdate); } if (observable2?.viewModels) { Object.assign(result.observable.viewModels, observable2.viewModels || {}); } if (observable2?.viewModelStores) { Object.assign( result.observable.viewModelStores, observable2.viewModelStores || {} ); } if (comparePayload != null) { result.comparePayload = comparePayload; } Object.assign(result, otherConfigUpdates); }); return result; }; const viewModelsConfig = createGlobalConfig( { comparePayload: false, payloadComputed: "struct", payloadObservable: "ref", wrapViewsInObserver: true, startViewTransitions: { mount: false, payloadChange: false, unmount: false }, observable: { viewModels: { useDecorators: true }, viewModelStores: { useDecorators: true } }, generateId: generateVmId, factory: (config) => { const VM = config.VM; return new VM({ ...config, vmConfig: mergeVMConfigs(config.vmConfig) }); }, hooks: { storeCreate: createPubSub() } }, Symbol.for("VIEW_MODELS_CONFIG") ); const applyObservable = (context, annotationsArray, observableConfig) => { if (observableConfig.custom) { return observableConfig.custom(context, annotationsArray); } if (observableConfig.disableWrapping) { return; } applyObservable$1(context, annotationsArray, observableConfig.useDecorators); }; const isViewModel = (value) => value.payloadChanged; const isViewModelClass = (value) => value.prototype.payloadChanged; const isViewModeSimpleClass = (value) => !isViewModelClass(value); const baseAnnotations$1 = [ [observable.ref, "_isMounted", "_isUnmounting"], [computed, "isMounted", "isUnmounting", "parentViewModel"], [action, "didMount", "didUnmount", "willUnmount", "setPayload"], [action.bound, "mount", "unmount"] ]; class ViewModelBase { constructor(vmParams) { this.vmParams = vmParams; this.id = vmParams.id; this.vmConfig = mergeVMConfigs(vmParams.vmConfig); this._payload = vmParams.payload; this.props = vmParams.props ?? {}; this.abortController = new AbortController(); this.unmountSignal = this.abortController.signal; if (this.vmConfig.comparePayload === "strict") { this.isPayloadEqual = comparer.structural; } else if (this.vmConfig.comparePayload === "shallow") { this.isPayloadEqual = isShallowEqual; } else if (typeof this.vmConfig.comparePayload === "function") { this.isPayloadEqual = this.vmConfig.comparePayload; } const annotations = [...baseAnnotations$1]; if (this.vmConfig.payloadObservable !== false) { annotations.push([ observable[this.vmConfig.payloadObservable ?? "ref"], "_payload" ]); } if (this.vmConfig.payloadComputed) { if (this.vmConfig.payloadComputed === "struct") { annotations.push([computed.struct, "payload"]); } else { annotations.push([ computed({ equals: this.vmConfig.payloadComputed === true ? void 0 : this.vmConfig.payloadComputed }), "payload" ]); } } applyObservable(this, annotations, this.vmConfig.observable.viewModels); } abortController; unmountSignal; id; _isMounted = false; _isUnmounting = false; _payload; vmConfig; isPayloadEqual; props; get payload() { return this._payload; } get viewModels() { if (process.env.NODE_ENV !== "production" && !this.vmParams.viewModels) { console.error( `Error #3: No access to ViewModelStore. This happened because [viewModels] param is not provided during to creating instance ViewModelBase. More info: https://js2me.github.io/mobx-view-model/errors/3` ); } return this.vmParams.viewModels; } get isMounted() { return this._isMounted; } get isUnmounting() { return this._isUnmounting; } willUnmount() { this._isUnmounting = true; } /** * Empty method to be overridden */ willMount() { } /** * The method is called when the view starts mounting */ mount() { this.vmConfig.onMount?.(this); startViewTransitionSafety( () => { runInAction(() => { this._isMounted = true; }); }, { disabled: !this.vmConfig.startViewTransitions.mount } ); this.didMount(); } /** * The method is called when the view was mounted */ didMount() { } /** * The method is called when the view starts unmounting */ unmount() { this.vmConfig.onUnmount?.(this); startViewTransitionSafety( () => { runInAction(() => { this._isMounted = false; }); }, { disabled: !this.vmConfig.startViewTransitions.unmount } ); this.didUnmount(); } /** * The method is called when the view was unmounted */ didUnmount() { this.abortController.abort(); runInAction(() => { this._isUnmounting = false; }); } /** * The method is called when the payload of the view model was changed * * The state - "was changed" is determined inside the setPayload method */ payloadChanged(payload, prevPayload) { } /** * Returns the parent view model * For this property to work, the getParentViewModel method is required */ get parentViewModel() { return this.getParentViewModel(this.vmParams.parentViewModelId); } /** * The method is called when the payload changes in the react component */ setPayload(payload) { if (!this.isPayloadEqual?.(this._payload, payload)) { startViewTransitionSafety( () => { runInAction(() => { this.payloadChanged(payload, this._payload); this._payload = payload; }); }, { disabled: !this.vmConfig.startViewTransitions.payloadChange } ); } } /** * The method of getting the parent view model */ getParentViewModel(parentViewModelId) { return this.vmParams.parentViewModel ?? this.viewModels?.get(parentViewModelId); } } const baseAnnotations = [ [computed, "mountedViewsCount"], [ action, "mount", "unmount", "attachVMConstructor", "attach", "detach", "linkComponents", "unlinkComponents" ] ]; class ViewModelStoreBase { constructor(config) { this.config = config; this.viewModels = observable.map([], { deep: false }); this.linkedComponentVMClasses = observable.map([], { deep: false }); this.viewModelIdsByClasses = observable.map([], { deep: true }); this.instanceAttachedCount = observable.map([], { deep: false }); this.mountingViews = observable.set([], { deep: false }); this.unmountingViews = observable.set([], { deep: false }); this.vmConfig = mergeVMConfigs(config?.vmConfig); this.viewModelsTempHeap = /* @__PURE__ */ new Map(); applyObservable( this, baseAnnotations, this.vmConfig.observable.viewModelStores ); this.vmConfig.hooks.storeCreate(this); } viewModels; linkedComponentVMClasses; viewModelIdsByClasses; instanceAttachedCount; /** * It is temp heap which is needed to get access to view model instance before all initializations happens */ viewModelsTempHeap; /** * Views waiting for mount */ mountingViews; /** * Views waiting for unmount */ unmountingViews; vmConfig; get mountedViewsCount() { return [...this.instanceAttachedCount.values()].reduce( (sum, count) => sum + count, 0 ); } processCreateConfig(config) { this.linkComponents( config.VM, config.component, config.ctx?.externalComponent ); } createViewModel(config) { const VMConstructor = config.VM; const vmConfig = mergeVMConfigs(this.vmConfig, config.vmConfig); const vmParams = { ...config, vmConfig }; if (vmConfig.factory) { return vmConfig.factory(vmParams); } return new VMConstructor(vmParams); } generateViewModelId(config) { if (config.id) { return config.id; } else { return this.vmConfig.generateId(config.ctx); } } linkComponents(VM, ...components) { components.forEach((component) => { if (component && !this.linkedComponentVMClasses.has(component)) { this.linkedComponentVMClasses.set(component, VM); } }); } unlinkComponents(...components) { components.forEach((component) => { if (component && this.linkedComponentVMClasses.has(component)) { this.linkedComponentVMClasses.delete(component); } }); } getIds(vmLookup) { if (!vmLookup) return []; if (typeof vmLookup === "string") { return [vmLookup]; } const viewModelClass = this.linkedComponentVMClasses.get( vmLookup ) || vmLookup; const viewModelIds = this.viewModelIdsByClasses.get(viewModelClass) || []; return viewModelIds; } getId(vmLookup) { const viewModelIds = this.getIds(vmLookup); if (viewModelIds.length === 0) return null; if (process.env.NODE_ENV !== "production" && viewModelIds.length > 1) { console.warn( `Found more than 1 view model with the same identifier. Last instance will been returned` ); } return viewModelIds.at(-1); } has(vmLookup) { const id = this.getId(vmLookup); if (!id) return false; return this.viewModels.has(id); } get(vmLookup) { const id = this.getId(vmLookup); if (!id) return null; const observedVM = this.viewModels.get(id); return observedVM ?? this.viewModelsTempHeap.get(id) ?? null; } getOrCreateVmId(model) { if (!model.id) { model.id = this.vmConfig.generateId({ VM: model.constructor }); } return model.id; } getAll(vmLookup) { const viewModelIds = this.getIds(vmLookup); return viewModelIds.map((id) => this.viewModels.get(id)); } async mount(model) { const modelId = this.getOrCreateVmId(model); this.mountingViews.add(modelId); await model.mount?.(); runInAction(() => { this.mountingViews.delete(modelId); }); } async unmount(model) { const modelId = this.getOrCreateVmId(model); this.unmountingViews.add(modelId); await model.unmount?.(); runInAction(() => { this.unmountingViews.delete(modelId); }); } attachVMConstructor(model) { const modelId = this.getOrCreateVmId(model); const constructor = model.constructor; if (this.viewModelIdsByClasses.has(constructor)) { const vmIds = this.viewModelIdsByClasses.get(constructor); if (!vmIds.includes(modelId)) { vmIds.push(modelId); } } else { this.viewModelIdsByClasses.set(constructor, [modelId]); } } dettachVMConstructor(model) { const constructor = model.constructor; if (this.viewModelIdsByClasses.has(constructor)) { const vmIds = this.viewModelIdsByClasses.get(constructor).filter((it) => it !== model.id); if (vmIds.length > 0) { this.viewModelIdsByClasses.set(constructor, vmIds); } else { this.viewModelIdsByClasses.delete(constructor); } } } markToBeAttached(model) { const modelId = this.getOrCreateVmId(model); this.viewModelsTempHeap.set(modelId, model); if ("attachViewModelStore" in model) { model.attachViewModelStore(this); } this.attachVMConstructor(model); } async attach(model) { const modelId = this.getOrCreateVmId(model); const attachedCount = this.instanceAttachedCount.get(modelId) ?? 0; this.instanceAttachedCount.set(modelId, attachedCount + 1); if (this.viewModels.has(modelId)) { return; } this.viewModels.set(modelId, model); this.attachVMConstructor(model); await this.mount(model); this.viewModelsTempHeap.delete(modelId); } async detach(id) { const attachedCount = this.instanceAttachedCount.get(id) ?? 0; this.viewModelsTempHeap.delete(id); const model = this.viewModels.get(id); if (!model) { return; } const nextInstanceAttachedCount = attachedCount - 1; this.instanceAttachedCount.set(id, nextInstanceAttachedCount); if (nextInstanceAttachedCount <= 0) { if ("willUnmount" in model) { model.willUnmount(); } this.instanceAttachedCount.delete(id); this.viewModels.delete(id); this.dettachVMConstructor(model); await this.unmount(model); } } isAbleToRenderView(id) { const isViewMounting = this.mountingViews.has(id); const hasViewModel = this.viewModels.has(id); return !!id && hasViewModel && !isViewMounting; } clean() { this.viewModels.clear(); this.linkedComponentVMClasses.clear(); this.viewModelIdsByClasses.clear(); this.instanceAttachedCount.clear(); this.mountingViews.clear(); this.unmountingViews.clear(); this.viewModelsTempHeap.clear(); } } const ActiveViewModelContext = createContext(null); const ViewModelsContext = createContext( null ); const ActiveViewModelProvider = ActiveViewModelContext.Provider; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; let useValueImpl = null; if (process.env.NODE_ENV === "production") { useValueImpl = (getValue) => { const valueRef = useRef(null); if (!valueRef.current) { valueRef.current = getValue(); } return valueRef.current; }; } else { useValueImpl = (getValue) => { return useMemo(getValue, []); }; } const useValue = useValueImpl; function useCreateViewModel(VM, payload, config) { if (isViewModelClass(VM)) { return useCreateViewModelBase(VM, payload, config); } return useCreateViewModelSimple(VM, payload); } const useCreateViewModelBase = (VM, payload, config) => { const viewModels = useContext(ViewModelsContext); const parentViewModel = useContext(ActiveViewModelContext); const ctx = config?.ctx ?? {}; const instance = useValue(() => { const id = viewModels?.generateViewModelId({ ...config, ctx, VM, parentViewModelId: parentViewModel?.id ?? null }) ?? config?.id ?? viewModelsConfig.generateId(ctx); const instanceFromStore = viewModels ? viewModels.get(id) : null; if (instanceFromStore) { return instanceFromStore; } else { const configCreate = { ...config, vmConfig: config?.vmConfig, id, parentViewModelId: parentViewModel?.id, payload: payload ?? {}, VM, viewModels, parentViewModel, ctx }; viewModels?.processCreateConfig(configCreate); const instance2 = config?.factory?.(configCreate) ?? viewModels?.createViewModel(configCreate) ?? viewModelsConfig.factory(configCreate); instance2.willMount(); viewModels?.markToBeAttached(instance2); return instance2; } }); useIsomorphicLayoutEffect(() => { if (viewModels) { viewModels.attach(instance); return () => { viewModels.detach(instance.id); }; } else { instance.mount(); return () => { instance.willUnmount(); instance.unmount(); }; } }, [instance]); instance.setPayload(payload ?? {}); return instance; }; const useCreateViewModelSimple = (VM, payload) => { const viewModels = useContext(ViewModelsContext); const parentViewModel = useContext(ActiveViewModelContext); const instance = useValue(() => { const instance2 = new VM(); instance2.parentViewModel = parentViewModel; viewModels?.markToBeAttached(instance2); return instance2; }); if ("setPayload" in instance) { useLayoutEffect(() => { instance.setPayload(payload); }, [payload]); } useIsomorphicLayoutEffect(() => { if (viewModels) { viewModels.attach(instance); return () => { viewModels.detach(instance.id); }; } else { instance.mount?.(); return () => { instance.unmount?.(); }; } }, [instance]); return instance; }; const useViewModel = (vmLookup) => { const viewModels = useContext(ViewModelsContext); const activeViewModel = useContext(ActiveViewModelContext); const model = viewModels?.get(vmLookup); let devModeModelRef = void 0; if (process.env.NODE_ENV !== "production") { devModeModelRef = useRef(); } if (vmLookup == null || !viewModels) { if (process.env.NODE_ENV !== "production" && !viewModels) { console.warn( "Warning #1: ViewModelStore not found.\n", "Unable to get access to view model by id or class name without using ViewModelStore\n", "Last active view model will be returned.\n", "More info: https://js2me.github.io/mobx-view-model/warnings/1" ); } if (!activeViewModel) { if (process.env.NODE_ENV !== "production") { throw new Error( 'Error #1: Active ViewModel not found.\nThis happened because "vmLookup" for hook "useViewModel" is not provided and hook trying to lookup active view model using ActiveViewModelContext which works only with using "withViewModel" HOC.\nPlease provide "vmLookup" (first argument for "useViewModel" hook) or use "withViewModel" HOC.\nMore info: https://js2me.github.io/mobx-view-model/errors/1' ); } throw new Error( "Error #1: https://js2me.github.io/mobx-view-model/errors/1" ); } if (process.env.NODE_ENV !== "production") { devModeModelRef.current = activeViewModel; } return activeViewModel; } if (!model) { let displayName = ""; if (typeof vmLookup === "string") { displayName = vmLookup; } else if ("name" in vmLookup) { displayName = vmLookup.name; } else { displayName = vmLookup.displayName; } if (process.env.NODE_ENV !== "production") { if (devModeModelRef.current) { return devModeModelRef.current; } else { throw new Error( `Error #2: View model not found for ${displayName}. This happened because your "vmLookup" provided for hook "useViewModel" is not found in "ViewModelStore". More info: https://js2me.github.io/mobx-view-model/errors/2` ); } } else { throw new Error( "Error #2: https://js2me.github.io/mobx-view-model/errors/2" ); } } if (process.env.NODE_ENV !== "production") { devModeModelRef.current = activeViewModel; } return model; }; const OnlyViewModel = observer( ({ model, config, payload, children }) => { const vm = useCreateViewModel(model, payload, config); if (!vm.isMounted) { return null; } if (typeof children === "function") { return children(vm); } return /* @__PURE__ */ jsx(Fragment, { children }); } ); const ViewModelsProvider = ViewModelsContext.Provider; function withViewModel(VM, configOrComponent, configOrNothing) { if (typeof configOrComponent === "function" || configOrComponent && configOrComponent.$$typeof !== void 0) { const config = configOrNothing ?? {}; return withViewModelWrapper( VM, { ...config, ctx: { VM, generateId: config.generateId, ...config.ctx } }, configOrComponent ); } else { const config = configOrComponent ?? {}; const finalConfig = { ...config, ctx: { VM, generateId: config.generateId, ...config.ctx } }; return (Component) => withViewModelWrapper(VM, finalConfig, Component); } } const REACT_MEMO_SYMBOL = Symbol.for("react.memo"); const withViewModelWrapper = (VM, config, OriginalComponent) => { const processViewComponent = config.vmConfig?.processViewComponent ?? viewModelsConfig.processViewComponent; const wrapViewsInObserver = config.vmConfig?.wrapViewsInObserver ?? viewModelsConfig.wrapViewsInObserver; let Component = processViewComponent?.(OriginalComponent, VM, config) ?? OriginalComponent; if (wrapViewsInObserver && Component && Component.$$typeof !== REACT_MEMO_SYMBOL) { Component = observer(Component); } const reactHook = config.reactHook; const getPayload = config.getPayload; const FallbackComponent = config.fallback ?? viewModelsConfig.fallbackComponent; const RawComponent = (allProps, ref) => { const viewModels = useContext(ViewModelsContext); reactHook?.(allProps, config.ctx, viewModels, ref); const { payload: rawPayload, ...componentProps } = allProps; const payload = getPayload?.(allProps) ?? rawPayload; if (config.forwardRef && !("forwardedRef" in componentProps)) { componentProps.forwardedRef = ref; } const model = useCreateViewModel(VM, payload, { ...config, props: componentProps }); const isRenderAllowedByStore = !viewModels || viewModels.isAbleToRenderView(model.id); const isRenderAllowed = isRenderAllowedByStore && model.isMounted !== false; if (isRenderAllowed) { return /* @__PURE__ */ jsx(ActiveViewModelProvider, { value: model, children: Component && /* @__PURE__ */ jsx(Component, { ...componentProps, model }) }); } return FallbackComponent && /* @__PURE__ */ jsx(FallbackComponent, { ...allProps, payload }); }; let ConnectedViewModel = RawComponent; if (config.forwardRef) { ConnectedViewModel = forwardRef(ConnectedViewModel); } ConnectedViewModel = observer(ConnectedViewModel); if (process.env.NODE_ENV !== "production") { ConnectedViewModel.displayName = `ConnectedViewModel(${VM.name}->Component)`; } config.component = ConnectedViewModel; return ConnectedViewModel; }; function withLazyViewModel(loadFunction, configOrFallbackComponent) { const config = typeof configOrFallbackComponent === "function" ? { fallback: configOrFallbackComponent } : configOrFallbackComponent; const patchedConfig = { ...config, ctx: { // @ts-expect-error ...config?.ctx, externalComponent: null } }; const fallbackComponent = patchedConfig?.fallback ?? viewModelsConfig.fallbackComponent; const lazyVM = loadable( async () => { const { Model: ModelOrAsync, View: ViewOrAsync } = await loadFunction(); const [Model, View] = await Promise.all([ unpackAsyncModule(ModelOrAsync), unpackAsyncModule(ViewOrAsync) ]); return withViewModel(Model, patchedConfig)(View); }, { loading: patchedConfig?.loading ?? fallbackComponent, preload: patchedConfig?.preload, throwOnError: patchedConfig?.throwOnError } ); patchedConfig.ctx.externalComponent = lazyVM; return lazyVM; } export { ActiveViewModelContext, ActiveViewModelProvider, OnlyViewModel, ViewModelBase, ViewModelStoreBase, ViewModelsContext, ViewModelsProvider, applyObservable, generateVmId, isViewModeSimpleClass, isViewModel, isViewModelClass, mergeVMConfigs, useCreateViewModel, useViewModel, viewModelsConfig, withLazyViewModel, withViewModel }; //# sourceMappingURL=index.js.map