@zedux/atoms
Version:
A Molecular State Engine for React
927 lines (787 loc) • 27.4 kB
text/typescript
import { createStore, detailedTypeof, is, isPlainObject } from '@zedux/core'
import { internalStore } from '../store/index'
import {
AnyAtomInstance,
AnyAtomTemplate,
AtomGettersBase,
AtomInstanceType,
AtomParamsType,
AtomSelectorOrConfig,
AtomStateType,
Cleanup,
DependentEdge,
EcosystemConfig,
EcosystemGraphNode,
GraphEdgeInfo,
GraphViewRecursive,
MaybeCleanup,
ParamlessTemplate,
PartialAtomInstance,
Selectable,
} from '../types/index'
import { External } from '../utils/index'
import { pluginActions } from '../utils/plugin-actions'
import { EvaluationStack } from './EvaluationStack'
import { Graph } from './Graph'
import { IdGenerator } from './IdGenerator'
import { AtomInstanceBase } from './instances/AtomInstanceBase'
import { Scheduler } from './Scheduler'
import { SelectorCache, Selectors } from './Selectors'
import { Mod, ZeduxPlugin } from './ZeduxPlugin'
import { AtomTemplate } from './templates/AtomTemplate'
const defaultMods = Object.keys(pluginActions).reduce((map, mod) => {
map[mod as Mod] = 0
return map
}, {} as Record<Mod, number>)
const mapOverrides = (overrides: AnyAtomTemplate[]) =>
overrides.reduce((map, atom) => {
map[atom.key] = atom
return map
}, {} as Record<string, AnyAtomTemplate>)
export class Ecosystem<Context extends Record<string, any> | undefined = any>
implements AtomGettersBase
{
public atomDefaults?: EcosystemConfig['atomDefaults']
public complexParams?: boolean
public context: Context
public destroyOnUnmount?: boolean
public flags?: string[]
public hydration?: Record<string, any>
public id: string
public modBus = createStore() // use an empty store as a message bus
public onReady: EcosystemConfig<Context>['onReady']
public overrides: Record<string, AnyAtomTemplate> = {}
public selectors: Selectors = new Selectors(this)
public ssr?: boolean
public _graph: Graph = new Graph(this)
// define this after _graph so it can access _graph immediately
public _evaluationStack: EvaluationStack = new EvaluationStack(this)
public _idGenerator = new IdGenerator()
public _instances: Record<string, AnyAtomInstance> = {}
public _mods: Record<Mod, number> = { ...defaultMods }
public _refCount = 0
public _scheduler: Scheduler = new Scheduler(this)
/**
* Only for use by internal addon packages - lets us attach anything we want
* to the ecosystem. For example, the React package uses this to store React
* Context objects
*/
public _storage: Record<string, any> = {}
private cleanup?: MaybeCleanup
private isInitialized = false
private plugins: { plugin: ZeduxPlugin; cleanup: Cleanup }[] = []
constructor(config: EcosystemConfig<Context>) {
if (DEV) {
if (config.flags && !Array.isArray(config.flags)) {
throw new TypeError(
"Zedux: The Ecosystem's `flags` property must be an array of strings"
)
}
if (config.overrides && !Array.isArray(config.overrides)) {
throw new TypeError(
"Zedux: The Ecosystem's `overrides` property must be an array of atom template objects"
)
}
}
Object.assign(this, config)
this.id ||= this._idGenerator.generateId('es')
if (config.overrides) {
this.setOverrides(config.overrides)
}
this.context = (this as any).context
this.isInitialized = true
this.cleanup = config.onReady?.(this)
}
/**
* Merge the passed atom overrides into the ecosystem's current list of
* overrides. Force-destroys all atom instances currently in the ecosystem
* that should now be overridden.
*
* This can't be used to remove overrides. Use `.setOverrides()` or
* `.removeOverrides()` for that.
*/
public addOverrides(overrides: AnyAtomTemplate[]) {
this.overrides = {
...this.overrides,
...mapOverrides(overrides),
}
overrides.forEach(override => {
const instances = this.findAll(override)
Object.values(instances).forEach(instance => instance.destroy(true))
})
}
/**
* Batch all state updates that happen synchronously during the passed
* callback's execution. Flush all updates when the passed callback completes.
*
* Has no effect if the scheduler is already running - updates are always
* batched when the scheduler is running.
*/
public batch<T = any>(callback: () => T) {
const scheduler = this._scheduler
const prevIsRunning = scheduler._isRunning
scheduler._isRunning = true
const result = callback()
scheduler._isRunning = prevIsRunning
scheduler.flush()
return result
}
/**
* Retrieve an object mapping atom instance ids to their current values.
*
* Calls the `dehydrate` atom config option (on atoms that have one) to
* transform state to a serializable form. Pass `transform: false` to prevent
* this.
*
* Atoms can be excluded from dehydration by passing `exclude` and/or
* `excludeFlags` options:
*
* ```ts
* myEcosystem.dehydrate({
* exclude: [myAtom, 'my-fuzzy-search-string'],
* excludeFlags: ['no-ssr']
* })
* ```
*
* An atom passed to `exclude` will exclude all instances of that atom. A
* string passed to `exclude` will exclude all instances whose id contains the
* string (case-insensitive)
*
* You can dehydrate only a subset of all atoms by passing `include` and/or
* `includeFlags` options:
*
* ```ts
* myEcosystem.dehydrate({
* include: [myAtom, 'my-fuzzy-search-string'],
* includeFlags: ['ssr']
* })
* ```
*
* An atom passed to `include` will include all instances of that atom. A
* string passed to `include` will include all instances whose id contains the
* string (case-insensitive)
*
* Excludes takes precedence over includes.
*
* By default, dehydration will call any configured `dehydrate` atom config
* options to transform atom instance state. Pass `{ transform: false }` to
* prevent this.
*/
public dehydrate({
exclude,
excludeFlags,
include,
includeFlags,
transform = true,
}: {
exclude?: (AnyAtomTemplate | string)[]
excludeFlags?: string[]
include?: (AnyAtomTemplate | string)[]
includeFlags?: string[]
transform?: boolean
} = {}) {
const instances = Object.values(this._instances).filter(
({ id, template }) => {
if (
exclude &&
exclude.some(atomOrKey =>
typeof atomOrKey === 'string'
? id.toLowerCase().includes(atomOrKey.toLowerCase())
: template.key === atomOrKey.key
)
) {
return false
}
if (
excludeFlags &&
excludeFlags.some(flag => template.flags?.includes(flag))
) {
return false
}
if (!include && !includeFlags) return true
if (
include &&
include.some(atomOrKey =>
typeof atomOrKey === 'string'
? id.toLowerCase().includes(atomOrKey.toLowerCase())
: template.key === atomOrKey.key
)
) {
return true
}
if (
includeFlags &&
includeFlags.some(flag => template.flags?.includes(flag))
) {
return true
}
return false
}
)
return instances.reduce((obj, { id, store, template }) => {
const state = store.getState()
obj[id] =
transform && template.dehydrate ? template.dehydrate(state) : state
return obj
}, {} as Record<string, any>)
}
/**
* Destroy this ecosystem - destroy all this ecosystem's atom instances,
* remove and clean up all plugins, and remove this ecosystem from the
* internal store.
*
* Destruction will bail out by default if this ecosystem is still being
* provided via an <EcosystemProvider>. Pass `true` as the first parameter to
* force destruction anyway.
*/
public destroy(force?: boolean) {
if (!force && this._refCount > 0) return
this.wipe()
// Check if this ecosystem has been destroyed already
const ecosystem = internalStore.getState()[this.id]
if (!ecosystem) return
this.plugins.forEach(({ cleanup }) => cleanup())
this.plugins = []
internalStore.setState(state => {
const newState = { ...state }
delete newState[this.id]
return newState
})
}
/**
* Get an atom instance. Don't create the atom instance if it doesn't exist.
* Don't register any graph dependencies.
*
* Tries to find an exact match, but falls back to doing a fuzzy search if no
* exact match is found. Pass atom params (or an empty array if no params or
* when passing a search string) for the second argument to disable fuzzy
* search.
*/
public find<A extends AnyAtomTemplate>(
template: A,
params: AtomParamsType<A>
): AtomInstanceType<A> | undefined
public find<A extends AnyAtomTemplate<{ Params: [] }>>(
template: A
): AtomInstanceType<A> | undefined
public find<A extends AnyAtomTemplate>(
template: ParamlessTemplate<A>
): AtomInstanceType<A> | undefined
public find<A extends AnyAtomTemplate = any>(
searchStr: string,
params?: []
): AtomInstanceType<A> | undefined
public find<A extends AnyAtomTemplate>(
template: A | string,
params?: AtomParamsType<A>
) {
const isString = typeof template === 'string'
if (!isString) {
const id = (template as A).getInstanceId(this, params)
// try to find an existing instance
const instance = this._instances[id]
if (instance) return instance
}
// if params are passed, don't fuzzy search
if (params) {
return this._instances[
isString
? template
: `${template.key}-${this._idGenerator.hashParams(
params,
this.complexParams
)}`
]
}
const matches = this.findAll(template)
return (
(isString && matches[template]) ||
(Object.values(matches)[0] as AtomInstanceType<A> | undefined)
)
}
/**
* Get an object of all atom instances in this ecosystem keyed by their id.
*
* Pass an atom template to only find instances of that atom. Pass an atom key
* string to only return instances whose id weakly matches the passed key.
*/
public findAll(template?: AnyAtomTemplate | string) {
const isAtom = (template as AnyAtomTemplate)?.key
const filterKey = isAtom || (template as string)?.toLowerCase()
const hash: Record<string, AnyAtomInstance> = {}
Object.values(this._instances)
.filter(
instance =>
!filterKey ||
(isAtom
? instance.template.key === (template as AnyAtomTemplate).key
: instance.id.toLowerCase().includes(filterKey))
)
.sort((a, b) => a.id.localeCompare(b.id))
.forEach(instance => {
hash[instance.id] = instance
})
return hash
}
public get<A extends AnyAtomTemplate>(
template: A,
params: AtomParamsType<A>
): AtomStateType<A>
public get<A extends AnyAtomTemplate<{ Params: [] }>>(
template: A
): AtomStateType<A>
public get<A extends AnyAtomTemplate>(
template: ParamlessTemplate<A>
): AtomStateType<A>
public get<I extends AnyAtomInstance>(instance: I): AtomStateType<I>
/**
* Returns an atom instance's value. Creates the atom instance if it doesn't
* exist yet. Doesn't register any graph dependencies.
*/
public get<A extends AnyAtomTemplate>(
atom: A | AnyAtomInstance,
params?: AtomParamsType<A>
) {
if (is(atom, AtomInstanceBase)) {
return (atom as AnyAtomInstance).store.getState()
}
const instance = this.getInstance(
atom as A,
params as AtomParamsType<A>
) as AnyAtomInstance
return instance.store.getState()
}
public getInstance<A extends AnyAtomTemplate>(
template: A,
params: AtomParamsType<A>,
edgeInfo?: GraphEdgeInfo
): AtomInstanceType<A>
public getInstance<A extends AnyAtomTemplate<{ Params: [] }>>(
template: A
): AtomInstanceType<A>
public getInstance<A extends AnyAtomTemplate>(
template: ParamlessTemplate<A>
): AtomInstanceType<A>
public getInstance<I extends AnyAtomInstance>(
instance: I,
params?: [],
edgeInfo?: GraphEdgeInfo
): I
/**
* Returns an atom instance. Creates the atom instance if it doesn't exist
* yet. Doesn't register any graph dependencies.
*/
public getInstance<A extends AnyAtomTemplate>(
atom: A | AnyAtomInstance,
params?: AtomParamsType<A>
) {
if (DEV) {
if (!atom || (!is(atom, AtomInstanceBase) && !is(atom, AtomTemplate))) {
throw new TypeError(
`Zedux: Expected an atom template or atom instance. Received ${detailedTypeof(
atom
)}`
)
}
if (typeof params !== 'undefined' && !Array.isArray(params)) {
throw new TypeError(
`Zedux: Expected atom params to be an array. Received ${detailedTypeof(
params
)}`
)
}
}
if (is(atom, AtomInstanceBase)) {
// if the passed atom instance is Destroyed, get(/create) the
// non-Destroyed instance
return (atom as AnyAtomInstance).status === 'Destroyed'
? this.getInstance(
(atom as AnyAtomInstance).template,
(atom as AnyAtomInstance).params
)
: atom
}
const id = (atom as A).getInstanceId(this, params)
// try to find an existing instance
const instance = this._instances[id]
if (instance) {
if (this._mods.instanceReused) {
this.modBus.dispatch(
pluginActions.instanceReused({ instance, template: atom as A })
)
}
return instance
}
// create a new instance
const resolvedAtom = this.resolveAtom(atom as A)
this._graph.addNode(id)
const newInstance = resolvedAtom._createInstance(
this,
id,
(params || []) as AtomParamsType<A>
)
this._instances[id] = newInstance
newInstance._init()
return newInstance
}
/**
* Hydrate the state of atoms in this ecosystem with an object mapping atom
* instance ids to their hydrated state. This object will usually be the
* result of a call to `ecosystem.dehydrate()`.
*
* This is the key to SSR. The ecosystem's initial state can be dehydrated on
* the server, sent to the client in serialized form, deserialized, and passed
* to `ecosystem.hydrate()`. Every atom instance that evaluates after this
* hydration can use the `hydrate` injectStore config option to retrieve its
* hydrated state.
*
* Pass `retroactive: false` to prevent this call from updating the state of
* all atom instances that have already been initialized with this new
* hydration. Hydration is retroactive by default.
*
* ```ts
* ecosystem.hydrate(dehydratedState, { retroactive: false })
* ```
*/
public hydrate(
dehydratedState: Record<string, any>,
config?: { retroactive?: boolean }
) {
if (DEV) {
if (!isPlainObject(dehydratedState)) {
throw new TypeError(
'Zedux: ecosystem.hydrate() - first parameter must be a plain object'
)
}
}
this.hydration = { ...this.hydration, ...dehydratedState }
if (config?.retroactive === false) return
Object.entries(dehydratedState).forEach(([key, val]) => {
const instance = this._instances[key]
if (!instance) return
instance.setState(
instance.template.hydrate ? instance.template.hydrate(val) : val
)
// we know hydration is defined at this point
delete (this.hydration as Record<string, any>)[key]
})
}
/**
* Add a ZeduxPlugin to this ecosystem. This ecosystem will subscribe to the
* plugin's modStore, whose state can be changed to reactively update the mods
* of this ecosystem.
*
* This method will also call the passed plugin's `.registerEcosystem` method,
* allowing the plugin to subscribe to this ecosystem's modBus
*
* The plugin will remain part of this ecosystem until it is unregistered or
* this ecosystem is destroyed. `.wipe()` and `.reset()` don't remove plugins.
* However, a plugin _can_ set the `ecosystemWiped` mod and react to those
* events.
*/
public registerPlugin(plugin: ZeduxPlugin) {
if (this.plugins.some(descriptor => descriptor.plugin === plugin)) return
const subscription = plugin.modStore.subscribe((newState, oldState) => {
this.recalculateMods(newState, oldState)
})
const cleanupRegistration = plugin.registerEcosystem(this)
const cleanup = () => {
subscription.unsubscribe()
if (cleanupRegistration) cleanupRegistration()
}
this.plugins.push({ cleanup, plugin })
this.recalculateMods(plugin.modStore.getState())
}
/**
* Remove all passed atoms from this ecosystem's list of atom overrides. Does
* nothing for passed atoms that aren't currently in the overrides list.
*
* Force destroys all instances of all removed atoms. This forced destruction
* will cause dependents of those instances to recreate their dependency atom
* instance without using an override.
*/
public removeOverrides(overrides: (AnyAtomTemplate | string)[]) {
this.overrides = mapOverrides(
Object.values(this.overrides).filter(template =>
overrides.every(override => {
const key = typeof override === 'string' ? override : override.key
return key !== template.key
})
)
)
overrides.forEach(override => {
const instances = this.findAll(override)
Object.values(instances).forEach(instance => instance.destroy(true))
})
}
/**
* Destroys all atom instances in this ecosystem, runs the cleanup function
* returned from `onReady` (if any), and calls `onReady` again to reinitialize
* the ecosystem.
*
* Note that this doesn't remove overrides or plugins but _does_ remove
* hydrations. This is because you can remove overrides/plugins yourself if
* needed, but there isn't currently a way to remove hydrations.
*/
public reset(newContext?: Context) {
this.wipe()
const prevContext = this.context
if (typeof newContext !== 'undefined') this.context = newContext
this.cleanup = this.onReady?.(this, prevContext)
}
/**
* Runs an AtomSelector statically - without registering any dependencies or
* updating any caches. If we've already cached this exact selector + args
* combo, returns the cached value without running the selector again
*/
public select<T, Args extends any[]>(
selectable: Selectable<T, Args>,
...args: Args
): T {
if (is(selectable, SelectorCache)) {
return (selectable as SelectorCache<T, Args>).result as T
}
const atomSelector = selectable as AtomSelectorOrConfig<T, Args>
const cache = this.selectors.find(atomSelector, args)
if (cache) return cache.result as T
const resolvedSelector =
typeof atomSelector === 'function' ? atomSelector : atomSelector.selector
return resolvedSelector(
{
ecosystem: this,
get: this.get.bind(this),
getInstance: this.getInstance.bind(this),
select: this.select.bind(this),
},
...args
)
}
/**
* Completely replace this ecosystem's current list of atom overrides with a
* new list.
*
* Force destroys all instances of all previously- and newly-overridden atoms.
* This forced destruction will cause dependents of those instances to
* recreate their dependency atom instance.
*/
public setOverrides(newOverrides: AnyAtomTemplate[]) {
const oldOverrides = this.overrides
this.overrides = mapOverrides(newOverrides)
if (!this.isInitialized) return
newOverrides.forEach(atom => {
const instances = this.findAll(atom)
Object.values(instances).forEach(instance => {
instance.destroy(true)
})
})
Object.values(oldOverrides).forEach(atom => {
const instances = this.findAll(atom)
Object.values(instances).forEach(instance => {
instance.destroy(true)
})
})
}
/**
* Unregister a plugin registered in this ecosystem via `.registerPlugin()`
*/
public unregisterPlugin(plugin: ZeduxPlugin) {
const index = this.plugins.findIndex(
descriptor => descriptor.plugin === plugin
)
if (index === -1) return
this.plugins[index].cleanup()
this.plugins.splice(index, 1)
this.recalculateMods(undefined, plugin.modStore.getState())
}
public viewGraph(view: 'bottom-up'): GraphViewRecursive
public viewGraph(view?: 'flat'): Record<
string,
{
dependencies: { key: string; operation: string }[]
dependents: { key: string; operation: string }[]
weight: number
}
>
public viewGraph(view: 'top-down'): GraphViewRecursive
/**
* Get the current graph of this ecosystem. There are 3 views:
*
* Flat (default). Returns an object with all graph nodes on the top layer,
* each node pointing to its dependencies and dependents. No nesting.
*
* Bottom-Up. Returns an object containing all the leaf nodes of the graph
* (nodes that have no internal dependents), each node containing an object of
* its parent nodes, recursively.
*
* Top-Down. Returns an object containing all the root nodes of the graph
* (nodes that have no dependencies), each node containing an object of its
* child nodes, recursively.
*/
public viewGraph(view?: string) {
const { nodes } = this._graph
if (view !== 'top-down' && view !== 'bottom-up') {
const hash: Record<
string,
{
dependencies: { key: string; operation: string }[]
dependents: { key: string; operation: string }[]
weight: number
}
> = {}
Object.keys(nodes).forEach(id => {
const node = nodes[id]
hash[id] = {
dependencies: [...node.dependencies.keys()].map(key => ({
key,
operation: (nodes[key].dependents.get(id) as DependentEdge)
.operation,
})),
dependents: [...node.dependents.keys()].map(key => ({
key,
operation: (node.dependents.get(key) as DependentEdge).operation,
})),
weight: node.weight,
}
})
return hash
}
const hash: GraphViewRecursive = {}
Object.keys(nodes).forEach(key => {
const node = nodes[key]
const isTopLevel =
view === 'bottom-up'
? [...node.dependents.values()].every(
dependent => dependent.flags & External
)
: !node.dependencies.size
if (isTopLevel) {
hash[key] = {}
}
})
const recurse = (node?: EcosystemGraphNode) => {
if (!node) return
const map = view === 'bottom-up' ? node.dependencies : node.dependents
const children: GraphViewRecursive = {}
for (const key of map.keys()) {
const child = recurse(nodes[key])
if (child) children[key] = child
}
return children
}
Object.keys(hash).forEach(key => {
const node = nodes[key]
const children = recurse(node)
if (children) hash[key] = children
})
return hash
}
/**
* Returns the list of reasons detailing why the current atom instance or
* selector is evaluating.
*
* Returns undefined if nothing is currently evaluating. Returns an empty
* array if this is the first evaluation of the instance or selector.
*/
public why() {
return this._evaluationStack.read()?.node.nextReasons
}
/**
* Destroy all atom instances in this ecosystem. Also run the cleanup function
* returned from the onReady callback (if any). Don't remove plugins or re-run
* the onReady callback.
*
* Also don't remove overrides. This may usually be wanted, but it's easy
* enough to add a `.setOverrides([])` call when you need it.
*
* Important! This method is mostly for internal use. You won't typically want
* to call this method. Prefer `.reset()` which re-runs the onReady callback
* after wiping the ecosystem, allowing onReady to re-initialize the ecosystem
* - preloading atoms, registering plugins, configuring context, etc
*/
public wipe() {
const { _instances, _mods, _scheduler, modBus, selectors } = this
// call cleanup function first so it can configure the ecosystem for cleanup
this.cleanup?.()
// TODO: Delete nodes in an optimal order, starting with nodes with no
// internal dependents. This is different from highest-weighted nodes since
// static dependents don't affect weight. This should make sure no internal
// nodes schedule unnecessary reevaaluations to recreate force-destroyed
// instances
Object.values(_instances).forEach(instance => {
instance.destroy(true)
})
this.hydration = undefined
selectors._wipe()
_scheduler.wipe()
_scheduler.flush()
if (_mods.ecosystemWiped) {
modBus.dispatch(pluginActions.ecosystemWiped({ ecosystem: this }))
}
}
/**
* Should only be used internally
*/
public _consumeHydration(instance: PartialAtomInstance) {
const hydratedValue = this.hydration?.[instance.id]
if (typeof hydratedValue === 'undefined') return
// hydration must exist here. This cast is fine:
delete (this.hydration as Record<string, any>)[instance.id]
return instance.template.hydrate
? instance.template.hydrate(hydratedValue)
: hydratedValue
}
/**
* Should only be used internally
*/
public _decrementRefCount() {
this._refCount--
if (!this.destroyOnUnmount) return
this.destroy() // only destroys if _refCount === 0
}
/**
* Should only be used internally
*/
public _destroyAtomInstance(id: string) {
// try to destroy instance (if not destroyed - this fn is called as part of
// that destruction process too)
this._graph.removeNode(id)
// mods have already been notified of the instance's status changing to
// Destroyed by this point. No need to notify anything of this mutation.
delete this._instances[id]
}
/**
* Should only be used internally
*/
public _incrementRefCount() {
this._refCount++
}
private recalculateMods(newState?: Mod[], oldState?: Mod[]) {
if (oldState) {
oldState.forEach(key => {
this._mods[key as Mod]-- // fun fact, undefined-- is fine
})
}
if (newState) {
newState.forEach(key => {
this._mods[key as Mod]++
})
}
}
private resolveAtom<A extends AnyAtomTemplate>(template: A) {
const { flags, overrides } = this
const override = overrides[template.key]
const maybeOverriddenAtom = (override || template) as A
// to turn off flag checking, just don't pass a `flags` prop
if (flags) {
const badFlag = maybeOverriddenAtom.flags?.find(
flag => !flags.includes(flag)
)
if (DEV && badFlag) {
console.error(
`Zedux: encountered unsafe atom template "${template.key}" with flag "${badFlag}". This atom template should be overridden in the current environment.`
)
}
}
return maybeOverriddenAtom
}
}