UNPKG

@zedux/atoms

Version:

A Molecular State Engine for React

371 lines (370 loc) 14.9 kB
import { createStore, zeduxTypes, is, Store, } from '@zedux/core'; import { pluginActions } from '../../utils/plugin-actions.js'; import { getErrorPromiseState, getInitialPromiseState, getSuccessPromiseState, } from '../../utils/promiseUtils.js'; import { AtomApi } from '../AtomApi.js'; import { AtomInstanceBase } from './AtomInstanceBase.js'; const StoreState = 1; const RawState = 2; const getStateType = (val) => { if (is(val, Store)) return StoreState; return RawState; }; const getStateStore = (factoryResult) => { const stateType = getStateType(factoryResult); const stateStore = stateType === StoreState ? factoryResult : createStore(); // define how we populate our store (doesn't apply to user-supplied stores) if (stateType === RawState) { stateStore.setState(typeof factoryResult === 'function' ? () => factoryResult : factoryResult); } return [stateType, stateStore]; }; export class AtomInstance extends AtomInstanceBase { constructor(ecosystem, template, id, params) { super(); this.ecosystem = ecosystem; this.template = template; this.id = id; this.params = params; this.status = 'Initializing'; this.nextReasons = []; /** * An alias for `.store.dispatch()` */ this.dispatch = (action) => this.store.dispatch(action); /** * An alias for `.store.setState()` */ this.setState = (settable, meta) => this.store.setState(settable, meta); /** * An alias for `.store.setStateDeep()` */ this.setStateDeep = (settable, meta) => this.store.setStateDeep(settable, meta); this._scheduleEvaluation = (reason, shouldSetTimeout) => { // TODO: Any calls in this case probably indicate a memory leak on the // user's part. Notify them. TODO: Can we pause evaluations while // status is Stale (and should we just always evaluate once when // waking up a stale atom)? if (this.status === 'Destroyed') return; this.nextReasons.push(reason); if (this.nextReasons.length > 1) return; // job already scheduled this.ecosystem._scheduler.schedule({ id: this.id, task: this.evaluationTask, type: 2, // EvaluateGraphNode (2) }, shouldSetTimeout); }; this.evaluationTask = () => this._evaluationTask(); this._createdAt = ecosystem._idGenerator.now(); // lol this.exports = this.exports; this.promise = this.promise; this.store = this.store; this._promiseStatus = this._promiseStatus; } /** * Detach this atom instance from the ecosystem and clean up all graph edges * and other subscriptions/effects created by this atom instance. * * Destruction will bail out if this atom instance still has dependents. Pass * `true` to force-destroy the atom instance anyway. */ destroy(force) { var _a, _b, _c, _d; if (this.status === 'Destroyed') return; // If we're not force-destroying, don't destroy if there are dependents if (!force && ((_a = this.ecosystem._graph.nodes[this.id]) === null || _a === void 0 ? void 0 : _a.refCount)) { return; } (_b = this._cancelDestruction) === null || _b === void 0 ? void 0 : _b.call(this); this._cancelDestruction = undefined; this._setStatus('Destroyed'); if (this.nextReasons.length) { this.ecosystem._scheduler.unschedule(this.evaluationTask); } // Clean up effect injectors first, then everything else const nonEffectInjectors = []; (_c = this._injectors) === null || _c === void 0 ? void 0 : _c.forEach(injector => { var _a; if (injector.type !== '@@zedux/effect') { nonEffectInjectors.push(injector); return; } (_a = injector.cleanup) === null || _a === void 0 ? void 0 : _a.call(injector); }); nonEffectInjectors.forEach(injector => { var _a; (_a = injector.cleanup) === null || _a === void 0 ? void 0 : _a.call(injector); }); this.ecosystem._graph.removeDependencies(this.id); (_d = this._subscription) === null || _d === void 0 ? void 0 : _d.unsubscribe(); this.ecosystem._destroyAtomInstance(this.id); } /** * An alias for `instance.store.getState()`. Returns the current state of this * atom instance's store. */ getState() { return this.store.getState(); } /** * Force this atom instance to reevaluate. */ invalidate(operation = 'invalidate', sourceType = 'External') { this._scheduleEvaluation({ operation, sourceType, type: 'cache invalidated', }, false); // run the scheduler synchronously after invalidation this.ecosystem._scheduler.flush(); } get _infusedSetter() { if (this._set) return this._set; const setState = (settable, meta) => this.setState(settable, meta); return (this._set = Object.assign(setState, this.exports)); } _init() { const factoryResult = this._doEvaluate(); [this._stateType, this.store] = getStateStore(factoryResult); this._subscription = this.store.subscribe((newState, oldState, action) => { // buffer updates (with cache size of 1) if this instance is currently // evaluating if (this._isEvaluating) { this._bufferedUpdate = { newState, oldState, action }; return; } this._handleStateChange(newState, oldState, action); }); this._setStatus('Active'); this.ecosystem._graph.flushUpdates(); // hydrate if possible const hydration = this.ecosystem._consumeHydration(this); if (this.template.manualHydration || typeof hydration === 'undefined') { return; } this.store.setState(hydration); } /** * When a standard atom instance's refCount hits 0 and a ttl is set, we set a * timeout to destroy this atom instance. */ _scheduleDestruction() { // the atom is already scheduled for destruction or destroyed if (this.status !== 'Active') return; this._setStatus('Stale'); const ttl = this._getTtl(); if (ttl == null || ttl === -1) return; if (ttl === 0) return this.destroy(); if (typeof ttl === 'number') { // ttl is > 0; schedule destruction const timeoutId = setTimeout(() => { this._cancelDestruction = undefined; this.destroy(); }, ttl); // TODO: dispatch an action over stateStore for these mutations this._cancelDestruction = () => { this._setStatus('Active'); this._cancelDestruction = undefined; clearTimeout(timeoutId); }; return; } if (typeof ttl.then === 'function') { let isCanceled = false; Promise.allSettled([ttl]).then(() => { this._cancelDestruction = undefined; if (!isCanceled) this.destroy(); }); this._cancelDestruction = () => { this._setStatus('Active'); this._cancelDestruction = undefined; isCanceled = true; }; return; } // ttl is an observable; destroy as soon as it emits const subscription = ttl.subscribe(() => { this._cancelDestruction = undefined; this.destroy(); }); this._cancelDestruction = () => { this._setStatus('Active'); this._cancelDestruction = undefined; subscription.unsubscribe(); }; } _doEvaluate() { const { _evaluationStack, _graph } = this.ecosystem; this._nextInjectors = []; let newFactoryResult; _evaluationStack.start(this); this._isEvaluating = true; _graph.bufferUpdates(this.id); try { newFactoryResult = this._evaluate(); } catch (err) { this._nextInjectors.forEach(injector => { var _a; (_a = injector.cleanup) === null || _a === void 0 ? void 0 : _a.call(injector); }); this._nextInjectors = undefined; _graph.destroyBuffer(); throw err; } finally { _evaluationStack.finish(); this._isEvaluating = false; // even if evaluation errored, we need to update dependents if the store's // state changed if (this._bufferedUpdate) { this._handleStateChange(this._bufferedUpdate.newState, this._bufferedUpdate.oldState, this._bufferedUpdate.action); this._bufferedUpdate = undefined; } this.prevReasons = this.nextReasons; this.nextReasons = []; } this._injectors = this._nextInjectors; this._nextInjectors = undefined; if (this.status !== 'Initializing') { // let this._init flush updates after status is set to Active _graph.flushUpdates(); } return newFactoryResult; } /** * A standard atom's value can be one of: * * - A raw value * - A Zedux store * - A function that returns a raw value * - A function that returns a Zedux store * - A function that returns an AtomApi */ _evaluate() { var _a; const { _value } = this.template; if (typeof _value !== 'function') { return _value; } try { const val = _value(...this.params); if (!is(val, AtomApi)) return val; const api = (this.api = val); // Exports can only be set on initial evaluation if (this.status === 'Initializing' && api.exports) { this.exports = api.exports; } // if api.value is a promise, we ignore api.promise if (typeof ((_a = api.value) === null || _a === void 0 ? void 0 : _a.then) === 'function') { return this._setPromise(api.value, true); } else if (api.promise) { this._setPromise(api.promise); } return api.value; } catch (err) { console.error(`Zedux: Error while evaluating atom "${this.template.key}" with params:`, this.params, err); throw err; } } _evaluationTask() { const newFactoryResult = this._doEvaluate(); const newStateType = getStateType(newFactoryResult); if (true /* DEV */ && newStateType !== this._stateType) { throw new Error(`Zedux: atom factory for atom "${this.template.key}" returned a different type than the previous evaluation. This can happen if the atom returned a store initially but then returned a non-store value on a later evaluation or vice versa`); } if (true /* DEV */ && newStateType === StoreState && newFactoryResult !== this.store) { throw new Error(`Zedux: atom factory for atom "${this.template.key}" returned a different store. Did you mean to use \`injectStore()\`, or \`injectMemo()\`?`); } // there is no way to cause an evaluation loop when the StateType is Value if (newStateType === RawState) { this.store.setState(typeof newFactoryResult === 'function' ? () => newFactoryResult : newFactoryResult); } } _getTtl() { var _a, _b, _c; if (((_a = this.api) === null || _a === void 0 ? void 0 : _a.ttl) == null) { return (_b = this.template.ttl) !== null && _b !== void 0 ? _b : (_c = this.ecosystem.atomDefaults) === null || _c === void 0 ? void 0 : _c.ttl; } // this atom instance set its own ttl const { ttl } = this.api; return typeof ttl === 'function' ? ttl() : ttl; } _handleStateChange(newState, oldState, action) { this.ecosystem._graph.scheduleDependents(this.id, this.nextReasons, newState, oldState, false); if (this.ecosystem._mods.stateChanged) { this.ecosystem.modBus.dispatch(pluginActions.stateChanged({ action, instance: this, newState, oldState, reasons: this.nextReasons, })); } // run the scheduler synchronously after any atom instance state update if (action.meta !== zeduxTypes.batch) { this.ecosystem._scheduler.flush(); } } _setStatus(newStatus) { const oldStatus = this.status; this.status = newStatus; if (this.ecosystem._mods.statusChanged) { this.ecosystem.modBus.dispatch(pluginActions.statusChanged({ newStatus, node: this, oldStatus, })); } } _setPromise(promise, isStateUpdater) { var _a; const currentState = (_a = this.store) === null || _a === void 0 ? void 0 : _a.getState(); if (promise === this.promise) return currentState; this.promise = promise; // since we're the first to chain off the returned promise, we don't need to // track the chained promise - it will run first, before React suspense's // `.then` on the thrown promise, for example promise .then(data => { if (this.promise !== promise) return; this._promiseStatus = 'success'; if (!isStateUpdater) return; this.store.setState(getSuccessPromiseState(data)); }) .catch(error => { if (this.promise !== promise) return; this._promiseStatus = 'error'; this._promiseError = error; if (!isStateUpdater) return; this.store.setState(getErrorPromiseState(error)); }); const state = getInitialPromiseState(currentState === null || currentState === void 0 ? void 0 : currentState.data); this._promiseStatus = state.status; this.ecosystem._graph.scheduleDependents(this.id, this.nextReasons, undefined, undefined, true, 'promise changed', 'Updated', true); return state; } }