diff --git a/packages/atoms/src/classes/Ecosystem.ts b/packages/atoms/src/classes/Ecosystem.ts index aeeb971d..b470d919 100644 --- a/packages/atoms/src/classes/Ecosystem.ts +++ b/packages/atoms/src/classes/Ecosystem.ts @@ -31,8 +31,9 @@ import { import { External, compare, - Static, makeReasonsReadable, + Eventless, + EventlessStatic, } from '../utils/general' import { pluginActions } from '../utils/plugin-actions' import { IdGenerator } from './IdGenerator' @@ -44,7 +45,6 @@ import { bufferEdge, getEvaluationContext } from '../utils/evaluationContext' import { getSelectorKey, getSelectorName, - runSelector, SelectorInstance, swapSelectorRefs, } from './SelectorInstance' @@ -151,19 +151,16 @@ export class Ecosystem | undefined = any> edgeConfig?: GraphEdgeConfig ) => { const instance = this.getNode(template, params as G['Params']) - const node = getEvaluationContext().n - // If getInstance is called in a reactive context, track the required atom + // If getNode is called in a reactive context, track the required atom // instances so we can add graph edges for them. When called outside a - // reactive context, getInstance() is just an alias for - // ecosystem.getInstance() - if (node) { + // reactive context, getNode() is just an alias for ecosystem.getNode() + getEvaluationContext().n && bufferEdge( instance, edgeConfig?.op || 'getNode', - edgeConfig?.f ?? Static + edgeConfig?.f ?? EventlessStatic ) - } return instance } @@ -179,7 +176,7 @@ export class Ecosystem | undefined = any> if (!node) return this.select(selectable, ...args) const instance = this.getNode(selectable, args) - bufferEdge(instance, 'select', 0) + bufferEdge(instance, 'select', Eventless) return instance.v } @@ -224,12 +221,9 @@ export class Ecosystem | undefined = any> public batch(callback: () => T) { const scheduler = this._scheduler - const prevIsRunning = scheduler._isRunning - scheduler._isRunning = true + const pre = scheduler.pre() const result = callback() - scheduler._isRunning = prevIsRunning - - scheduler.flush() + scheduler.post(pre) return result } @@ -880,7 +874,7 @@ export class Ecosystem | undefined = any> const isSwappingRefs = instance.t !== template && paramsUnchanged && - this.n.get(instance.id)?.o.size === 1 && + instance.o.size === 1 && !this.b.has(template) && getSelectorName(instance.t) === getSelectorName(template) @@ -1031,6 +1025,9 @@ export class Ecosystem | undefined = any> // call cleanup function first so it can configure the ecosystem for cleanup this.cleanup?.() + // prevent node destruction from flushing the scheduler + _scheduler._isRunning = true + // 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 @@ -1040,6 +1037,7 @@ export class Ecosystem | undefined = any> node.destroy(true) }) + _scheduler._isRunning = false this.b = new WeakMap() // TODO: is this necessary? this.hydration = undefined diff --git a/packages/atoms/src/classes/GraphNode.ts b/packages/atoms/src/classes/GraphNode.ts index c2989289..6eda0268 100644 --- a/packages/atoms/src/classes/GraphNode.ts +++ b/packages/atoms/src/classes/GraphNode.ts @@ -7,6 +7,7 @@ import { GraphEdgeConfig, InternalEvaluationReason, LifecycleStatus, + ListenerConfig, NodeFilter, NodeFilterOptions, NodeGenerics, @@ -14,6 +15,7 @@ import { import { is, Job } from '@zedux/core' import { Ecosystem } from './Ecosystem' import { + Eventless, EventSent, ExplicitExternal, makeReasonReadable, @@ -24,6 +26,7 @@ import { EventEmitter, SingleEventListener, ListenableEvents, + EvaluationReason, } from '../types/events' import { bufferEdge, getEvaluationContext } from '../utils/evaluationContext' import { addEdge, removeEdge, setNodeStatus } from '../utils/graph' @@ -45,6 +48,8 @@ export abstract class GraphNode */ public izn = true + public L: undefined | Listener = undefined + /** * @see Job.T */ @@ -55,19 +60,13 @@ export abstract class GraphNode */ public W = 1 - /** - * `v`alue - the current state of this signal. - */ - // @ts-expect-error only some node types have state. They will need to make - // sure they set this. This should be undefined for nodes that don't. - public v: G['State'] - /** * Detach this node from the ecosystem and clean up all graph edges and other * subscriptions/effects created by this node. * - * Destruction will bail out if this node still has dependents (`node.o.size - * !== 0`). Pass `true` to force-destroy the node anyway. + * Destruction will bail out if this node still has non-passive observers + * (`node.o.size - node.P !== 0`). Pass `true` to force-destroy the node + * anyway. * * When force-destroying a node that still has dependents, the node will be * immediately recreated and all dependents notified of the destruction. @@ -85,11 +84,12 @@ export abstract class GraphNode * * To retrieve the node's value non-reactively, use `.getOnce()` instead. */ - public get() { + public get(config?: GraphEdgeConfig) { // If get is called in a reactive context, track the required atom // instances so we can add graph edges for them. When called outside a // reactive context, get() is just an alias for ecosystem.get() - getEvaluationContext().n && bufferEdge(this, 'get', 0) + getEvaluationContext().n && + bufferEdge(this, config?.op ?? 'get', config?.f ?? Eventless) return this.v } @@ -111,25 +111,30 @@ export abstract class GraphNode on>( eventName: E, callback: SingleEventListener, - edgeDetails?: GraphEdgeConfig + listenerConfig?: ListenerConfig ): Cleanup - on(callback: CatchAllListener, edgeDetails?: GraphEdgeConfig): Cleanup + on(callback: CatchAllListener, listenerConfig?: ListenerConfig): Cleanup /** * Register a listener that will be called on this emitter's events. * - * Internally, this manually adds a graph edge between this node and a new - * external pseudo node. + * Event listeners are "passive" by default - they don't prevent the node + * they're listening to from becoming Stale or Destroyed. + * + * Pass `{ active: true }` to change this, making the listener create its own + * graph node that observes the current node. Like all normal observers, this + * will prevent lifecycle changes. * - * TODO: probably move this to the Signal class and remove the Events generic - * from GraphNodes (events don't apply to selectors, effect nodes, or probably - * lots of other future node types). + * Internally, this adds a special "passive" observer to the node the first + * time `.on` is called. Subsequent `.on` calls add listeners to that passive + * observer's callback list and make it respond to more events. If a catch-all + * listener is registered, the passive observer will react to all events. */ public on>( eventNameOrCallback: E | ((eventMap: Partial>) => void), - callbackOrConfig?: SingleEventListener | GraphEdgeConfig, - maybeConfig?: GraphEdgeConfig + callbackOrConfig?: SingleEventListener | ListenerConfig, + maybeConfig?: ListenerConfig ): Cleanup { const isSingleListener = typeof eventNameOrCallback === 'string' const eventName = isSingleListener ? eventNameOrCallback : '' @@ -138,21 +143,13 @@ export abstract class GraphNode ? (callbackOrConfig as SingleEventListener) : (eventNameOrCallback as CatchAllListener) - const { f, op } = ((isSingleListener ? maybeConfig : callbackOrConfig) || - {}) as GraphEdgeConfig - - const operation = op || 'on' + const { active } = ((isSingleListener ? maybeConfig : callbackOrConfig) || + {}) as ListenerConfig const notify = (reason: InternalEvaluationReason) => { - // if `reason.t`ype doesn't exist, it's a change event - const eventMap = ( - reason.t - ? reason.e ?? {} - : { - ...reason.e, - change: makeReasonReadable(reason, observer), - } - ) as ListenableEvents + const eventMap = (reason.f || reason.e || {}) as Partial< + ListenableEvents + > // if it's a single event listener and the event isn't in the map, ignore eventName in eventMap @@ -160,19 +157,24 @@ export abstract class GraphNode : isSingleListener || (callback as CatchAllListener)(eventMap) } - // External nodes can be disabled by setting this `m`ounted property to false - notify.m = true + // active listeners create their own Listener node + if (this.L && !active) { + this.L.I(eventName, notify) - const observer = new ExternalNode( - this.e, - this.e._idGenerator.generateNodeId(), - notify, - true - ) + return () => this.L?.D(eventName, notify) + } else { + const observer = new Listener( + this.e, + this.e._idGenerator.generateNodeId() + ) + + observer.u(this, 'on', ExplicitExternal) + observer.I(eventName, notify) - observer.u(this, operation, f ?? ExplicitExternal) + if (!active) this.L = observer - return () => observer.k(this) + return () => observer.D(eventName, notify) + } } /** @@ -343,6 +345,13 @@ export abstract class GraphNode */ public abstract t: G['Template'] + /** + * `v`alue - the current state of this signal. + */ + // @ts-expect-error only some node types have state. They will need to make + // sure they set this. This should be undefined for nodes that don't. + public v: G['State'] + /** * `w`hy - the list of reasons explaining why this graph node updated or is * going to update. @@ -350,19 +359,14 @@ export abstract class GraphNode public w: InternalEvaluationReason[] = [] } -export class ExternalNode extends GraphNode { +export class ExternalNode< + G extends NodeGenerics = AnyNodeGenerics +> extends GraphNode { /** * @see GraphNode.T */ public T = 3 as 2 // temporary until we sort out new graph algo - /** - * `b`ufferedEvents - the list of buffered events that will be batched - * together when this node's `j`ob runs. Only applies if - * `this.I`sEventListener - */ - public b?: Record - /** * `i`nstance - the single source node this external node is observing */ @@ -394,28 +398,9 @@ export class ExternalNode extends GraphNode { */ public readonly n: ((reason: InternalEvaluationReason) => void) & { m?: boolean - }, - - /** - * `I`sEventListener - currently there are only two "types" of ExternalNodes - * - * - nodes that listen to state/promise/lifecycle updates - * - nodes that listen to events - * - * Each has slightly different functionality. We could use another subclass - * for this, but for now, just use a boolean to track which type this - * ExternalNode is. - */ - public I?: boolean + } ) { super() - - // This is the simplest way to ensure that observers run in the order they - // were added in. The idCounter was always just incremented to create this - // node's id. ExternalNodes always run after all internal jobs are fully - // flushed, so tracking graph node "weight" in `this.W`eight is useless. - // Track listener added order instead. - this.W = e._idGenerator.idCounter e.n.set(id, this) setNodeStatus(this, 'Active') } @@ -501,18 +486,9 @@ export class ExternalNode extends GraphNode { * @see GraphNode.r */ public r(reason: InternalEvaluationReason, defer?: boolean) { - // always update if `I`sEventListener. Ignore `EventSent` reasons otherwise. - if (this.I || (!this.I && reason.t !== EventSent)) { - if (this.I) { - this.b = this.b ? { ...this.b, ...reason.e } : reason.e - } - - // We can optimize this for event listeners by telling ExternalNode the - // event it's listening to and short-circuiting here, before scheduling a - // useless job, if the event isn't present (and isn't an ImplicitEvent - // that won't be present on `reason.e`). TODO: investigate. - this.w.push(reason) === 1 && this.e._scheduler.schedule(this, defer) - } + // ignore `EventSent` reasons + reason.t === EventSent || + (this.w.push(reason) === 1 && this.e._scheduler.schedule(this, defer)) } /** @@ -530,3 +506,128 @@ export class ExternalNode extends GraphNode { }) } } + +const noop = () => {} + +export class Listener< + G extends NodeGenerics = AnyNodeGenerics +> extends ExternalNode { + /** + * catch`A`llCount - counts how many catch-all notifiers are registered + */ + public A = 0 + + /** + * event`C`ounts - counts notifiers for each event type (except catch-all) + */ + public C: Record = {} + + /** + * `N`otifiers - passed notify functions + */ + public N: ((reason: InternalEvaluationReason) => void)[] = [] + + constructor( + /** + * @see ExternalNode.e + */ + e: Ecosystem, + + /** + * @see ExternalNode.id + */ + id: string + ) { + super(e, id, noop) + + // This is the simplest way to ensure that event observers run in the order + // they were added in. The idCounter was always just incremented to create + // this node's id. Listeners always run after all internal jobs are fully + // flushed, so tracking graph node "weight" in `this.W`eight is useless. + // Track listener added order instead. + // this.W = e._idGenerator.idCounter + } + + /** + * `D`ecrementNotifiers - remove a notifier function from this listener's list + */ + public D( + eventName: string, + notify: (reason: InternalEvaluationReason) => void + ) { + const { C, N } = this + const index = N?.indexOf(notify) + + if (~index) { + N.splice(index, 1) + + if (eventName) { + C[eventName]-- + } else { + this.A-- + } + + N.length || this.m() + } + } + + public I( + eventName: string, + notify: (reason: InternalEvaluationReason) => void + ) { + this.N.push(notify) + + if (eventName) { + this.C[eventName] = (this.C[eventName] ?? 0) + 1 + } else { + this.A++ + } + } + + /** + * @see ExternalNode.j + */ + public j() { + if (this.N.length) { + for (const notify of this.N) { + for (const reason of this.w) { + notify(reason) + } + } + } + this.w = [] + + // listeners auto-detach and destroy themselves when the node they listen to + // is destroyed (after telling `this.N`otifiers about it) + if (this.i?.l === 'Destroyed') { + this.k(this.i) + } + } + + /** + * @see ExternalNode.r + */ + public r(reason: InternalEvaluationReason, defer?: boolean) { + const { e, w } = this + let newImplicitEvent: EvaluationReason | undefined + + // if this wasn't an explicit EventSent reason, it has an implicit event we + // need to create. Attach it to the reason so we only do this once. + if (!reason.f && reason.t !== EventSent) { + newImplicitEvent = makeReasonReadable(reason, this) + + reason.f = { ...reason.e, [newImplicitEvent.type]: newImplicitEvent } + } + + const shouldSchedule = + this.A || + (reason.e && Object.keys(reason.e).some(key => this.C[key])) || + (newImplicitEvent && this.C[newImplicitEvent.type]) + + // schedule the job if needed. If not scheduling, kill this listener now if + // its source is destroyed. + shouldSchedule + ? w.push(reason) === 1 && e._scheduler.schedule(this, defer) + : this.i?.l === 'Destroyed' && this.k(this.i) + } +} diff --git a/packages/atoms/src/classes/MappedSignal.ts b/packages/atoms/src/classes/MappedSignal.ts index 068ae34b..17578c45 100644 --- a/packages/atoms/src/classes/MappedSignal.ts +++ b/packages/atoms/src/classes/MappedSignal.ts @@ -2,11 +2,11 @@ import { Settable } from '@zedux/core' import { AnyNodeGenerics, AtomGenerics, - ExplicitEvents, InternalEvaluationReason, Mutatable, SendableEvents, Transaction, + UndefinedEvents, } from '../types/index' import { destroyBuffer, @@ -15,10 +15,10 @@ import { startBuffer, } from '../utils/evaluationContext' import { Ecosystem } from './Ecosystem' -import { Signal } from './Signal' -import { recursivelyMutate, recursivelyProxy } from './proxies' +import { doMutate, Signal } from './Signal' +import { EventSent, TopPrio } from '../utils/general' -export type SignalMap = Record> +export type SignalMap = Record | unknown> export class MappedSignal< G extends Pick & { @@ -29,6 +29,21 @@ export class MappedSignal< State: any } > extends Signal { + /** + * `C`hangeEvents - any events passed to `this.set`. We propagate these + * directly to observers rather than re-inferring them all from inner signals + * after passing the `set` changes along to them. + */ + public C?: Partial> + + /** + * `b`ufferedTransactions - when propagating mutate events from inner signals + * that did _not_ originate from this wrapper signal, we attach the inner + * signal's key path to each transaction and send those modified transactions + * along with the change event when this wrapper signal's job runs. + */ + public b?: Transaction[] + /** * `I`dsToKeys - maps wrapped signal ids to the keys they control in this * wrapper signal's state. @@ -46,109 +61,45 @@ export class MappedSignal< /** * @see Signal.e */ - public readonly e: Ecosystem, + e: Ecosystem, /** * @see Signal.id */ - public readonly id: string, + id: string, /** * The map of state properties to signals that control them */ public M: SignalMap ) { - const entries = Object.entries(M) - const flattenedEvents = {} as { - [K in keyof G['Events']]: () => G['Events'][K] - } - - super(e, id, null, flattenedEvents) - - // `get` every signal and auto-add each one as a source of the mapped signal - const { n, s } = getEvaluationContext() - startBuffer(this) - - try { - this.v = Object.fromEntries( - entries.map(([key, val]) => { - // flatten all events from all inner signals into the mapped signal's - // events list - Object.assign(flattenedEvents, val.E) - this.I[val.id] = key - - return [key, e.live.get(val)] - }) - ) - } catch (e) { - destroyBuffer(n, s) + super( + e, + id, + undefined, + {} as { + [K in keyof G['Events']]: () => G['Events'][K] + } + ) - throw e - } finally { - flushBuffer(n, s) - } + this.v = this.u(M, true) } public mutate( mutatable: Mutatable, - events?: Partial + events?: Partial> ) { - const oldState = this.v - - if ( - DEV && - (typeof oldState !== 'object' || !oldState) && - !Array.isArray(oldState) && - !((oldState as any) instanceof Set) - ) { - throw new TypeError( - 'Zedux: signal.mutate only supports native JS objects, arrays, and sets' - ) - } - - const transactions: Transaction[] = [] - let newState = oldState - - const parentProxy = { - t: transactions, - u: (val: G['State']) => (newState = val), - } - - const proxyWrapper = recursivelyProxy(oldState, parentProxy) - - if (typeof mutatable === 'function') { - const result = (mutatable as (state: G['State']) => any)(proxyWrapper.p) - - // if the callback function doesn't return void, assume it's a partial - // state object that represents a set of mutations Zedux needs to apply to - // the signal's state. - if (result) recursivelyMutate(proxyWrapper.p, result) - } else { - recursivelyMutate(proxyWrapper.p, mutatable) - } - - newState === oldState || - this.set(newState, { - ...events, - // TODO: put this whole function in a job so scheduler is already - // running here rather than adding a `batch` event here - batch: true, - // TODO: instead of calling `this.set`, loop over object entries here - // and pass each signal only the transactions that apply to it, with the - // first path key removed (and the array flattened to a string if - // there's only one key left) - mutate: transactions, - } as Partial & ExplicitEvents) - - return [newState, transactions] as const + return doMutate(this, true, mutatable, events) } - // public send>(eventName: E): void + public send>(eventName: E): void - // public send( - // eventName: E, - // payload: G['Events'][E] - // ): void + public send( + eventName: E, + payload: G['Events'][E] + ): void + + public send>(events: E): void /** * @see Signal.send @@ -162,58 +113,176 @@ export class MappedSignal< eventName: E, payload?: G['Events'][E] ) { + const pre = this.e._scheduler.pre() + for (const signal of Object.values(this.M)) { - signal.E?.[eventName] && signal.send(eventName, payload, true) + if ((signal as Signal | undefined)?.izn) { + ;(signal as Signal).E?.[eventName] && + (signal as Signal).send(eventName, payload) + } } - // flush once now that all nodes are scheduled - this.e._scheduler.flush() + this.e._scheduler.post(pre) } public set( settable: Settable, - events?: Partial + events?: Partial> ) { const newState = typeof settable === 'function' ? (settable as (state: G['State']) => G['State'])(this.v) : settable + if (newState === this.v) return + + this.C = events + + const pre = this.e._scheduler.pre() + for (const [key, value] of Object.entries(newState)) { if (value !== this.v[key]) { - // TODO: filter out events that aren't either ExplicitEvents or - // specified in this inner signal: - this.M[key].set(value, events) + const signal = this.M[key] + + if (!(signal as Signal | undefined)?.izn) { + if (!this.N) this.N = { ...this.v } + + this.N![key] = value + continue + } + + const relevantEvents = + (signal as Signal).E && + events && + Object.fromEntries( + Object.entries(events).filter( + // we don't pass mutate events to inner signals - they weren't + // mutated, the outer signal was. This is to optimize + // performance for the most common case - usually you won't + // subscribe to individual inner signals; you subscribe to the + // full, outer signal or the atom itself. + ([eventName]) => + eventName !== 'mutate' && eventName in (signal as Signal).E! + ) + ) + + ;(signal as Signal).set(value, relevantEvents) } } + + // if this wrapper signal hasn't been scheduled at this point, no inner + // signals were updated. Either `set` was called with a different object + // reference (making `this.N` undefined and this whole call a noop) or + // `this.N` will contain one or more updates for non-signal inner values. + // No need to involve the scheduler. Update own state now. + if (!this.w.length && this.N) this.j() + + this.e._scheduler.post(pre) } /** * @see Signal.j */ public j() { - // Wrapped signal(s) changed. Propagate the change(s) to this wrapper - // signal. Use `super.set` for this 'cause `this.set` intercepts set calls - // and forwards them the other way - to the inner signals - super.set(this.N) + // Inner signal(s) or values changed. Propagate the change(s) to this + // wrapper signal and its observers. Use `super.set` for this 'cause + // `this.set` intercepts set calls and forwards them the other way - to the + // inner signals + super.set( + this.N, + this.C ?? (this.b && ({ mutate: this.b } as Partial>)) + ) this.w = [] + this.C = undefined + this.N = undefined + this.b = undefined } /** * @see Signal.r */ public r(reason: InternalEvaluationReason, defer?: boolean) { - if (this.w.push(reason) === 1) { - this.e._scheduler.schedule(this, defer) + if (reason.t !== EventSent) { + if (this.w.push(reason) === 1) { + this.e._scheduler.schedule(this, defer) + } - if (reason.s) this.N = { ...this.v } - } + if (reason.s) { + this.N ??= { ...this.v } + + this.N![this.I[reason.s.id]] = reason.s.v + + // handle the `mutate` event specifically - add the inner node's key to + // all transaction `k`ey paths + if (!this.C && reason.e?.mutate) { + this.b ??= [] - if (reason.s) this.N![this.I[reason.s.id]] = reason.s.v + const key = this.I[reason.s.id] + + this.b.push( + ...(reason.e.mutate as Transaction[]).map(transaction => ({ + ...transaction, + k: Array.isArray(transaction.k) + ? [key, ...transaction.k] + : [key, transaction.k], + })) + ) + } + } + } // forward events from wrapped signals to observers of this wrapper signal. // Use `super.send` for this 'cause `this.send` intercepts events and passes // them the other way (up to wrapped signals) - reason.e && super.send(reason.e as SendableEvents) + if (reason.e && (!reason.e.mutate || Object.keys(reason.e).length > 1)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { mutate, ...events } = reason.e + + super.send(events as Partial) + } + } + + public u(map: SignalMap, isInitializing?: boolean) { + const entries = Object.entries(map) + + // `get` every signal and auto-add each one as a source of the mapped signal + const { n, s } = getEvaluationContext() + startBuffer(this) + + try { + const edgeConfig = { f: TopPrio } + + if (isInitializing) { + return Object.fromEntries( + entries.map(([key, val]) => { + if (!(val as Signal | undefined)?.izn) return [key, val as any] + + // flatten all events from all inner signals into the mapped signal's + // events list + Object.assign(this.E!, (val as Signal).E) + this.I[(val as Signal).id] = key + + return [key, (val as Signal).get(edgeConfig)] + }) + ) + } + + for (const [key, val] of entries) { + if ((val as Signal).izn) { + // make sure the edge is re-created + ;(val as Signal).get(edgeConfig) + + // update the (forward) map and reverse map + this.M[key] = val as Signal + this.I[(val as Signal).id] = key + } + } + } catch (e) { + destroyBuffer(n, s) + + throw e + } finally { + flushBuffer(n, s) + } } } diff --git a/packages/atoms/src/classes/Scheduler.ts b/packages/atoms/src/classes/Scheduler.ts index 5a0c4199..53e168ae 100644 --- a/packages/atoms/src/classes/Scheduler.ts +++ b/packages/atoms/src/classes/Scheduler.ts @@ -6,8 +6,8 @@ export class Scheduler implements SchedulerInterface { * We set this to true internally when the scheduler starts flushing. We also * set it to true when batching updates, to prevent anything from flushing. */ - public _isRunning?: boolean - public _isRunningNows?: boolean + public _isRunning = false + public _isRunningNows = false // private _runStartTime?: number @@ -49,6 +49,47 @@ export class Scheduler implements SchedulerInterface { this.runJobs() } + /** + * Call after any operation that may have nested flush attempts. This in + * itself _is_ a flush attempt so whatever calls may also need wrapping in + * pre+post. + * + * This is the counterpart to `scheduler.pre()`. Call with the value returned + * from `.pre()` + */ + public post(prevIsRunning: boolean) { + this._isRunning = prevIsRunning + this.flush() + } + + /** + * Call before any operation that may have nested flush attempts. When + * combined with `scheduler.post`, this is essentially an `ecosystem.batch()` + * call but more performant since it doesn't involve creating a closure. + * + * This ensures that many of Zedux's recursive call stacks don't flush + * multiple times - only the top-level call finally flushes when everything is + * scheduled. + * + * Returns a value that should be passed to `scheduler.post()` after the + * potentially-nested flush operation. Always combine with + * `scheduler.post(preReturnValue)` + * + * Example: + * + * ```ts + * const pre = scheduler.pre() + * setAtomStateOrSendSignalEventsEtc() + * scheduler.post(pre) + * ``` + */ + public pre() { + const prevIsRunning = this._isRunning + this._isRunning = true + + return prevIsRunning + } + /** * Insert a job into the queue. Insertion point depends on job's type and * weight. diff --git a/packages/atoms/src/classes/SelectorInstance.ts b/packages/atoms/src/classes/SelectorInstance.ts index f3b7244f..813734cd 100644 --- a/packages/atoms/src/classes/SelectorInstance.ts +++ b/packages/atoms/src/classes/SelectorInstance.ts @@ -75,7 +75,7 @@ export const runSelector = ( if (isInitializing) { setNodeStatus(node, 'Active') } else if (!resultsComparator(result, oldState)) { - if (!suppressNotify) scheduleDependents({ p: oldState, s: node }) + suppressNotify || scheduleDependents({ n: node.v, o: oldState, s: node }) if (_mods.stateChanged) { modBus.dispatch( diff --git a/packages/atoms/src/classes/Signal.ts b/packages/atoms/src/classes/Signal.ts index c2f18a39..d19ae360 100644 --- a/packages/atoms/src/classes/Signal.ts +++ b/packages/atoms/src/classes/Signal.ts @@ -1,9 +1,9 @@ import { Settable } from '@zedux/core' import { AtomGenerics, - ExplicitEvents, InternalEvaluationReason, Mutatable, + NodeGenerics, SendableEvents, Transaction, UndefinedEvents, @@ -13,12 +13,72 @@ import { destroyNodeFinish, destroyNodeStart, handleStateChange, - scheduleDependents, + scheduleEventListeners, + setNodeStatus, } from '../utils/graph' import { Ecosystem } from './Ecosystem' import { GraphNode } from './GraphNode' import { recursivelyMutate, recursivelyProxy } from './proxies' +export const doMutate = ( + node: Signal, + isWrapperSignal: boolean, + mutatable: Mutatable, + events?: Partial> +) => { + const oldState = node.v + + if ( + DEV && + (typeof oldState !== 'object' || !oldState) && + !Array.isArray(oldState) && + !((oldState as any) instanceof Set) + ) { + throw new TypeError( + 'Zedux: signal.mutate only supports native JS objects, arrays, and sets' + ) + } + + const transactions: Transaction[] = [] + let newState = oldState + + const parentProxy = { + t: transactions, + u: (val: G['State']) => (newState = val), + } + + const proxyWrapper = recursivelyProxy(oldState, parentProxy) + + if (typeof mutatable === 'function') { + const result = (mutatable as (state: G['State']) => any)(proxyWrapper.p) + + // if the callback function doesn't return void, assume it's a partial + // state object that represents a set of mutations Zedux needs to apply to + // the signal's state. + if (result) recursivelyMutate(proxyWrapper.p, result) + } else { + recursivelyMutate(proxyWrapper.p, mutatable) + } + + if (newState !== oldState) { + if (isWrapperSignal) { + node.set(newState, { + ...events, + mutate: transactions, + } as Partial>) + } else { + node.v = newState + + handleStateChange(node, oldState, { + ...events, + mutate: transactions, + } as Partial>) + } + } + + return [newState, transactions] as const +} + export class Signal< G extends Pick & { Params?: any @@ -68,9 +128,13 @@ export class Signal< * unused functions with typed return types. We use ReturnType on these to * infer the expected payload type of each custom event. */ - public E?: { [K in keyof G['Events']]: () => G['Events'][K] } + public E?: { [K in keyof G['Events']]: () => G['Events'][K] }, + + deferActiveStatus?: boolean ) { super() + + deferActiveStatus || setNodeStatus(this, 'Active') } /** @@ -102,76 +166,31 @@ export class Signal< */ public mutate( mutatable: Mutatable, - events?: Partial + events?: Partial> ) { - const oldState = this.v - - if ( - DEV && - (typeof oldState !== 'object' || !oldState) && - !Array.isArray(oldState) && - !((oldState as any) instanceof Set) - ) { - throw new TypeError( - 'Zedux: signal.mutate only supports native JS objects, arrays, and sets' - ) - } - - const transactions: Transaction[] = [] - let newState = oldState - - const parentProxy = { - t: transactions, - u: (val: G['State']) => (newState = this.v = val), - } - - const proxyWrapper = recursivelyProxy(oldState, parentProxy) - - if (typeof mutatable === 'function') { - const result = (mutatable as (state: G['State']) => any)(proxyWrapper.p) - - // if the callback function doesn't return void, assume it's a partial - // state object that represents a set of mutations Zedux needs to apply to - // the signal's state. - if (result) recursivelyMutate(proxyWrapper.p, result) - } else { - recursivelyMutate(proxyWrapper.p, mutatable) - } - - newState === oldState || - handleStateChange(this, oldState, { - ...events, - mutate: transactions, - } as Partial & ExplicitEvents) - - return [newState, transactions] as const + return doMutate(this, false, mutatable, events) } - public send>>(eventName: E): void + public send>(eventName: E): void - public send>( + public send( eventName: E, - payload: SendableEvents[E], - defer?: boolean + payload: G['Events'][E] ): void - public send>>(events: E): void + public send>(events: E): void /** * Manually notify this signal's event listeners of an event. Accepts an * object to send multiple events at once. * - * The optional third `defer` param is mostly for internal use. We pass - * `false` and manually flush the scheduler to batch multiple sends. - * * ```ts * signal.send({ eventA: 'payload for a', eventB: 'payload for b' }) * ``` */ - public send>( - eventNameOrMap: E | Partial>, - payload?: SendableEvents[E], - defer?: boolean + public send( + eventNameOrMap: E | Partial, + payload?: G['Events'][E] ) { // TODO: maybe safeguard against users sending unrecognized events here // (especially `send`ing an ImplicitEvent would break everything) @@ -180,9 +199,9 @@ export class Signal< ? eventNameOrMap : { [eventNameOrMap]: payload } - scheduleDependents({ e: events, s: this, t: EventSent }) - - defer || this.e._scheduler.flush() + const pre = this.e._scheduler.pre() + scheduleEventListeners({ e: events, s: this, t: EventSent }) + this.e._scheduler.post(pre) } /** @@ -197,7 +216,7 @@ export class Signal< */ public set( settable: Settable, - events?: Partial + events?: Partial> ) { const oldState = this.v const newState = (this.v = @@ -221,6 +240,7 @@ export class Signal< /** * @see GraphNode.h */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars public h(val: any) {} /** @@ -239,5 +259,6 @@ export class Signal< /** * @see GraphNode.r a noop - signals have nothing to evaluate */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars public r(reason: InternalEvaluationReason, defer?: boolean) {} } diff --git a/packages/atoms/src/classes/index.ts b/packages/atoms/src/classes/index.ts index 744bc1d7..15400dad 100644 --- a/packages/atoms/src/classes/index.ts +++ b/packages/atoms/src/classes/index.ts @@ -7,5 +7,5 @@ export * from './Ecosystem' export { ExternalNode, GraphNode } from './GraphNode' export * from './MappedSignal' export * from './SelectorInstance' -export * from './Signal' +export { Signal } from './Signal' export * from './ZeduxPlugin' diff --git a/packages/atoms/src/classes/instances/AtomInstance.ts b/packages/atoms/src/classes/instances/AtomInstance.ts index 1dfb88fa..8a55b3c4 100644 --- a/packages/atoms/src/classes/instances/AtomInstance.ts +++ b/packages/atoms/src/classes/instances/AtomInstance.ts @@ -1,5 +1,9 @@ import { is, Observable, Settable } from '@zedux/core' -import { Mutatable, SendableEvents } from '@zedux/atoms/types/events' +import { + Mutatable, + SendableEvents, + UndefinedEvents, +} from '@zedux/atoms/types/events' import { AtomGenerics, AtomGenericsToAtomApiGenerics, @@ -12,7 +16,6 @@ import { DehydrationOptions, AnyAtomGenerics, InternalEvaluationReason, - ExplicitEvents, } from '@zedux/atoms/types/index' import { EventSent, @@ -20,8 +23,8 @@ import { prefix, PromiseChange, Static, + TopPrio, } from '@zedux/atoms/utils/general' -import { pluginActions } from '@zedux/atoms/utils/plugin-actions' import { getErrorPromiseState, getInitialPromiseState, @@ -35,7 +38,9 @@ import { destroyNodeFinish, destroyNodeStart, handleStateChange, - scheduleDependents, + scheduleEventListeners, + scheduleStaticDependents, + setNodeStatus, } from '../../utils/graph' import { bufferEdge, @@ -136,7 +141,7 @@ const setPromise = >( const state: PromiseState = getInitialPromiseState(currentState?.data) instance._promiseStatus = state.status - scheduleDependents({ s: instance, t: PromiseChange }, true, true) + scheduleStaticDependents({ r: instance.w, s: instance, t: PromiseChange }) return state as unknown as G['State'] } @@ -170,11 +175,6 @@ export class AtomInstance< */ public a: boolean | undefined = undefined - /** - * `b`ufferedEvents - when the wrapped signal emits events, we - */ - public b?: Partial - /** * `S`ignal - the signal returned from this atom's state factory. If this is * undefined, no signal was returned, and this atom itself becomes the signal. @@ -210,7 +210,7 @@ export class AtomInstance< */ public readonly p: G['Params'] ) { - super(e, id, undefined) // TODO NOW: fix this undefined + super(e, id, undefined, undefined, true) } /** @@ -254,7 +254,10 @@ export class AtomInstance< * Force this atom instance to reevaluate. */ public invalidate() { - this.r({ s: this, t: Invalidate }, false) + const reason = { s: this, t: Invalidate } as const + this.r(reason, false) + + scheduleEventListeners(reason) // run the scheduler synchronously after invalidation this.e._scheduler.flush() @@ -269,13 +272,22 @@ export class AtomInstance< */ public mutate( mutatable: Mutatable, - events?: Partial + events?: Partial> ) { return this.S ? this.S.mutate(mutatable, events) : super.mutate(mutatable, events) } + public send>(eventName: E): void + + public send( + eventName: E, + payload: G['Events'][E] + ): void + + public send>(events: E): void + /** * @see Signal.send atoms don't have events themselves, but they * inherit them from any signal returned from the state factory. @@ -298,7 +310,7 @@ export class AtomInstance< */ public set( settable: Settable, - events?: Partial + events?: Partial> ) { return this.S ? this.S.set(settable, events) : super.set(settable, events) } @@ -334,7 +346,7 @@ export class AtomInstance< const { n, s } = getEvaluationContext() this.j() - this._setStatus('Active') + setNodeStatus(this, 'Active') flushBuffer(n, s) // hydrate if possible @@ -359,8 +371,8 @@ export class AtomInstance< // evaluation - just capture the state update and forward to this atom's // observers. const oldState = this.v - this.v = this.S!.v // `this.S`ignal must exist if `this.a`lteredEdge is true - handleStateChange(this, oldState, this.b) + this.v = this.S!.v // `this.S`ignal must exist if `this.a`lteredEdge does + handleStateChange(this, oldState) return } @@ -375,7 +387,8 @@ export class AtomInstance< if (this.l === 'Initializing') { if ((newFactoryResult as Signal)?.izn) { this.S = newFactoryResult - this.v = (newFactoryResult as Signal).v + this.v = (newFactoryResult as Signal).v + this.E = (newFactoryResult as Signal).E } else { this.v = newFactoryResult } @@ -394,7 +407,7 @@ export class AtomInstance< const oldState = this.v this.v = this.S ? newFactoryResult.v : newFactoryResult - this.v === oldState || handleStateChange(this, oldState, this.b) + this.v === oldState || handleStateChange(this, oldState) } if (this.S) { @@ -402,13 +415,13 @@ export class AtomInstance< if (edge) { // the edge between this atom and its wrapped signal needs to be - // reactive. Track whether we altered the `p`endingFlags added by - // `injectSignal`/similar. If we did, the edge was static and should - // not trigger a reevaluation of this atom. + // reactive. Track whether we made the `p`endingFlags added by + // `injectSignal`/similar non-Static. If we did, we need to make the + // edge not reevaluate this atom. this.a = !!(edge.p! & Static) // `.p!` - doesn't matter if undefined - edge.p = 0 + edge.p = TopPrio } else { - bufferEdge(newFactoryResult, 'implicit', 0) + bufferEdge(newFactoryResult, 'implicit', TopPrio) } } } catch (err) { @@ -428,10 +441,9 @@ export class AtomInstance< const oldState = this.v this.v = this.S.v - handleStateChange(this, oldState, this.b) + handleStateChange(this, oldState) } - this.b = undefined this.w = [] } @@ -448,7 +460,7 @@ export class AtomInstance< const ttl = this._getTtl() if (ttl === 0) return this.destroy() - this._setStatus('Stale') + setNodeStatus(this, 'Stale') if (ttl == null || ttl === -1) return if (typeof ttl === 'number') { @@ -458,9 +470,8 @@ export class AtomInstance< this.destroy() }, ttl) - // TODO: dispatch an action over stateStore for these mutations this.c = () => { - this._setStatus('Active') + setNodeStatus(this, 'Active') this.c = undefined clearTimeout(timeoutId) } @@ -476,7 +487,7 @@ export class AtomInstance< }) this.c = () => { - this._setStatus('Active') + setNodeStatus(this, 'Active') this.c = undefined isCanceled = true } @@ -491,7 +502,7 @@ export class AtomInstance< }) this.c = () => { - this._setStatus('Active') + setNodeStatus(this, 'Active') this.c = undefined subscription.unsubscribe() } @@ -517,19 +528,21 @@ export class AtomInstance< // status is Stale (and should we just always evaluate once when // waking up a stale atom)? if (this.l !== 'Destroyed' && this.w.push(reason) === 1) { - if (reason.s && reason.s === this.S) { - // when `this.S`ignal gives us events along with a state update, we need - // to buffer those and emit them together after this atom evaluates - if (reason.e) { - this.b = this.b - ? { ...this.b, ...(reason.e as typeof this.b) } - : (reason.e as unknown as typeof this.b) - } - } - // refCount just hit 1; we haven't scheduled a job for this node yet this.e._scheduler.schedule(this, defer) } + + if (reason.s && reason.s === this.S && reason.e) { + // when `this.S`ignal gives us events along with a state update, subsume + // it as this atom's own state update. This atom will reevaluate before + // any scheduled dependents, giving the state factory an opportunity to + // change the signal's state again, resulting in an additional event. + const oldState = this.v + this.v = reason.s.v + + this.v === oldState || + handleStateChange(this, oldState, reason.e as Partial) + } } public _set?: ExportsInfusedSetter @@ -551,19 +564,4 @@ export class AtomInstance< return typeof ttl === 'function' ? ttl() : ttl } - - private _setStatus(newStatus: LifecycleStatus) { - const oldStatus = this.l - this.l = newStatus - - if (this.e._mods.statusChanged) { - this.e.modBus.dispatch( - pluginActions.statusChanged({ - newStatus, - node: this, - oldStatus, - }) - ) - } - } } diff --git a/packages/atoms/src/classes/proxies.ts b/packages/atoms/src/classes/proxies.ts index b8fa4ecf..60a4fd70 100644 --- a/packages/atoms/src/classes/proxies.ts +++ b/packages/atoms/src/classes/proxies.ts @@ -94,6 +94,7 @@ export abstract class ProxyWrapper * should be the only data type to override this. Everything else should let * this error throw. */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars public d(state: State, key: PropertyKey) { throw new Error(unsupportedOperator) } @@ -128,6 +129,7 @@ export abstract class ProxyWrapper * `s`etImpl - handle the `=` assignment operator. Only native JS objects and * arrays should override this. */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars public s(state: State, key: PropertyKey, val: any) { throw new Error(unsupportedOperator) } diff --git a/packages/atoms/src/index.ts b/packages/atoms/src/index.ts index dea848eb..ebc374cd 100644 --- a/packages/atoms/src/index.ts +++ b/packages/atoms/src/index.ts @@ -9,6 +9,7 @@ import { destroyNodeFinish, destroyNodeStart, scheduleDependents, + scheduleStaticDependents, } from './utils/graph' export * from '@zedux/core' @@ -20,6 +21,7 @@ export * from './types/index' // These are very obfuscated on purpose. Don't use! They're for Zedux packages. export const zi = { + a: scheduleStaticDependents, b: destroyNodeStart, c: createInjector, d: destroyBuffer, diff --git a/packages/atoms/src/injectors/injectAtomInstance.ts b/packages/atoms/src/injectors/injectAtomInstance.ts index cd18e6e3..21750519 100644 --- a/packages/atoms/src/injectors/injectAtomInstance.ts +++ b/packages/atoms/src/injectors/injectAtomInstance.ts @@ -1,5 +1,5 @@ import { createInjector } from '../factories/createInjector' -import { prefix, Static } from '../utils/general' +import { Eventless, EventlessStatic, prefix } from '../utils/general' import type { InjectorDescriptor } from '../utils/types' import { AnyAtomInstance, @@ -63,7 +63,7 @@ export const injectAtomInstance: { atom as A, params as ParamsOf, { - f: config?.subscribe ? 0 : Static, + f: config?.subscribe ? Eventless : EventlessStatic, op: config?.operation || defaultOperation, } ) @@ -85,7 +85,7 @@ export const injectAtomInstance: { atom as A, params as ParamsOf, { - f: config?.subscribe ? 0 : Static, + f: config?.subscribe ? Eventless : EventlessStatic, op: config?.operation || defaultOperation, } ) diff --git a/packages/atoms/src/injectors/injectMappedSignal.ts b/packages/atoms/src/injectors/injectMappedSignal.ts index f48999ba..7b2bfb48 100644 --- a/packages/atoms/src/injectors/injectMappedSignal.ts +++ b/packages/atoms/src/injectors/injectMappedSignal.ts @@ -1,16 +1,21 @@ import { MappedSignal, SignalMap } from '../classes/MappedSignal' +import { Signal } from '../classes/Signal' import { AnyNonNullishValue, EventsOf, InjectSignalConfig, + None, Prettify, StateOf, } from '../types/index' import { readInstance } from '../utils/evaluationContext' -import { Static } from '../utils/general' -import { injectAtomGetters } from './injectAtomGetters' +import { Eventless, EventlessStatic } from '../utils/general' import { injectMemo } from './injectMemo' +type MapAll = MapEventsToPayloads<{ + [K in keyof M]: M[K] extends Signal ? EventsOf : None +}> + type MapEventsToPayloads> = TupleToEvents< Events, UnionToTuple @@ -37,19 +42,42 @@ type UnionToTuple = UnionToIntersection< ? [...UnionToTuple>, W] : [] +/** + * Creates a special "mapped" signal that wraps other signals. The state of + * mapped signals is always a normal JS object. + * + * Automatically keeps the inner signal references up-to-date on every + * evaluation and re-creates any that are force-destroyed. + * + * Also accepts non-signal values. These are set as-is in the mapped signal's + * state on initial evaluation and ignored on subsequent evaluations. + * + * Does not support deeply-nested objects. Use multiple `injectMappedSignal` + * calls for that. + * + * ```ts + * const otherSignal = injectSignal('other state') + * const nestedSignal = injectAtomInstance(otherAtom) // atoms are signals + * + * const signal = injectMappedSignal({ + * other: otherSignal, // ref kept in sync + * nonSignal: 'any value here', // sets only the initial state of this field + * nestedMappedSignal: injectMappedSignal({ + * nested: nestedSignal // ref kept in sync + * }) + * }) + * ``` + */ export const injectMappedSignal = ( map: M, - config?: Pick< - InjectSignalConfig }>>, - 'reactive' - > + config?: Pick>, 'reactive'> ) => { const instance = readInstance() const signal = injectMemo(() => { return new MappedSignal<{ - Events: Prettify }>> - State: { [K in keyof M]: StateOf } + Events: Prettify> + State: { [K in keyof M]: M[K] extends Signal ? StateOf : M[K] } }>( instance.e, instance.e._idGenerator.generateId(`@signal(${instance.id})`), @@ -58,10 +86,14 @@ export const injectMappedSignal = ( }, []) // create a graph edge between the current atom and the new signal - injectAtomGetters().getNode(signal, undefined, { - f: config?.reactive === false ? Static : 0, + signal.get({ + f: config?.reactive === false ? EventlessStatic : Eventless, op: 'injectMappedSignal', }) + // iterate over the passed object on every evaluation. Check for any changed + // inner signal references and swap them out if needed. + signal.u(map) + return signal } diff --git a/packages/atoms/src/injectors/injectMemo.ts b/packages/atoms/src/injectors/injectMemo.ts index dfd4ab49..ad0776fe 100644 --- a/packages/atoms/src/injectors/injectMemo.ts +++ b/packages/atoms/src/injectors/injectMemo.ts @@ -21,6 +21,7 @@ export const injectMemo: ( ({ type: `${prefix}/memo`, deps, + // TODO: wrap all injector callback calls in `ecosystem.untrack` result: valueFactory(), } as MemoInjectorDescriptor), ( diff --git a/packages/atoms/src/injectors/injectSignal.ts b/packages/atoms/src/injectors/injectSignal.ts index 7bfeaa0a..5f0ddf49 100644 --- a/packages/atoms/src/injectors/injectSignal.ts +++ b/packages/atoms/src/injectors/injectSignal.ts @@ -1,7 +1,7 @@ import { Signal } from '../classes/Signal' import { EventMap, InjectSignalConfig, MapEvents, None } from '../types/index' import { readInstance } from '../utils/evaluationContext' -import { Static } from '../utils/general' +import { Eventless, EventlessStatic } from '../utils/general' import { injectMemo } from './injectMemo' /** @@ -15,7 +15,7 @@ import { injectMemo } from './injectMemo' * }) * ``` */ -export const As = () => 0 as T +export const As = () => 0 as unknown as T /** * The main API for creating signals in Zedux. Returns a stable instance of the @@ -50,8 +50,8 @@ export const injectSignal = ( }, []) // create a graph edge between the current atom and the new signal - instance.e.live.getNode(signal, undefined, { - f: config?.reactive === false ? Static : 0, + signal.get({ + f: config?.reactive === false ? EventlessStatic : Eventless, op: 'injectSignal', }) diff --git a/packages/atoms/src/types/atoms.ts b/packages/atoms/src/types/atoms.ts index 85dde03b..df1d6dc3 100644 --- a/packages/atoms/src/types/atoms.ts +++ b/packages/atoms/src/types/atoms.ts @@ -95,6 +95,10 @@ export type ExportsOf = ? G['Exports'] : never +export type GenericsOf = A extends GraphNode + ? G + : never + export type NodeGenerics = Pick< AtomGenerics, 'Events' | 'Params' | 'State' | 'Template' diff --git a/packages/atoms/src/types/events.ts b/packages/atoms/src/types/events.ts index a72c2f7f..dad1c21f 100644 --- a/packages/atoms/src/types/events.ts +++ b/packages/atoms/src/types/events.ts @@ -1,28 +1,71 @@ import { RecursivePartial } from '@zedux/core' +import { GraphNode } from '../classes/GraphNode' import { AnyNodeGenerics, AtomGenerics, - ChangeEvent, Cleanup, - GraphEdgeConfig, + LifecycleStatus, + ListenerConfig, NodeGenerics, Prettify, } from './index' +export type CatchAllListener = ( + eventMap: Partial> +) => void + +export interface ChangeEvent + extends EventBase { + newState: G['State'] + oldState: G['State'] + type: 'change' +} + +export interface CycleEvent + extends EventBase { + oldStatus: LifecycleStatus + newStatus: LifecycleStatus + type: 'cycle' +} + +export type EvaluationReason = + | ChangeEvent + | CycleEvent + | EventReceivedEvent + | InvalidateEvent + | PromiseChangeEvent + +export interface EventBase { + operation?: string // e.g. a method like "injectValue" + reasons?: EvaluationReason[] + source?: GraphNode +} + +export interface EventEmitter { + on( + eventName: E, + callback: SingleEventListener, + config?: ListenerConfig + ): Cleanup + + on(callback: CatchAllListener, config?: ListenerConfig): Cleanup +} + +export interface EventReceivedEvent + extends EventBase { + type: 'event' +} + /** - * Events that can be dispatched manually. This is not the full list of events - * that can be listened to on Zedux event emitters - for example, all stateful - * nodes emit `change` and (via the ecosystem) `cycle` events and atoms emit - * `promisechange` events. + * Events that can be sent manually. This is not the full list of events that + * can be listened to on Zedux event emitters. + * + * @see ListenableEvents + * @see SendableEvents */ export interface ExplicitEvents { /** - * Dispatch a `batch` event alongside any `.set` or `.mutate` update to defer notifying dependents - */ - batch: boolean - - /** - * `mutate` events can be dispatched manually alongside a `.set` call to + * `mutate` events can be sent manually alongside a `.set` call to * bypass Zedux's automatic proxy-based mutation tracking. This may be desired * for better performance or when using data types that Zedux doesn't natively * proxy. @@ -34,28 +77,60 @@ export interface ExplicitEvents { mutate: Transaction[] } -export type CatchAllListener = ( - eventMap: Partial> -) => void +/** + * Events that Zedux creates internally and sends to listeners. You cannot + * `.send()` these events manually. + * + * Not all of these apply to every node type. For example, only atoms will ever + * send `promiseChange` events. + */ +export interface ImplicitEvents { + /** + * Zedux sends this event whenever a GraphNode's value changes. + */ + change: ChangeEvent -export interface EventEmitter { - // TODO: add a `passive` option for listeners that don't prevent destruction - on( - eventName: E, - callback: SingleEventListener, - edgeDetails?: GraphEdgeConfig - ): Cleanup + /** + * When listening to a GraphNode, Zedux sends this event for the following + * lifecycle status changes: + * + * - Active -> Stale + * - Active -> Destroyed + * - Stale -> Active + * - Stale -> Destroyed + * + * When listening to the Ecosystem, Zedux also sends this event for: + * + * - Initializing -> Active + * + * (It isn't possible to attach an event listener to a GraphNode before it's + * Active) + */ + cycle: CycleEvent - on(callback: CatchAllListener, edgeDetails?: GraphEdgeConfig): Cleanup + /** + * Zedux sends this event whenever `atomInstance.invalidate()` is called. Some + * Zedux APIs hook into this event like `injectPromise`'s `runOnInvalidate` + * option. + */ + invalidate: InvalidateEvent + + /** + * Zedux sends this event when an atom instance's `.promise` reference changed + * on a reevaluation. This essentially makes an atom's `.promise` another + * piece of its state - all Zedux's injectors, atom getters, and React hooks + * will cause a reevaluation/rerender when this event fires. + */ + promiseChange: PromiseChangeEvent } -export interface ImplicitEvents { - change: ChangeEvent +export interface InvalidateEvent + extends EventBase { + type: 'invalidate' } -export type ListenableEvents = Prettify< - G['Events'] & ExplicitEvents & ImplicitEvents -> +export type ListenableEvents = + Prettify> export type Mutatable = | RecursivePartial @@ -63,6 +138,11 @@ export type Mutatable = export type MutatableTypes = any[] | Record | Set +export interface PromiseChangeEvent + extends EventBase { + type: 'promiseChange' +} + export type SendableEvents> = Prettify< G['Events'] & ExplicitEvents > diff --git a/packages/atoms/src/types/index.ts b/packages/atoms/src/types/index.ts index 5257394f..d3fe184e 100644 --- a/packages/atoms/src/types/index.ts +++ b/packages/atoms/src/types/index.ts @@ -10,10 +10,8 @@ import { AnyAtomGenerics, AnyAtomInstance, AnyAtomTemplate, - AnyNodeGenerics, AtomGenerics, AtomGenericsToAtomApiGenerics, - NodeGenerics, NodeOf, ParamsOf, StateOf, @@ -240,14 +238,6 @@ export type AtomValueOrFactory< } > = AtomStateFactory | G['State'] -export type ChangeEvent = Prettify< - Omit, 'type'> & { - newState: G['State'] - oldState: G['State'] - type: 'change' - } -> - export type Cleanup = () => void export interface DehydrationOptions extends NodeFilterOptions { @@ -277,25 +267,6 @@ export interface EcosystemConfig< export type EffectCallback = () => MaybeCleanup | Promise -export interface EvaluationReasonBase< - G extends NodeGenerics = AnyNodeGenerics -> { - operation?: string // e.g. a method like "injectValue" - reasons?: EvaluationReason[] - source?: GraphNode - type: EvaluationType -} - -export type EvaluationReason = - | ChangeEvent - | EvaluationReasonBase - -export type EvaluationType = - | 'invalidate' - | 'destroy' - | 'promiseChange' - | 'change' - /** * A user-defined object mapping custom event names to unused placeholder * functions whose return types are used to infer expected event payloads. @@ -315,7 +286,7 @@ export interface GraphEdgeConfig { /** * `f`lags - the binary EdgeFlags of this edge */ - f: number + f?: number /** * `op`eration - an optional user-friendly string describing the operation @@ -371,16 +342,32 @@ export interface InjectStoreConfig { export interface InternalEvaluationReason { /** * `e`ventMap - any events sent along with the update that should notify this - * node's listeners and/or trigger special functionality in Zedux (e.g. via - * the `batch` event). These are always either custom events or - * ExplicitEvents, never ImplicitEvents + * node's listeners and/or trigger special functionality in Zedux. These are + * always either custom events or ExplicitEvents, never ImplicitEvents */ e?: Record /** - * `p`revState - the old state of the source node + * `f`ullEventMap - when an evaluation reason reaches an event observer, that + * observer creates the full map of ImplicitEvents that the reason should + * trigger. These ImplicitEvents are merged into the existing `e`ventMap of + * custom events and ExplicitEvents. + * + * This is what ultimately gets passed to `GraphNode#on` event listeners. */ - p?: State + f?: Record + + /** + * `n`ewStateOrStatus - depending on `t`ype, this is either the new state or + * new lifecycle status of the `s`ource node. + */ + n?: LifecycleStatus | State + + /** + * `o`ldStateOrStatus - depending on `t`ype, this is either the previous state + * or previous lifecycle status of the `s`ource node. + */ + o?: LifecycleStatus | State /** * `s`ource - the node that caused its observer to update @@ -412,6 +399,10 @@ export type IonStateFactory> = export type LifecycleStatus = 'Active' | 'Destroyed' | 'Initializing' | 'Stale' +export interface ListenerConfig { + active?: boolean +} + export type MapEvents = Prettify<{ [K in keyof T]: ReturnType }> diff --git a/packages/atoms/src/utils/evaluationContext.ts b/packages/atoms/src/utils/evaluationContext.ts index 93cf75f2..a5a71823 100644 --- a/packages/atoms/src/utils/evaluationContext.ts +++ b/packages/atoms/src/utils/evaluationContext.ts @@ -113,8 +113,10 @@ export const flushBuffer = ( for (const [source, sourceEdge] of evaluationContext.n!.s) { // remove the edge if it wasn't recreated while buffering. Don't remove // anything but implicit-internal edges (those are the only kind we - // auto-create during evaluation - other types may have been added - // manually by the user and we don't want to touch them here) + // auto-create during evaluation - other types may have been added manually + // by the user and we don't want to touch them here). TODO: this check may + // be unnecessary - users only manually add observers (e.g. via + // `GraphNode#on`), not sources. Possibly remove if (sourceEdge.flags & ExplicitExternal) continue if (sourceEdge.p == null) { diff --git a/packages/atoms/src/utils/general.ts b/packages/atoms/src/utils/general.ts index ba3f067d..9a61103e 100644 --- a/packages/atoms/src/utils/general.ts +++ b/packages/atoms/src/utils/general.ts @@ -5,37 +5,43 @@ import { EvaluationReason, InternalEvaluationReason } from '../types/index' * The EdgeFlags. These are used as bitwise flags. * * The flag score determines job priority in the scheduler. Scores range from - * 0-7. Lower score = higher prio. Examples: + * 0-8. Lower score = higher prio. Examples: * - * 0 = implicit-internal-dynamic - * 3 = explicit-external-dynamic - * 7 = explicit-external-static + * - 0 = eventAware-implicit-internal-dynamic (aka TopPrio) + * - 3 = eventless-explicit-internal-dynamic + * - 15 = eventless-explicit-external-static + * + * Event edges are (currently) never paired with other flags and are unique in + * that they don't prevent node destruction. * * IMPORTANT: Keep these in-sync with the copies in the react package - * packages/react/src/utils.ts */ -export const Explicit = 1 -export const External = 2 +export const TopPrio = 0 +export const Eventless = 1 +export const Explicit = 2 +export const External = 4 +export const Static = 8 +export const OutOfRange = 16 // not a flag; use a value bigger than any flag export const ExplicitExternal = Explicit | External -export const Static = 4 -export const OutOfRange = 8 // not a flag; use a value bigger than any flag +export const EventlessStatic = Eventless | Static /** * The InternalEvaluationTypes. These get translated to user-friendly - * EvaluationTypes by `ecosytem.why`. + * strings by `ecosytem.why`. * * IMPORTANT! Keep these in sync with `@zedux/stores/atoms-port.ts` */ export const Invalidate = 1 -export const Destroy = 2 +export const Cycle = 2 // only causes evaluations when status becomes Destroyed export const PromiseChange = 3 export const EventSent = 4 export type InternalEvaluationType = - | typeof Destroy + | typeof Cycle + | typeof EventSent | typeof Invalidate | typeof PromiseChange - | typeof EventSent /** * Compare two arrays for shallow equality. Returns true if they're "equal". @@ -49,24 +55,45 @@ export const compare = (nextDeps?: any[], prevDeps?: any[]) => export const prefix = '@@zedux' -const reasonTypeMap = { - [Destroy]: 'destroy', - [Invalidate]: 'invalidate', - [PromiseChange]: 'promiseChange', - 4: 'change', -} as const - export const makeReasonReadable = ( reason: InternalEvaluationReason, node?: GraphNode -): EvaluationReason => ({ - newState: reason.s?.v, - oldState: reason.p, - operation: node?.s.get(reason.s!)?.operation, - reasons: reason.r && makeReasonsReadable(reason.s, reason.r), - source: reason.s, - type: reasonTypeMap[reason.t ?? 4], -}) +): EvaluationReason => { + const base = { + operation: node?.s.get(reason.s!)?.operation, + reasons: reason.r && makeReasonsReadable(reason.s, reason.r), + source: reason.s, + } + + return reason.t === Cycle + ? { + ...base, + oldStatus: reason.o, + newStatus: reason.n, + type: 'cycle', + } + : reason.t === Invalidate + ? { + ...base, + type: 'invalidate', + } + : reason.t === PromiseChange + ? { + ...base, + type: 'promiseChange', + } + : reason.t === EventSent + ? { + ...base, + type: 'event', + } + : { + ...base, + newState: reason.n, + oldState: reason.o, + type: 'change', + } +} export const makeReasonsReadable = ( node?: GraphNode, diff --git a/packages/atoms/src/utils/graph.ts b/packages/atoms/src/utils/graph.ts index 37b97ac6..da663e17 100644 --- a/packages/atoms/src/utils/graph.ts +++ b/packages/atoms/src/utils/graph.ts @@ -5,9 +5,9 @@ import { LifecycleStatus, } from '@zedux/atoms/types/index' import { type GraphNode } from '../classes/GraphNode' -import { ExplicitEvents } from '../types/events' +import { SendableEvents } from '../types/events' import { pluginActions } from './plugin-actions' -import { Destroy, Static } from './general' +import { Cycle, Eventless, Static } from './general' /** * Actually add an edge to the graph. When we buffer graph updates, we're @@ -20,13 +20,12 @@ export const addEdge = ( ) => { const { _mods, modBus } = dependency.e - // draw the edge in both nodes. Dependent may not exist if it's an external - // pseudo-node - dependent && dependent.s.set(dependency, newEdge) + // draw the edge in both nodes + dependent.s.set(dependency, newEdge) dependency.o.set(dependent, newEdge) dependency.c?.() - // static dependencies don't change a node's weight + // Static sources don't change a node's weight if (!(newEdge.flags & Static)) { recalculateNodeWeight(dependency.W, dependent) } @@ -47,13 +46,13 @@ export const addEdge = ( export const destroyNodeStart = (node: GraphNode, force?: boolean) => { // If we're not force-destroying, don't destroy if there are dependents. Also // don't destroy of `node.K`eep is set - if (node.l === 'Destroyed' || (!force && node.o.size)) return + if (node.l === 'Destroyed' || (!force && node.o.size - (node.L ? 1 : 0))) { + return + } node.c?.() node.c = undefined - setNodeStatus(node, 'Destroyed') - if (node.w.length) node.e._scheduler.unschedule(node) return true @@ -66,18 +65,6 @@ export const destroyNodeFinish = (node: GraphNode) => { removeEdge(node, dependency) } - // if an atom instance is force-destroyed, it could still have dependents. - // Inform them of the destruction - scheduleDependents( - { - r: node.w, - s: node, - t: Destroy, - }, - true, - true - ) - // now remove all edges between this node and its dependents for (const [observer, edge] of node.o) { if (!(edge.flags & Static)) { @@ -93,6 +80,8 @@ export const destroyNodeFinish = (node: GraphNode) => { } node.e.n.delete(node.id) + + setNodeStatus(node, 'Destroyed') } export const handleStateChange = < @@ -100,9 +89,10 @@ export const handleStateChange = < >( node: GraphNode, oldState: G['State'], - events?: Partial + events?: Partial> ) => { - scheduleDependents({ e: events, p: oldState, r: node.w, s: node }, false) + const pre = node.e._scheduler.pre() + scheduleDependents({ e: events, n: node.v, o: oldState, r: node.w, s: node }) if (node.e._mods.stateChanged) { node.e.modBus.dispatch( @@ -116,12 +106,10 @@ export const handleStateChange = < } // run the scheduler synchronously after any node state update - events?.batch || node.e._scheduler.flush() + node.e._scheduler.post(pre) } -const recalculateNodeWeight = (weightDiff: number, node?: GraphNode) => { - if (!node) return // happens when node is external - +const recalculateNodeWeight = (weightDiff: number, node: GraphNode) => { node.W += weightDiff for (const observer of node.o.keys()) { @@ -139,7 +127,7 @@ const recalculateNodeWeight = (weightDiff: number, node?: GraphNode) => { */ export const removeEdge = (dependent: GraphNode, dependency: GraphNode) => { // erase graph edge between dependent and dependency - dependent && dependent.s.delete(dependency) + dependent.s.delete(dependency) // hmm could maybe happen when a dependency was force-destroyed if a child // tries to destroy its edge before recreating it (I don't think we ever do @@ -168,28 +156,70 @@ export const removeEdge = (dependent: GraphNode, dependency: GraphNode) => { ) } - scheduleNodeDestruction(dependency) + if (dependent === dependency.L) { + dependency.L = undefined + } else { + scheduleNodeDestruction(dependency) + } } +/** + * Schedule all a node's dynamic, normal observers to run immediately. + * + * This should always be followed up by an `ecosystem._scheduler.flush()` call + * unless we know for sure the scheduler is already running (e.g. when + * `runSelector` is called and isn't initializing). + */ export const scheduleDependents = ( reason: Omit & { s: NonNullable - }, - defer?: boolean, - scheduleStaticDeps?: boolean + } ) => { for (const [observer, edge] of reason.s.o) { - // Static deps don't update on state change, only on promise change or node - // force-destruction - if (scheduleStaticDeps || !(edge.flags & Static)) observer.r(reason, defer) + edge.flags & Static || observer.r(reason, false) } } +/** + * Schedule jobs to notify a node's event-aware observers of one or more events. + * Event-aware observers include those added via `node.on()` and atom instances + * that forward their `S`ignal's events. + * + * This should always be followed up by an `ecosystem._scheduler.flush()` call + * unless we know for sure the scheduler is already running (e.g. when + * `runSelector` is called and isn't initializing). + */ +export const scheduleEventListeners = ( + reason: Omit & { + s: NonNullable + } +) => { + for (const [observer, edge] of reason.s.o) { + edge.flags & Eventless || observer.r(reason, false) + } +} + +/** + * Static deps don't update on state change, only on promise change or node + * force-destruction. This currently flushes the scheduler immediately. + */ +export const scheduleStaticDependents = ( + reason: Omit & { + s: NonNullable + } +) => { + for (const observer of reason.s.o.keys()) { + observer.r(reason, false) + } + + reason.s.e._scheduler.flush() +} + /** * When a node's refCount hits 0, schedule destruction of that node. */ export const scheduleNodeDestruction = (node: GraphNode) => - node.o.size || node.l !== 'Active' || node.m() + node.o.size - (node.L ? 1 : 0) || node.l !== 'Active' || node.m() export const setNodeStatus = (node: GraphNode, newStatus: LifecycleStatus) => { const oldStatus = node.l @@ -204,4 +234,30 @@ export const setNodeStatus = (node: GraphNode, newStatus: LifecycleStatus) => { }) ) } + + if (newStatus === 'Destroyed') { + // Event observers don't prevent destruction so may still need cleaning up. + // Schedule them so they can do so. Also if a node is force-destroyed, it + // could still have observers. Inform them of the destruction so they can + // recreate their source node. + scheduleStaticDependents({ + n: newStatus, + o: oldStatus, + r: node.w, + s: node, + t: Cycle, + }) + } else if (oldStatus !== 'Initializing') { + scheduleEventListeners({ + n: newStatus, + o: oldStatus, + r: node.w, + s: node, + t: Cycle, + }) + + // flushing here should be fine - `setNodeStatus` is only called during + // React component renders when `oldStatus` is Initializing + node.e._scheduler.flush() + } } diff --git a/packages/immer/src/injectImmerStore.ts b/packages/immer/src/injectImmerStore.ts index c01a8907..0e8d5267 100644 --- a/packages/immer/src/injectImmerStore.ts +++ b/packages/immer/src/injectImmerStore.ts @@ -24,7 +24,7 @@ const doSubscribe = ( return } - instance.r({ p: oldState }, false) + instance.r({ o: oldState }, false) // run the scheduler synchronously after any store update if (action?.meta !== zeduxTypes.batch) { diff --git a/packages/machines/src/injectMachineStore.ts b/packages/machines/src/injectMachineStore.ts index 818fb04d..9c7c8de2 100644 --- a/packages/machines/src/injectMachineStore.ts +++ b/packages/machines/src/injectMachineStore.ts @@ -284,7 +284,7 @@ export const injectMachineStore: < return } - instance.r({ p: oldState }, false) + instance.r({ o: oldState }, false) // run the scheduler synchronously after any store update if (action?.meta !== zeduxTypes.batch) { diff --git a/packages/react/src/hooks/useAtomInstance.ts b/packages/react/src/hooks/useAtomInstance.ts index 15bc56bd..2b171e07 100644 --- a/packages/react/src/hooks/useAtomInstance.ts +++ b/packages/react/src/hooks/useAtomInstance.ts @@ -9,12 +9,10 @@ import { } from '@zedux/atoms' import { Dispatch, SetStateAction, useEffect, useState } from 'react' import { ZeduxHookConfig } from '../types' -import { External, Static } from '../utils' +import { Eventless, EventlessStatic, External } from '../utils' import { useEcosystem } from './useEcosystem' import { useReactComponentId } from './useReactComponentId' -const OPERATION = 'useAtomInstance' - /** * Creates an atom instance for the passed atom template based on the passed * params. If an instance has already been created for the passed params, reuses @@ -65,9 +63,7 @@ export const useAtomInstance: { } = ( atom: A | AnyAtomInstance, params?: ParamsOf, - { operation = OPERATION, subscribe, suspend }: ZeduxHookConfig = { - operation: OPERATION, - } + { operation = 'useAtomInstance', subscribe, suspend }: ZeduxHookConfig = {} ) => { const ecosystem = useEcosystem() const observerId = useReactComponentId() @@ -81,7 +77,7 @@ export const useAtomInstance: { // It should be fine for this to run every render. It's possible to change // approaches if it is too heavy sometimes. But don't memoize this call: - const instance: AtomInstance = ecosystem.getNode(atom, params) + let instance: AtomInstance = ecosystem.getNode(atom, params) const renderedValue = instance.v let node = @@ -92,7 +88,11 @@ export const useAtomInstance: { node.l === 'Destroyed' && (node = new ExternalNode(ecosystem, observerId, render)) node.i === instance || - node.u(instance, operation, External | (subscribe ? 0 : Static)) + node.u( + instance, + operation, + External | (subscribe ? Eventless : EventlessStatic) + ) } // Yes, subscribe during render. This operation is idempotent and we handle @@ -102,6 +102,8 @@ export const useAtomInstance: { // Only remove the graph edge when the instance id changes or on component // destruction. useEffect(() => { + instance = ecosystem.getNode(atom, params) + // Try adding the edge again (will be a no-op unless React's StrictMode ran // this effect's cleanup unnecessarily) addEdge() diff --git a/packages/react/src/hooks/useAtomSelector.ts b/packages/react/src/hooks/useAtomSelector.ts index dac0970e..b6edca78 100644 --- a/packages/react/src/hooks/useAtomSelector.ts +++ b/packages/react/src/hooks/useAtomSelector.ts @@ -6,12 +6,10 @@ import { StateOf, } from '@zedux/atoms' import { Dispatch, SetStateAction, useEffect, useState } from 'react' -import { External } from '../utils' +import { Eventless, External } from '../utils' import { useEcosystem } from './useEcosystem' import { useReactComponentId } from './useReactComponentId' -const OPERATION = 'useAtomSelector' - /** * Get the result of running a selector in the current ecosystem. * @@ -38,7 +36,7 @@ export const useAtomSelector = ( } ] - const instance: SelectorInstance = render.i + let instance: SelectorInstance = render.i ? ecosystem.u(render.i, template, args, render) : ecosystem.getNode(template, args) @@ -52,13 +50,17 @@ export const useAtomSelector = ( const addEdge = () => { node.l === 'Destroyed' && (node = new ExternalNode(ecosystem, observerId, render)) - node.i === instance || node.u(instance, OPERATION, External) + node.i === instance || + node.u(instance, 'useAtomSelector', Eventless | External) } // Yes, subscribe during render. This operation is idempotent. addEdge() useEffect(() => { + instance = ecosystem.getNode(template, args) + render.i = instance + // Try adding the edge again (will be a no-op unless React's StrictMode ran // this effect's cleanup unnecessarily) addEdge() diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts index 63766bf0..9d314cab 100644 --- a/packages/react/src/utils.ts +++ b/packages/react/src/utils.ts @@ -8,10 +8,14 @@ export const ecosystemContext = createContext('@@global') * * IMPORTANT: keep these in-sync with the ones in the atoms package */ -export const Explicit = 1 -export const External = 2 -export const Static = 4 -// export const Deferred = 8 +export const TopPrio = 0 +export const Eventless = 1 +export const Explicit = 2 +export const External = 4 +export const Static = 8 +export const OutOfRange = 16 // not a flag; use a value bigger than any flag +export const ExplicitExternal = Explicit | External +export const EventlessStatic = Eventless | Static export const destroyed = Symbol.for(`@@zedux/destroyed`) diff --git a/packages/react/test/__snapshots__/types.test.tsx.snap b/packages/react/test/__snapshots__/types.test.tsx.snap index db68d4b4..c5d7f11c 100644 --- a/packages/react/test/__snapshots__/types.test.tsx.snap +++ b/packages/react/test/__snapshots__/types.test.tsx.snap @@ -6,61 +6,27 @@ exports[`react types signals 1`] = ` "className": "Signal", "observers": { "no-1": { - "flags": 3, - "operation": "on", - }, - "no-2": { - "flags": 3, - "operation": "on", - }, - "no-3": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "sources": {}, "state": 1, - "status": "Initializing", + "status": "Active", "weight": 1, }, "no-1": { - "className": "ExternalNode", - "observers": {}, - "sources": { - "@signal-0": { - "flags": 3, - "operation": "on", - }, - }, - "state": undefined, - "status": "Active", - "weight": 3, - }, - "no-2": { - "className": "ExternalNode", - "observers": {}, - "sources": { - "@signal-0": { - "flags": 3, - "operation": "on", - }, - }, - "state": undefined, - "status": "Active", - "weight": 4, - }, - "no-3": { - "className": "ExternalNode", + "className": "Listener", "observers": {}, "sources": { "@signal-0": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "state": undefined, "status": "Active", - "weight": 5, + "weight": 2, }, } `; diff --git a/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap b/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap index bec43d4f..311744ec 100644 --- a/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap +++ b/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap @@ -6,13 +6,13 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "AtomInstance", "observers": { "no-1": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "sources": { "@@selector-common-name-0": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, @@ -25,14 +25,14 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "SelectorInstance", "observers": { "1": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, }, "sources": { "root": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -42,23 +42,23 @@ exports[`selection same-name selectors share the namespace when destroyed and re "weight": 2, }, "no-1": { - "className": "ExternalNode", + "className": "Listener", "observers": {}, "sources": { "1": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "state": undefined, "status": "Active", - "weight": 5, + "weight": 4, }, "root": { "className": "AtomInstance", "observers": { "@@selector-common-name-0": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -77,13 +77,13 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "AtomInstance", "observers": { "no-3": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "sources": { "@@selector-common-name-2": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, @@ -96,14 +96,14 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "SelectorInstance", "observers": { "2": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, }, "sources": { "root": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -113,23 +113,23 @@ exports[`selection same-name selectors share the namespace when destroyed and re "weight": 2, }, "no-3": { - "className": "ExternalNode", + "className": "Listener", "observers": {}, "sources": { "2": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "state": undefined, "status": "Active", - "weight": 7, + "weight": 4, }, "root": { "className": "AtomInstance", "observers": { "@@selector-common-name-2": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -148,13 +148,13 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "AtomInstance", "observers": { "no-4": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "sources": { "@@selector-common-name-0": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, @@ -167,13 +167,13 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "AtomInstance", "observers": { "no-3": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "sources": { "@@selector-common-name-2": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, @@ -186,14 +186,14 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "SelectorInstance", "observers": { "1": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, }, "sources": { "root": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -206,14 +206,14 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "SelectorInstance", "observers": { "2": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, }, "sources": { "root": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -223,41 +223,41 @@ exports[`selection same-name selectors share the namespace when destroyed and re "weight": 2, }, "no-3": { - "className": "ExternalNode", + "className": "Listener", "observers": {}, "sources": { "2": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "state": undefined, "status": "Active", - "weight": 7, + "weight": 4, }, "no-4": { - "className": "ExternalNode", + "className": "Listener", "observers": {}, "sources": { "1": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "state": undefined, "status": "Active", - "weight": 8, + "weight": 4, }, "root": { "className": "AtomInstance", "observers": { "@@selector-common-name-0": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, "@@selector-common-name-2": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -276,13 +276,13 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "AtomInstance", "observers": { "no-4": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "sources": { "@@selector-common-name-0": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, @@ -295,14 +295,14 @@ exports[`selection same-name selectors share the namespace when destroyed and re "className": "SelectorInstance", "observers": { "1": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, }, "sources": { "root": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -312,23 +312,23 @@ exports[`selection same-name selectors share the namespace when destroyed and re "weight": 2, }, "no-4": { - "className": "ExternalNode", + "className": "Listener", "observers": {}, "sources": { "1": { - "flags": 3, + "flags": 6, "operation": "on", }, }, "state": undefined, "status": "Active", - "weight": 8, + "weight": 4, }, "root": { "className": "AtomInstance", "observers": { "@@selector-common-name-0": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, diff --git a/packages/react/test/integrations/events.test.tsx b/packages/react/test/integrations/events.test.tsx new file mode 100644 index 00000000..54aa5bf3 --- /dev/null +++ b/packages/react/test/integrations/events.test.tsx @@ -0,0 +1,256 @@ +import { + api, + atom, + ChangeEvent, + CycleEvent, + EvaluationReason, + GenericsOf, + InvalidateEvent, + PromiseChangeEvent, +} from '@zedux/atoms' +import { useAtomValue } from '@zedux/react' +import { expectTypeOf } from 'expect-type' +import React from 'react' +import { ecosystem } from '../utils/ecosystem' +import { renderInEcosystem } from '../utils/renderInEcosystem' + +describe('events', () => { + test("event observers don't prevent node destruction", async () => { + jest.useFakeTimers() + const atom1 = atom('1', 'a', { ttl: 2 }) + const node1 = ecosystem.getNode(atom1) + + function Test() { + const state = useAtomValue(atom1) + + return
{state}
+ } + + const { findByTestId, unmount } = renderInEcosystem() + + const div = await findByTestId('test') + const calls: EvaluationReason[] = [] + + expect(div.innerHTML).toBe('a') + + const cleanup = node1.on('cycle', event => { + calls.push(event) + }) + + unmount() + + jest.advanceTimersByTime(1) + + const expectedStaleEvent: CycleEvent = { + oldStatus: 'Active', + newStatus: 'Stale', + operation: 'on', + reasons: [], + source: node1, + type: 'cycle', + } + + expect(calls).toEqual([expectedStaleEvent]) + expect(node1.L).toBeDefined() + + jest.advanceTimersByTime(2) + + const expectedDestroyedEvent: CycleEvent = { + oldStatus: 'Stale', + newStatus: 'Destroyed', + // the edge is destroyed before the event is created. TODO: maybe fix + operation: undefined, + reasons: [], + source: node1, + type: 'cycle', + } + + expect(calls).toEqual([expectedStaleEvent, expectedDestroyedEvent]) + expect(node1.L).toBe(undefined) + expect(node1.o.size).toBe(0) + + cleanup() // noop + + expect(node1.L).toBe(undefined) + expect(node1.o.size).toBe(0) + }) + + test("event observers don't cause node destruction by default", () => { + const atom1 = atom('1', 'a', { ttl: 0 }) + const node1 = ecosystem.getNode(atom1) + + const cleanup = node1.on('cycle', () => {}) + + cleanup() + + expect(node1.l).toBe('Active') + + const cleanup2 = node1.on('cycle', () => {}, { active: true }) + + cleanup2() + + expect(node1.l).toBe('Destroyed') + }) + + test('cycle event', () => { + const atom1 = atom('1', 'a') + const atom2 = atom('2', 'b') + const node1 = ecosystem.getNode(atom1) + const node2 = ecosystem.getNode(atom2) + const calls: any[] = [] + + node1.on('cycle', event => { + expectTypeOf(event).toEqualTypeOf>>() + + calls.push([event.source?.id, event.oldStatus, event.newStatus]) + }) + + node2.on('cycle', event => { + calls.push([event.source?.id, event.oldStatus, event.newStatus]) + }) + + node1.on(() => {}, { active: true })() // add and remove observer + + expect(calls).toEqual([['1', 'Active', 'Stale']]) + + node2.destroy() + + expect(calls).toEqual([ + ['1', 'Active', 'Stale'], + ['2', 'Active', 'Destroyed'], + ]) + + expect(node1.o.size).toBe(1) + expect(node2.o.size).toBe(0) // listener was auto-cleaned up + }) + + test('invalidate event', () => { + let counter = 0 + const atom1 = atom('1', () => counter++) + const node1 = ecosystem.getNode(atom1) + + const calls: any[] = [] + + node1.on('invalidate', (event, eventMap) => { + expectTypeOf(event).toEqualTypeOf< + InvalidateEvent> + >() + calls.push([event, eventMap]) + }) + + node1.on(event => { + expectTypeOf(event.invalidate).toEqualTypeOf< + InvalidateEvent> | undefined + >() + calls.push([event]) + }) + + expect(node1.get()).toBe(0) + node1.invalidate() + expect(node1.get()).toBe(1) + + const expectedInvalidateEvent: InvalidateEvent = { + operation: 'on', + reasons: undefined, + source: node1, + type: 'invalidate', + } + + const expectedChangeEvent: ChangeEvent = { + newState: 1, + oldState: 0, + operation: 'on', + reasons: [ + { + ...expectedInvalidateEvent, + operation: undefined, + }, + ], + source: node1, + type: 'change', + } + + expect(calls).toEqual([ + [expectedInvalidateEvent, { invalidate: expectedInvalidateEvent }], + [{ invalidate: expectedInvalidateEvent }], + [{ change: expectedChangeEvent }], + ]) + }) + + test('promiseChange event', () => { + let counter = 0 + const atom1 = atom('1', () => api(Promise.resolve(counter++))) + const node1 = ecosystem.getNode(atom1) + + const calls: any[] = [] + + node1.on('promiseChange', (event, eventMap) => { + expectTypeOf(event).toEqualTypeOf< + PromiseChangeEvent> + >() + calls.push([event, eventMap]) + }) + + node1.on(event => { + expectTypeOf(event.promiseChange).toEqualTypeOf< + PromiseChangeEvent> | undefined + >() + calls.push([event]) + }) + + node1.invalidate() + + const expectedInvalidateEvent: InvalidateEvent = { + operation: 'on', + reasons: undefined, + source: node1, + type: 'invalidate', + } + + const expectedPromiseChangeEvent: PromiseChangeEvent = { + operation: 'on', + reasons: [ + { + ...expectedInvalidateEvent, + operation: undefined, + }, + ], + source: node1, + type: 'promiseChange', + } + + // Zedux will set this to a new object reference on promise change, even + // though the new object deep-equals the old object. + const expectedState = { + data: undefined, + isError: false, + isLoading: true, + isSuccess: false, + status: 'loading', + } + + const expectedChangeEvent: ChangeEvent = { + newState: expectedState, + oldState: expectedState, + operation: 'on', + reasons: [ + { + ...expectedInvalidateEvent, + operation: undefined, + }, + ], + source: node1, + type: 'change', + } + + expect(calls).toEqual([ + [ + expectedPromiseChangeEvent, + { promiseChange: expectedPromiseChangeEvent }, + ], + [{ invalidate: expectedInvalidateEvent }], + [{ promiseChange: expectedPromiseChangeEvent }], + [{ change: expectedChangeEvent }], + ]) + }) +}) diff --git a/packages/react/test/integrations/lifecycle.test.tsx b/packages/react/test/integrations/lifecycle.test.tsx index 4fa5db2b..2fee30b9 100644 --- a/packages/react/test/integrations/lifecycle.test.tsx +++ b/packages/react/test/integrations/lifecycle.test.tsx @@ -171,7 +171,7 @@ describe('ttl', () => { expect(instance1.l).toBe('Active') - instance1.on(() => {})() // add dependent and immediately clean it up + instance1.on(() => {}, { active: true })() // add dependent and immediately clean it up expect(instance1.l).toBe('Stale') @@ -197,12 +197,12 @@ describe('ttl', () => { expect(instance1.l).toBe('Active') - instance1.on(() => {})() // add dependent and immediately clean it up + instance1.on(() => {}, { active: true })() // add dependent and immediately clean it up expect(instance1.l).toBe('Stale') jest.runAllTimers() - const cleanup = instance1.on(() => {}) + const cleanup = instance1.on(() => {}, { active: true }) expect(instance1.l).toBe('Active') @@ -231,7 +231,7 @@ describe('ttl', () => { expect(instance1.l).toBe('Active') - instance1.on(() => {})() // add dependent and immediately clean it up + instance1.on(() => {}, { active: true })() // add dependent and immediately clean it up expect(instance1.l).toBe('Stale') @@ -249,12 +249,12 @@ describe('ttl', () => { expect(instance1.l).toBe('Active') - instance1.on(() => {})() // add dependent and immediately clean it up + instance1.on(() => {}, { active: true })() // add dependent and immediately clean it up expect(instance1.l).toBe('Stale') jest.advanceTimersByTime(1) - const cleanup = instance1.on(() => {}) + const cleanup = instance1.on(() => {}, { active: true }) expect(instance1.l).toBe('Active') @@ -272,7 +272,7 @@ describe('ttl', () => { const atom1 = atom('1', () => 'a') const instance1 = testEcosystem.getInstance(atom1) - const cleanup = instance1.on(() => {}) + const cleanup = instance1.on(() => {}, { active: true }) const keys = [...testEcosystem.n.keys()] expect(keys).toHaveLength(2) diff --git a/packages/react/test/integrations/mapped-signals.test.tsx b/packages/react/test/integrations/mapped-signals.test.tsx index 05486023..c6e93567 100644 --- a/packages/react/test/integrations/mapped-signals.test.tsx +++ b/packages/react/test/integrations/mapped-signals.test.tsx @@ -1,6 +1,8 @@ import { api, + As, atom, + EventsOf, injectMappedSignal, injectSignal, ion, @@ -9,6 +11,56 @@ import { import { ecosystem } from '../utils/ecosystem' import { expectTypeOf } from 'expect-type' +const setupNestedSignals = () => { + const atom1 = atom('1', () => { + const signalA = injectSignal({ aa: 1 }) + const signalB = injectSignal(2) + const signal = injectMappedSignal({ a: signalA, b: signalB }) + + return api(signal).setExports({ signal, signalA, signalB }) + }) + + const node1 = ecosystem.getNode(atom1) + + expect(node1.get()).toEqual({ a: { aa: 1 }, b: 2 }) + + const calls: any[] = [] + + node1.on('mutate', transactions => { + calls.push(['atom mutate', transactions]) + }) + + node1.on('change', event => { + calls.push(['atom change', event.newState]) + }) + + node1.exports.signal.on('mutate', transactions => { + calls.push(['mapped signal mutate', transactions]) + }) + + node1.exports.signal.on('change', event => { + calls.push(['mapped signal change', event.newState]) + }) + + node1.exports.signalA.on('mutate', transactions => { + calls.push(['inner signal a mutate', transactions]) + }) + + node1.exports.signalA.on('change', event => { + calls.push(['inner signal a change', event.newState]) + }) + + node1.exports.signalB.on('mutate', transactions => { + calls.push(['inner signal b mutate', transactions]) + }) + + node1.exports.signalB.on('change', event => { + calls.push(['inner signal b change', event.newState]) + }) + + return { calls, node1 } +} + describe('mapped signals', () => { test('forward state updates to inner signals', () => { const values: string[] = [] @@ -110,6 +162,11 @@ describe('mapped signals', () => { b: signalB, }) + expectTypeOf>().toEqualTypeOf<{ + a: string + b: string + }>() + return signal }) @@ -121,22 +178,285 @@ describe('mapped signals', () => { expect(node1.get()).toEqual({ a: 'aa', b: 'b' }) }) -}) -// const signalA = injectSignal('a', { -// events: { eventA: As, eventC: As }, -// }) -// const signalB = injectSignal('b', { -// events: { eventA: As, eventB: As<1 | 2> }, -// }) + test('forward events from/to inner signals', () => { + const atom1 = atom('1', () => { + const signalA = injectSignal('a', { + events: { + a1: As, + a2: As, + }, + }) + + const signalB = injectSignal('b', { + events: { + b1: As, + }, + }) + + const signal = injectMappedSignal({ a: signalA, b: signalB }) + + return api(signal).setExports({ signal, signalA, signalB }) + }) + + const node1 = ecosystem.getNode(atom1) + const calls: any[] = [] + + node1.exports.signalA.on(eventMap => { + calls.push(eventMap) + }) + + node1.exports.signalB.on(eventMap => { + calls.push(eventMap) + }) + + node1.exports.signal.on('a1', (_, eventMap) => { + calls.push(eventMap) + }) + + node1.exports.signal.on('b1', (_, eventMap) => { + calls.push(eventMap) + }) + + node1.on(eventMap => { + calls.push(eventMap) + }) + + node1.send('a1', 'a') + + const expectedA1Event = { a1: 'a' } + + expect(calls).toEqual([expectedA1Event, expectedA1Event, expectedA1Event]) + calls.splice(0, 3) + + node1.exports.signalB.send('b1', 2) + + const expectedB1Event = { b1: 2 } + + expect(calls).toEqual([expectedB1Event, expectedB1Event, expectedB1Event]) + + expectTypeOf>().toEqualTypeOf<{ + a1: string + a2: boolean + b1: number + }>() + }) + + test('updates inner signal references when they change on subsequent reevaluations', () => { + const atom1 = atom('1', 'a') + const atom2 = ion('2', ({ getNode }) => { + const node1 = getNode(atom1) + const signal = injectMappedSignal({ a: node1 }) + + return api(signal).setExports({ signal }) + }) + + const node1 = ecosystem.getNode(atom1) + const node2 = ecosystem.getNode(atom2) + + expect(node2.exports.signal.M.a).toBe(node1) + + node1.destroy(true) + + expect(node2.exports.signal.M.a).not.toBe(node1) + expect(node2.exports.signal.M.a).toBe(ecosystem.getNode(atom1)) + }) + + test('mutating outer signal does not send mutate events to inner signals', () => { + const { calls, node1 } = setupNestedSignals() + + node1.exports.signal.mutate(state => { + state.a.aa = 11 + }) + + expect(node1.get()).toEqual({ a: { aa: 11 }, b: 2 }) + + node1.exports.signal.mutate(state => { + state.a.aa = 1 + }) + + const expectedTransactions1 = [{ k: ['a', 'aa'], v: 11 }] + const expectedTransactions2 = [{ k: ['a', 'aa'], v: 1 }] + const expectedSequence = [ + ['inner signal a change', { aa: 11 }], + ['mapped signal mutate', expectedTransactions1], + ['mapped signal change', { a: { aa: 11 }, b: 2 }], + ['atom mutate', expectedTransactions1], + ['atom change', { a: { aa: 11 }, b: 2 }], + ['inner signal a change', { aa: 1 }], + ['mapped signal mutate', expectedTransactions2], + ['mapped signal change', { a: { aa: 1 }, b: 2 }], + ['atom mutate', expectedTransactions2], + ['atom change', { a: { aa: 1 }, b: 2 }], + ] + + expect(node1.get()).toEqual({ a: { aa: 1 }, b: 2 }) + expect(calls).toEqual(expectedSequence) + calls.splice(0, calls.length) + + node1.mutate(state => { + state.a.aa = 11 + }) + + node1.mutate(state => { + state.a.aa = 1 + }) + + expect(node1.get()).toEqual({ a: { aa: 1 }, b: 2 }) + expect(calls).toEqual(expectedSequence) + }) + + test('mutating inner signals sends wrapped mutate events to outer signal', () => { + const { calls, node1 } = setupNestedSignals() + + node1.exports.signalA.mutate(state => { + state.aa = 11 + }) + + const expectedTransactions1 = [{ k: ['a', 'aa'], v: 11 }] + const expectedSequence = [ + ['inner signal a mutate', [{ k: 'aa', v: 11 }]], + ['inner signal a change', { aa: 11 }], + ['mapped signal mutate', expectedTransactions1], + ['mapped signal change', { a: { aa: 11 }, b: 2 }], + ['atom mutate', expectedTransactions1], + ['atom change', { a: { aa: 11 }, b: 2 }], + ] + + expect(node1.get()).toEqual({ a: { aa: 11 }, b: 2 }) + expect(calls).toEqual(expectedSequence) + }) + + test('mixed types', () => { + const atom1 = atom('1', 'atom') + const atom2 = ion('2', ({ getNode }) => { + const node1 = getNode(atom1) + const innerSignal = injectSignal( + { aa: 1 }, + { events: { eventA: As } } + ) + const nestedSignal = injectMappedSignal({ bb: innerSignal }) + + const signal = injectMappedSignal({ + a: innerSignal, + b: nestedSignal, + c: node1, + d: 'string', + e: 2, + }) -// const result = injectMappedSignal({ -// a: signalA, -// b: signalB, -// }) + return api(signal).setExports({ innerSignal, nestedSignal, signal }) + }) + + const node1 = ecosystem.getNode(atom1) + const node2 = ecosystem.getNode(atom2) + const calls: any[] = [] + + expectTypeOf>().toEqualTypeOf<{ + a: { + aa: number + } + b: { + bb: { + aa: number + } + } + c: string + d: string + e: number + }>() + + expect(node2.get()).toEqual({ + a: { + aa: 1, + }, + b: { + bb: { + aa: 1, + }, + }, + c: 'atom', + d: 'string', + e: 2, + }) + + node1.on('change', event => calls.push(['atom1 change', event.newState])) + node2.on('change', event => + calls.push(['atom2 change', event.newState.b.bb.aa]) + ) + node2.exports.innerSignal.on('change', event => + calls.push(['innerSignal change', event.newState.aa]) + ) + node2.exports.nestedSignal.on('change', event => + calls.push(['nestedSignal change', event.newState.bb.aa]) + ) + node2.exports.signal.on('change', event => + calls.push(['signal change', event.newState.a.aa]) + ) + + node2.set(state => ({ + ...state, + a: { + ...state.a, + aa: 11, + }, + c: 'atom 2', + e: 3, + })) + + expect(node2.get()).toEqual({ + a: { + aa: 11, + }, + b: { + bb: { + aa: 11, + }, + }, + c: 'atom 2', + d: 'string', + e: 3, + }) -// type TEvents = EventsOf -// type Tuple = UnionToTuple> -// type TEvents4 = TupleToEvents + expect(node1.get()).toBe('atom 2') -// result.on('eventB', (test, map) => 2) + expect(calls).toEqual([ + ['atom1 change', 'atom 2'], + ['innerSignal change', 11], + ['nestedSignal change', 11], + ['signal change', 11], + ['atom2 change', 11], + ]) + calls.splice(0, calls.length) + + node2.mutate(state => { + state.b.bb.aa = 111 + state.c = 'atom 3' + state.d = 'string 2' + }) + + expect(node2.get()).toEqual({ + a: { + aa: 111, + }, + b: { + bb: { + aa: 111, + }, + }, + c: 'atom 3', + d: 'string 2', + e: 3, + }) + + expect(node1.get()).toBe('atom 3') + + expect(calls).toEqual([ + ['atom1 change', 'atom 3'], + ['innerSignal change', 111], + ['nestedSignal change', 111], + ['signal change', 111], + ['atom2 change', 111], + ]) + }) +}) diff --git a/packages/react/test/integrations/plugins.test.tsx b/packages/react/test/integrations/plugins.test.tsx index 3a242bf5..112b49b7 100644 --- a/packages/react/test/integrations/plugins.test.tsx +++ b/packages/react/test/integrations/plugins.test.tsx @@ -290,50 +290,31 @@ describe('plugins', () => { 'edgeCreated', // edge "moved" ]) + events.splice(0, 7) + instance2.destroy() expect(events).toEqual([ - 'subscribe', - 'statusChanged', - 'statusChanged', - 'edgeCreated', - 'statusChanged', - 'statusChanged', - 'edgeCreated', - 'statusChanged', // instance2 destroyed 'edgeRemoved', + 'statusChanged', // instance2 destroyed 'statusChanged', // instance1 becomes Stale 'cause it has no dependents ]) testEcosystem.unregisterPlugin(plugin) expect(events).toEqual([ - 'subscribe', - 'statusChanged', - 'statusChanged', - 'edgeCreated', - 'statusChanged', - 'statusChanged', - 'edgeCreated', - 'statusChanged', 'edgeRemoved', 'statusChanged', + 'statusChanged', 'unsubscribe', ]) testEcosystem.destroy() expect(events).toEqual([ - 'subscribe', - 'statusChanged', - 'statusChanged', - 'edgeCreated', - 'statusChanged', - 'statusChanged', - 'edgeCreated', - 'statusChanged', 'edgeRemoved', 'statusChanged', + 'statusChanged', 'unsubscribe', ]) }) diff --git a/packages/react/test/integrations/selection.test.tsx b/packages/react/test/integrations/selection.test.tsx index 5ae4b642..f3e2e4cd 100644 --- a/packages/react/test/integrations/selection.test.tsx +++ b/packages/react/test/integrations/selection.test.tsx @@ -274,7 +274,7 @@ describe('selection', () => { Object.defineProperty(selector2, 'name', { value: NAME }) const instance1 = ecosystem.getInstance(atom1) - const cleanup1 = instance1.on(() => {}) + const cleanup1 = instance1.on(() => {}, { active: true }) snapshotNodes() expect(instance1.get()).toBe(3) @@ -284,13 +284,13 @@ describe('selection', () => { expect(ecosystem.n).toEqual(new Map()) const instance2 = ecosystem.getInstance(atom2) - const cleanup2 = instance2.on(() => {}) + const cleanup2 = instance2.on(() => {}, { active: true }) snapshotNodes() expect(instance2.get()).toBe(4) const instance3 = ecosystem.getInstance(atom1) - const cleanup3 = instance3.on(() => {}) + const cleanup3 = instance3.on(() => {}, { active: true }) snapshotNodes() expect(instance1.get()).toBe(3) diff --git a/packages/react/test/integrations/signals.test.tsx b/packages/react/test/integrations/signals.test.tsx index 53ade8ee..bb039117 100644 --- a/packages/react/test/integrations/signals.test.tsx +++ b/packages/react/test/integrations/signals.test.tsx @@ -91,14 +91,14 @@ describe('signals', () => { expect(transactionsList).toEqual([[{ k: ['b', '0', 'c'], v: 3 }]]) }) - test('mixed mutations and sets all notify subscribers', () => { + test('mutations and sets on an injected signal notify direct and transitive observers', () => { const instance1 = ecosystem.getNode(atom1) const calls: any[][] = [] instance1.exports.signal.on('mutate', (transactions, eventMap) => { expectTypeOf(transactions).toEqualTypeOf() - calls.push(['mutate', transactions, eventMap]) + calls.push(['direct mutate', transactions, eventMap]) }) type GenericsOf = T extends Signal ? G : never @@ -113,7 +113,11 @@ describe('signals', () => { instance1.exports.signal.on('change', (change, eventMap) => { expectTypeOf(change).toEqualTypeOf() - calls.push(['change', change, eventMap]) + calls.push(['direct change', change, eventMap]) + }) + + instance1.on('change', (change, eventMap) => { + calls.push(['transitive change', change, eventMap]) }) instance1.exports.signal.mutate(state => { @@ -140,11 +144,30 @@ describe('signals', () => { ], } + const expectedTransitiveEvents = { + ...expectedEvents, + change: { + ...expectedEvents.change, + reasons: [ + { + ...expectedEvents.change, + operation: 'injectSignal', + }, + ], + source: instance1, + }, + } + expect(calls).toEqual([ - ['mutate', expectedEvents.mutate, expectedEvents], - ['change', expectedEvents.change, expectedEvents], + ['direct mutate', expectedEvents.mutate, expectedEvents], + ['direct change', expectedEvents.change, expectedEvents], + [ + 'transitive change', + expectedTransitiveEvents.change, + expectedTransitiveEvents, + ], ]) - calls.splice(0, 2) + calls.splice(0, 3) instance1.exports.signal.set(state => ({ ...state, a: state.a + 1 })) @@ -156,7 +179,27 @@ describe('signals', () => { }, } - expect(calls).toEqual([['change', expectedEvents2.change, expectedEvents2]]) + const expectedTransitiveEvents2 = { + change: { + ...expectedEvents2.change, + reasons: [ + { + ...expectedEvents2.change, + operation: 'injectSignal', + }, + ], + source: instance1, + }, + } + + expect(calls).toEqual([ + ['direct change', expectedEvents2.change, expectedEvents2], + [ + 'transitive change', + expectedTransitiveEvents2.change, + expectedTransitiveEvents2, + ], + ]) }) test("a non-reactively-injected signal still updates the atom's value", () => { diff --git a/packages/react/test/legacy-types.test.tsx b/packages/react/test/legacy-types.test.tsx index fbc63e69..ba2eaefa 100644 --- a/packages/react/test/legacy-types.test.tsx +++ b/packages/react/test/legacy-types.test.tsx @@ -464,9 +464,6 @@ describe('react types', () => { const outerAtom = atom( 'outer', (instance: AtomInstance>) => { - // TODO: this may just start working when switching all the long private - // member vars of the non-legacy AtomInstance class to smaller - // identifiers. Follow up. const val = injectAtomValue(instance) // subscribe to updates return val.toUpperCase() diff --git a/packages/react/test/stores/__snapshots__/graph.test.tsx.snap b/packages/react/test/stores/__snapshots__/graph.test.tsx.snap index f304ad54..c3aafb4c 100644 --- a/packages/react/test/stores/__snapshots__/graph.test.tsx.snap +++ b/packages/react/test/stores/__snapshots__/graph.test.tsx.snap @@ -6,7 +6,7 @@ exports[`graph getInstance(atom) returns the instance 1`] = ` "className": "AtomInstance", "observers": { "ion1": { - "flags": 4, + "flags": 9, "operation": "getNode", "p": undefined, }, @@ -20,7 +20,7 @@ exports[`graph getInstance(atom) returns the instance 1`] = ` "className": "AtomInstance", "observers": { "ion1": { - "flags": 4, + "flags": 9, "operation": "getNode", "p": undefined, }, @@ -35,12 +35,12 @@ exports[`graph getInstance(atom) returns the instance 1`] = ` "observers": {}, "sources": { "atom1": { - "flags": 4, + "flags": 9, "operation": "getNode", "p": undefined, }, "atom2": { - "flags": 4, + "flags": 9, "operation": "getNode", "p": undefined, }, @@ -62,7 +62,7 @@ exports[`graph injectAtomGetters 1`] = ` }, ], "dependents": [], - "weight": 3, + "weight": 4, }, "Test-:r1:": { "dependencies": [ @@ -72,7 +72,7 @@ exports[`graph injectAtomGetters 1`] = ` }, ], "dependents": [], - "weight": 0, + "weight": 1, }, "atom1": { "dependencies": [], @@ -171,7 +171,7 @@ exports[`graph on reevaluation, get() updates the graph 1`] = ` "className": "AtomInstance", "observers": { "d": { - "flags": 0, + "flags": 1, "operation": "injectAtomValue", "p": undefined, }, @@ -185,7 +185,7 @@ exports[`graph on reevaluation, get() updates the graph 1`] = ` "className": "AtomInstance", "observers": { "d": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -200,12 +200,12 @@ exports[`graph on reevaluation, get() updates the graph 1`] = ` "observers": {}, "sources": { "a": { - "flags": 0, + "flags": 1, "operation": "injectAtomValue", "p": undefined, }, "b-["b"]": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -223,7 +223,7 @@ exports[`graph on reevaluation, get() updates the graph 2`] = ` "className": "AtomInstance", "observers": { "d": { - "flags": 0, + "flags": 1, "operation": "injectAtomValue", "p": undefined, }, @@ -245,7 +245,7 @@ exports[`graph on reevaluation, get() updates the graph 2`] = ` "className": "AtomInstance", "observers": { "d": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -260,12 +260,12 @@ exports[`graph on reevaluation, get() updates the graph 2`] = ` "observers": {}, "sources": { "a": { - "flags": 0, + "flags": 1, "operation": "injectAtomValue", "p": undefined, }, "c": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, diff --git a/packages/react/test/stores/graph.test.tsx b/packages/react/test/stores/graph.test.tsx index 11e9a909..88b19530 100644 --- a/packages/react/test/stores/graph.test.tsx +++ b/packages/react/test/stores/graph.test.tsx @@ -15,6 +15,7 @@ import { api, atom, AtomInstanceType, injectStore, ion } from '@zedux/stores' import React from 'react' import { ecosystem, getNodes, snapshotNodes } from '../utils/ecosystem' import { renderInEcosystem } from '../utils/renderInEcosystem' +import { Eventless } from '@zedux/react/utils' const atom1 = atom('atom1', () => 1) const atom2 = atom('atom2', () => 2) @@ -61,7 +62,7 @@ describe('graph', () => { expect(div).toHaveTextContent('3') const expectedEdges = { - flags: 0, + flags: Eventless, operation: 'get', } diff --git a/packages/react/test/stores/injectors.test.tsx b/packages/react/test/stores/injectors.test.tsx index 56bdb3e6..cce668fd 100644 --- a/packages/react/test/stores/injectors.test.tsx +++ b/packages/react/test/stores/injectors.test.tsx @@ -257,7 +257,7 @@ describe('injectors', () => { [], [ { - newState: undefined, // TODO: this will be defined again when atoms use signals + newState: 'b', oldState: 'a', operation: undefined, reasons: undefined, @@ -267,7 +267,7 @@ describe('injectors', () => { ], [ { - newState: undefined, // TODO: this will be defined again when atoms use signals + newState: 'b', oldState: 'a', operation: undefined, reasons: undefined, diff --git a/packages/react/test/types.test.tsx b/packages/react/test/types.test.tsx index fc35affe..844deab9 100644 --- a/packages/react/test/types.test.tsx +++ b/packages/react/test/types.test.tsx @@ -33,7 +33,9 @@ import { None, Transaction, ChangeEvent, - AnyNodeGenerics, + CycleEvent, + InvalidateEvent, + PromiseChangeEvent, } from '@zedux/react' import { expectTypeOf } from 'expect-type' import { ecosystem, snapshotNodes } from './utils/ecosystem' @@ -760,12 +762,21 @@ describe('react types', () => { }, }) + type Generics = { + Events: EventsOf + State: StateOf + Params: undefined + Template: undefined + } + type TestListenableEvents = Partial<{ a: number b: undefined - batch: boolean mutate: Transaction[] - change: ChangeEvent> + change: ChangeEvent + cycle: CycleEvent + invalidate: InvalidateEvent + promiseChange: PromiseChangeEvent }> const calls: any[] = [] diff --git a/packages/react/test/units/SelectorInstance.test.tsx b/packages/react/test/units/SelectorInstance.test.tsx index 0cb8c19c..9ae543ab 100644 --- a/packages/react/test/units/SelectorInstance.test.tsx +++ b/packages/react/test/units/SelectorInstance.test.tsx @@ -41,7 +41,7 @@ describe('the SelectorInstance class', () => { const instance2b = ecosystem.getNode(selector2) const instance1b = ecosystem.getNode(selector1) - const cleanup = instance1b.on(() => {}) + const cleanup = instance1b.on(() => {}, { active: true }) instance2b.destroy() // destroys only selector2 diff --git a/packages/react/test/units/__snapshots__/SelectorInstance.test.tsx.snap b/packages/react/test/units/__snapshots__/SelectorInstance.test.tsx.snap index 41e26b9e..987a0970 100644 --- a/packages/react/test/units/__snapshots__/SelectorInstance.test.tsx.snap +++ b/packages/react/test/units/__snapshots__/SelectorInstance.test.tsx.snap @@ -6,14 +6,14 @@ exports[`the SelectorInstance class deeply nested selectors get auto-created 1`] "className": "SelectorInstance", "observers": { "@@selector-selector2-1": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, }, "sources": { "1": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -26,14 +26,14 @@ exports[`the SelectorInstance class deeply nested selectors get auto-created 1`] "className": "SelectorInstance", "observers": { "@@selector-selector3-0": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, }, "sources": { "@@selector-selector1-2": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, @@ -47,7 +47,7 @@ exports[`the SelectorInstance class deeply nested selectors get auto-created 1`] "observers": {}, "sources": { "@@selector-selector2-1": { - "flags": 0, + "flags": 1, "operation": "select", "p": undefined, }, diff --git a/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap b/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap index 0a09f0ee..c06bfd6d 100644 --- a/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap +++ b/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap @@ -6,7 +6,7 @@ exports[`useAtomSelector inline selector that returns a different object referen "className": "AtomInstance", "observers": { "@@selector-unnamed-0": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -22,13 +22,13 @@ exports[`useAtomSelector inline selector that returns a different object referen "className": "SelectorInstance", "observers": { "Test-:r0:": { - "flags": 2, + "flags": 5, "operation": "useAtomSelector", }, }, "sources": { "1": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -44,7 +44,7 @@ exports[`useAtomSelector inline selector that returns a different object referen "observers": {}, "sources": { "@@selector-unnamed-0": { - "flags": 2, + "flags": 5, "operation": "useAtomSelector", }, }, @@ -61,7 +61,7 @@ exports[`useAtomSelector inline selector that returns a different object referen "className": "AtomInstance", "observers": { "@@selector-unnamed-0": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -77,13 +77,13 @@ exports[`useAtomSelector inline selector that returns a different object referen "className": "SelectorInstance", "observers": { "Test-:r0:": { - "flags": 2, + "flags": 5, "operation": "useAtomSelector", }, }, "sources": { "1": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -99,7 +99,7 @@ exports[`useAtomSelector inline selector that returns a different object referen "observers": {}, "sources": { "@@selector-unnamed-0": { - "flags": 2, + "flags": 5, "operation": "useAtomSelector", }, }, @@ -115,8 +115,8 @@ exports[`useAtomSelector inline selector that returns a different object referen "1": { "className": "AtomInstance", "observers": { - "@@selector-unnamed-1": { - "flags": 0, + "@@selector-unnamed-0": { + "flags": 1, "operation": "get", "p": undefined, }, @@ -128,17 +128,17 @@ exports[`useAtomSelector inline selector that returns a different object referen "status": "Stale", "weight": 1, }, - "@@selector-unnamed-1": { + "@@selector-unnamed-0": { "className": "SelectorInstance", "observers": { "Test-:r0:": { - "flags": 2, + "flags": 5, "operation": "useAtomSelector", }, }, "sources": { "1": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -153,8 +153,8 @@ exports[`useAtomSelector inline selector that returns a different object referen "className": "ExternalNode", "observers": {}, "sources": { - "@@selector-unnamed-1": { - "flags": 2, + "@@selector-unnamed-0": { + "flags": 5, "operation": "useAtomSelector", }, }, @@ -170,8 +170,8 @@ exports[`useAtomSelector inline selector that returns a different object referen "1": { "className": "AtomInstance", "observers": { - "@@selector-unnamed-1": { - "flags": 0, + "@@selector-unnamed-0": { + "flags": 1, "operation": "get", "p": undefined, }, @@ -183,17 +183,17 @@ exports[`useAtomSelector inline selector that returns a different object referen "status": "Stale", "weight": 1, }, - "@@selector-unnamed-1": { + "@@selector-unnamed-0": { "className": "SelectorInstance", "observers": { "Test-:r0:": { - "flags": 2, + "flags": 5, "operation": "useAtomSelector", }, }, "sources": { "1": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -208,8 +208,8 @@ exports[`useAtomSelector inline selector that returns a different object referen "className": "ExternalNode", "observers": {}, "sources": { - "@@selector-unnamed-1": { - "flags": 2, + "@@selector-unnamed-0": { + "flags": 5, "operation": "useAtomSelector", }, }, @@ -222,17 +222,17 @@ exports[`useAtomSelector inline selector that returns a different object referen exports[`useAtomSelector inline selectors are swapped out in strict mode double-renders 1`] = ` { - "@@selector-unnamed-1": { + "@@selector-unnamed-0": { "className": "SelectorInstance", "observers": { "Test-:r0:": { - "flags": 2, + "flags": 5, "operation": "useAtomSelector", }, }, "sources": { "1": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, @@ -246,17 +246,17 @@ exports[`useAtomSelector inline selectors are swapped out in strict mode double- exports[`useAtomSelector inline selectors are swapped out in strict mode double-renders 2`] = ` { - "@@selector-unnamed-1": { + "@@selector-unnamed-0": { "className": "SelectorInstance", "observers": { "Test-:r0:": { - "flags": 2, + "flags": 5, "operation": "useAtomSelector", }, }, "sources": { "1": { - "flags": 0, + "flags": 1, "operation": "get", "p": undefined, }, diff --git a/packages/react/test/units/useAtomSelector.test.tsx b/packages/react/test/units/useAtomSelector.test.tsx index 2224b0a5..691e772b 100644 --- a/packages/react/test/units/useAtomSelector.test.tsx +++ b/packages/react/test/units/useAtomSelector.test.tsx @@ -386,7 +386,7 @@ describe('useAtomSelector', () => { } const instance = ecosystem.getNode(selector2) - instance.on(() => {}) + instance.on(() => {}, { active: true }) const { findByTestId } = renderInEcosystem() diff --git a/packages/stores/src/AtomInstance.ts b/packages/stores/src/AtomInstance.ts index 52fd0488..aaf024b5 100644 --- a/packages/stores/src/AtomInstance.ts +++ b/packages/stores/src/AtomInstance.ts @@ -22,10 +22,10 @@ import { PromiseState, PromiseStatus, InternalEvaluationReason, - ExplicitEvents, Transaction, ZeduxPlugin, zi, + SendableEvents, } from '@zedux/atoms' import { AtomApi } from './AtomApi' import { @@ -197,7 +197,7 @@ export class AtomInstance< public set( settable: Settable, - events?: Partial + events?: Partial> ) { return this.setState(settable, events && Object.keys(events)[0]) } @@ -398,7 +398,7 @@ export class AtomInstance< action: ActionChain ) { this.v = newState - zi.u({ p: oldState, r: this.w, s: this }, false) + zi.u({ n: this.v, o: oldState, r: this.w, s: this }) if (this.e._mods.stateChanged) { this.e.modBus.dispatch( @@ -453,7 +453,7 @@ export class AtomInstance< const state: PromiseState = getInitialPromiseState(currentState?.data) this._promiseStatus = state.status - zi.u({ s: this, t: PromiseChange }, true, true) + zi.a({ s: this, t: PromiseChange }) return state as unknown as G['State'] } diff --git a/packages/stores/src/atoms-port.ts b/packages/stores/src/atoms-port.ts index 2e22c0bb..c6f213bb 100644 --- a/packages/stores/src/atoms-port.ts +++ b/packages/stores/src/atoms-port.ts @@ -23,7 +23,7 @@ export const prefix = '@@zedux' * IMPORTANT! Keep these in sync with `@zedux/atoms/utils/general.ts` */ export const Invalidate = 1 -export const Destroy = 2 +export const Cycle = 2 export const PromiseChange = 3 export const EventSent = 4 diff --git a/packages/stores/src/injectStore.ts b/packages/stores/src/injectStore.ts index d7414634..82806f58 100644 --- a/packages/stores/src/injectStore.ts +++ b/packages/stores/src/injectStore.ts @@ -6,18 +6,16 @@ export const doSubscribe = ( instance: PartialAtomInstance, store: Store ) => - store.subscribe((newState, p, action) => { + store.subscribe((n, o, action) => { // Nothing to do if the state hasn't changed. Also, ignore state updates - // during evaluation. TODO: Create an ecosystem-level flag to turn on - // warning logging for state-updates-during-evaluation, since this may be - // considered an anti-pattern. + // during evaluation. if (instance._isEvaluating || action.meta === zeduxTypes.ignore) { return } const isBatch = action?.meta === zeduxTypes.batch - instance.r({ p }, isBatch) + instance.r({ n, o }, isBatch) // run the scheduler synchronously after every store update unless batching if (!isBatch) { diff --git a/yarn.lock b/yarn.lock index 14f0d295..75509194 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3315,7 +3315,7 @@ ignore@^5.0.4, ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== -immer@^10.0.0: +immer@^10.1.1: version "10.1.1" resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==