UNPKG

dreamstate

Version:

Store management library based on react context and observers

448 lines (432 loc) 20.4 kB
'use strict'; var createProvider = require('./lib.js'); var react = require('react'); require('tslib'); require('shallow-equal'); /** * Factory function that creates a bound method descriptor for a given method. * This ensures the method is bound to the instance, preserving the correct `this` context when invoked. * * @template T The type of the method being bound. * @param {TypedPropertyDescriptor<T>} from The original typed property descriptor of the method to bind. * @param {PropertyKey} property The property key of the method being modified. * @returns {PropertyDescriptor} A new property descriptor with the method bound to the instance. */ function createBoundDescriptor(from, property) { // Todo: Wait for autobind merge with fix of shared callbacks issue and other. var definingProperty = false; return { configurable: true, get: function () { if (definingProperty /* this === target.prototype || - will it fire? Check parent prototypes? Object.prototype.hasOwnProperty.call(this, property) || typeof from.value !== "function" */) { return from.value; } // Expect only functions to be called, throw errors on other cases. var bound = from.value.bind(this); definingProperty = true; Object.defineProperty(this, property, { configurable: true, writable: false, value: bound }); definingProperty = false; return bound; }, set: function () { throw new createProvider.DreamstateError(createProvider.EDreamstateErrorCode.RESTRICTED_OPERATION, "Direct runtime modification of bound method is not allowed."); } }; } /** * Decorator factory that modifies the method descriptor to bind the method to the prototype instance. * This ensures that the method retains the correct `this` context when invoked. * * All credits: 'https://www.npmjs.com/package/autobind-decorator'. * Modified to support proposal syntax. * * @returns {MethodDecorator} A method decorator that binds the method to the instance prototype. */ function Bind() { return function (targetOrDescriptor, propertyKey, descriptor) { // Different behaviour for legacy and proposal decorators. if (propertyKey && descriptor) { return createBoundDescriptor(descriptor, propertyKey); } else { targetOrDescriptor.descriptor = createBoundDescriptor(targetOrDescriptor.descriptor, targetOrDescriptor.key); } }; } // Todo: Wait for proper proposal decorators. // Todo: Tests. function createMethodDecorator(resolver) { return function (prototypeOrDescriptor, method) { if (prototypeOrDescriptor && method) { resolver(method, prototypeOrDescriptor.constructor); return prototypeOrDescriptor; } else { prototypeOrDescriptor.finisher = function (targetClass) { resolver(prototypeOrDescriptor.key, targetClass); }; return prototypeOrDescriptor; } }; } /** * Class method decorator factory that marks the decorated method as a handler for specified query types. * * This decorator ensures that the decorated method will be invoked when a query of the specified type(s) * is triggered within the current scope. It supports handling a single query type or an array of query types. * The supported query types include `string`, `number`, and `symbol`. * * @param {TQueryType | Array<TQueryType>} queryType - The query type or an array of query types * that the decorated method will handle. * @returns {MethodDecorator} A method decorator that attaches the query handler functionality to the method. */ function OnQuery(queryType) { if (!createProvider.isCorrectQueryType(queryType)) { throw new createProvider.DreamstateError(createProvider.EDreamstateErrorCode.INCORRECT_QUERY_TYPE, typeof queryType); } /* * Support old and new decorators with polyfill. */ return createMethodDecorator(function (method, ManagerClass) { if (!(ManagerClass.prototype instanceof createProvider.ContextManager)) { throw new createProvider.DreamstateError(createProvider.EDreamstateErrorCode.TARGET_CONTEXT_MANAGER_EXPECTED, "Only ContextManager extending classes methods can be decorated as query handlers."); } if (createProvider.QUERY_METADATA_REGISTRY.has(ManagerClass)) { createProvider.QUERY_METADATA_REGISTRY.get(ManagerClass).push([method, queryType]); } else { createProvider.QUERY_METADATA_REGISTRY.set(ManagerClass, [[method, queryType]]); } }); } /** * Class method decorator factory that marks the decorated method as a handler for specified signal types. * * This decorator ensures that the decorated method is invoked when a signal of the specified type(s) * is emitted within the current scope. It supports handling a single signal type or an array of signal types. * * Supported signal types include: `string`, `number`, and `symbol`. * * @param {(TSignalType | Array<TSignalType>)} signalType - The signal type or an array of signal types * that the decorated method will handle. * @returns {MethodDecorator} A method decorator that attaches the handler functionality to the method. */ function OnSignal(signalType) { /* * If Array: * - Check not empty * - Validate all elements * If single type: * - Check the only value */ if (Array.isArray(signalType) ? signalType.length === 0 || signalType.some(function (it) { return !createProvider.isCorrectSignalType(it); }) : !createProvider.isCorrectSignalType(signalType)) { throw new createProvider.DreamstateError(createProvider.EDreamstateErrorCode.INCORRECT_PARAMETER, "Unexpected signal type provided, expected symbol, string, number or array of it. Got: ".concat(typeof signalType, ".")); } /* * Support old and new decorators with polyfill. */ return createMethodDecorator(function (method, ManagerClass) { if (!(ManagerClass.prototype instanceof createProvider.ContextManager)) { throw new createProvider.DreamstateError(createProvider.EDreamstateErrorCode.INCORRECT_PARAMETER, "Only ContextManager extending classes methods can be decorated as handlers."); } if (createProvider.SIGNAL_METADATA_REGISTRY.has(ManagerClass)) { createProvider.SIGNAL_METADATA_REGISTRY.get(ManagerClass).push([method, signalType]); } else { createProvider.SIGNAL_METADATA_REGISTRY.set(ManagerClass, [[method, signalType]]); } }); } /** * Lazy initializer of current scope context provider props object that preserves object references. * Composes props object that will be same for each tree re-render. * * @returns {ProviderProps<IScopeContext>} The initialized props object for the scope provider. */ function scopeStateInitializer() { return { value: createProvider.createScope() }; } /** * Provides an isolated scope for signaling and context managers. * * The `ScopeProvider` component wraps its children within a dedicated scope, ensuring that signals * and context managers operate independently from other parts of the React tree. This isolation * helps prevent interference between different parts of the application and maintains the integrity * of context data and signal handling. * * @param {IScopeProviderProps} props - The properties for the scope provider, including the children * to be rendered within the isolated scope. * @returns {ReactElement} A React element representing the scope provider. */ function ScopeProvider(props) { var scopeState = react.useState(scopeStateInitializer); return react.createElement(createProvider.ScopeContext.Provider, scopeState[0], props.children); } /* * Easier devtools usage for dev environment. */ { ScopeProvider.displayName = "Dreamstate.ScopeProvider"; } /** * Creates an actions store, which is an object containing readonly method links representing actions. * The intention is to provide a container that is visually and programmatically distinguishable as * a storage of actions. * * Every call to 'setContext' will perform a comparison of the current 'context' before updating, * excluding the actions object, as it is expected to be immutable and consistent. * * @template T The type of actions object. * @param {T} actions - An object containing a set of mutation operations (actions). * @returns {Readonly<T>} An instance of an ActionsStore class containing the supplied actions. */ function createActions(actions) { if (createProvider.isObject(actions)) { return new createProvider.ActionsStore(actions); } else { throw new createProvider.DreamstateError(createProvider.EDreamstateErrorCode.INCORRECT_PARAMETER, "Actions store should be initialized with an object, got '".concat(typeof actions, "' instead.")); } } /** * Creates a computed value that will be re-calculated after each context update. * The computed value is recalculated only when its dependencies, as determined by the memo function, * are updated. * * @template T The type of the computed value. * @template C The type of the context the computed value depends on. * @param {Function} selector A generic selector function that returns computed values on update. * @param {Function} memo An optional memo checker function that returns an array of dependencies, * indicating whether the computed value should be updated. * @returns {TComputed<T, C>} A computed value object that will be updated based on context changes. */ function createComputed(selector, memo) { if (createProvider.isFunction(selector) && (createProvider.isUndefined(memo) || createProvider.isFunction(memo))) { // Cast computed to T & TComputed since it works like state object later. return new createProvider.ComputedValue(selector, memo); } else { throw new createProvider.DreamstateError(createProvider.EDreamstateErrorCode.INCORRECT_PARAMETER, "Computed value should be initialized with functional selector and optional memo function."); } } /** * A utility class for extending nested stores with loadable state and enabling shallow checking. * * This class is used by the `ContextManager` to manage asynchronous data, track loading states, * and detect changes efficiently. It helps in handling data fetching scenarios where state * needs to reflect loading, success, or error conditions. */ var LoadableStore = /** @class */function () { function LoadableStore(value, isLoading, error) { this.value = value; this.isLoading = isLoading; this.error = error; } /** * Optionally sets a value for the loading state. * If provided, it updates the loading state with the given value. * * @template T - The type of the loading state value. * @template E - The type of error state, if any. * @param {T | undefined} value - The value to set for the loading state. If not provided, the loading * state will remain unchanged. * @returns {ILoadable<T, E>} The updated loadable instance with the new loading state. */ LoadableStore.prototype.asLoading = function (value) { return new LoadableStore(arguments.length > 0 ? value : this.value, true, null); }; /** * Optionally sets a value for the failed state. * If provided, it updates the state to reflect a failure and optionally includes the provided value. * * @template T - The type of the loading state value. * @template E - The type of the error state. * @param {E} error - The error object representing the failure state. * @param {T | undefined} value - Optional value to associate with the failed state. If not provided, * the failure state will only reflect the error. * @returns {ILoadable<T, E>} The updated loadable instance with the failed state. */ LoadableStore.prototype.asFailed = function (error, value) { return new LoadableStore(arguments.length > 1 ? value : this.value, false, error); }; /** * Optionally sets a value for the ready state. * If provided, it updates the state to reflect readiness and optionally includes the provided value. * * @template T - The type of the loading state value. * @template E - The type of the error state. * @param {T | undefined} value - Optional value to associate with the ready state. If not provided, * the ready state will reflect only the readiness. * @returns {ILoadable<T, E>} The updated loadable instance with the ready state. */ LoadableStore.prototype.asReady = function (value) { return new LoadableStore(arguments.length > 0 ? value : this.value, false, null); }; /** * Optionally sets the loading state and error for an update. * This method allows updating the loadable state with a new value, and optionally sets * the loading state and any associated error. * * @template T - The type of the loading state value. * @template E - The type of the error state. * @param {T} value - The new value to associate with the updated state. * @param {boolean} [isLoading] - Optional flag to set the loading state. Defaults to `false` if not provided. * @param {E | null} [error] - Optional error to associate with the update. Defaults to `null` if not provided. * @returns {ILoadable<T, E>} The updated loadable instance with the new value, loading state, and error. */ LoadableStore.prototype.asUpdated = function (value, isLoading, error) { return new LoadableStore(value, arguments.length > 1 ? isLoading : this.isLoading, arguments.length > 2 ? error : this.error); }; return LoadableStore; }(); /** * Creates a loadable value, which is useful when the context value has error/loading states. * The loadable value can represent different states: loading, ready, or error. * * @template T The type of the value being loaded. * @template E The type of the error (defaults to `Error`). * @param {T | null} [value] The initial value or `null` if not yet loaded. * @param {boolean} [isLoading] A flag indicating whether the value is in a loading state. * @param {E | null} [error] The error if the value failed to load, or `null` if no error. * @returns {ILoadable<T, E>} A loadable value utility representing the current state of the value. */ function createLoadable(value, isLoading, error) { if (value === void 0) { value = null; } if (isLoading === void 0) { isLoading = false; } if (error === void 0) { error = null; } return new LoadableStore(value, isLoading, error); } /** * Creates a nested sub-state for deeper shallow checking, useful when the context contains nested objects * that need to be checked separately during updates. * * As an example: * - `{ first: 'first', second: { one: 1, two: 2 } }` - `first` and `second` will be checked, * while `one` and `two` will be ignored. * - `{ first: 'first', second: createNested({ one: 1, two: 2 }) }` - `first`, `one`, and `two` * will be checked during updates. * * @template T The type of the nested object. * @param {T} initialValue The initial value of the nested store object. * @returns {TNested<T>} An instance of a nested store containing the initial state, marked for deeper shallow checking. */ function createNested(initialValue) { if (createProvider.isObject(initialValue)) { return Object.assign(new createProvider.NestedStore(), initialValue); } else { throw new createProvider.DreamstateError(createProvider.EDreamstateErrorCode.INCORRECT_PARAMETER, "Nested stores should be initialized with an object, got '".concat(typeof initialValue, "' instead.")); } } /** * A custom hook that subscribes to context updates with memoization. * * This hook functions similarly to the standard `useContext` hook but adds memoization based on a dependency selector. * It is particularly useful when a context manager contains a large or frequently changing state, * yet a component only requires updates for specific parts of that state. * * @template T - The type of the context state object. * @template D - The type of the context manager constructor that provides the context state. * @param {D} ManagerClass - The class constructor for the context manager which supplies the context state. * @param {(context: T) => unknown[]} dependenciesSelector - A selector of dependencies from the context state. * The hook will re-render the component only when these selected dependencies change. * @returns {T} The current context state, memoized based on the provided dependencies. */ function useContextWithMemo(ManagerClass, dependenciesSelector) { var scope = react.useContext(createProvider.ScopeContext); var state = react.useState(function () { return scope.INTERNAL.REGISTRY.CONTEXT_STATES_REGISTRY.get(ManagerClass); }); /* * Fire state change only if any of dependencies is updated. */ react.useEffect(function () { var initialState = state[0]; var setState = state[1]; var subscriptionState = scope.INTERNAL.REGISTRY.CONTEXT_STATES_REGISTRY.get(ManagerClass) || null; // Flag `null` if HMR/StrictMode reset happen, usually just means HMR manager replacing or react 18 strict mode. var observed = subscriptionState ? dependenciesSelector(subscriptionState) : null; /* * Expected to be skipped first time, when state is picked with selector from registry. * Expected to be fired every time ManagerClass is changed - when HMR is called (state is same, effect triggered). */ if (initialState !== subscriptionState) { setState(subscriptionState); } return scope.INTERNAL.subscribeToManager(ManagerClass, function (nextContext) { if (!observed) { observed = dependenciesSelector(nextContext); return setState(nextContext); } var nextObserved = dependenciesSelector(nextContext); for (var it_1 = 0; it_1 < nextObserved.length; it_1++) { if (observed[it_1] !== nextObserved[it_1]) { observed = nextObserved; return setState(nextContext); } } }); }, [ManagerClass, scope.INTERNAL]); return state[0]; } /** * Custom hook that wraps `useContext` to provide scoped context data with optional update * optimization via a dependency selector. It returns the context from the specified manager * class and limits re-renders to changes in selected dependencies. * * @template T - The type of the context state. * @template D - The type of the context manager constructor. * @param {D} ManagerClass - The manager class whose instance context is returned. * @param {(context: D["prototype"]["context"]) => TAnyValue[]} dependenciesSelector - An optional function * that receives the current context and returns an array of dependencies. The component re-renders * only if values in this array change. Without it, the component updates on every context change. * @returns {D["prototype"]["context"]} The context data provided by the manager within the current * dreamstate scope. */ function useManager(ManagerClass, dependenciesSelector) { /* * Use pub-sub + checking approach only if dependency selector was provided. * If component should update on every change there is no point of doing anything additional to default context. */ return dependenciesSelector ? useContextWithMemo(ManagerClass, dependenciesSelector) : react.useContext(ManagerClass.REACT_CONTEXT); } /** * Custom hook that retrieves the current scope context. * This hook provides access to the current scope in the React tree. It returns a bundle of * functions and data that allow for processing data, signals and queries within that scope. * * @returns {IScopeContext} The current scope context in the React tree. */ function useScope() { return react.useContext(createProvider.ScopeContext); } exports.ContextManager = createProvider.ContextManager; exports.DreamstateError = createProvider.DreamstateError; Object.defineProperty(exports, "DreamstateErrorCode", { enumerable: true, get: function () { return createProvider.EDreamstateErrorCode; } }); exports.createProvider = createProvider.createProvider; exports.createScope = createProvider.createScope; exports.Bind = Bind; exports.OnQuery = OnQuery; exports.OnSignal = OnSignal; exports.ScopeProvider = ScopeProvider; exports.createActions = createActions; exports.createComputed = createComputed; exports.createLoadable = createLoadable; exports.createNested = createNested; exports.useManager = useManager; exports.useScope = useScope;