Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: make signal.get reactive and add signal.getOnce #151

Merged
merged 8 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions packages/atoms/src/classes/Ecosystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,19 +137,7 @@ export class Ecosystem<Context extends Record<string, any> | undefined = any>
const get: AtomGetters['get'] = <G extends AtomGenerics>(
atom: AtomTemplateBase<G>,
params?: G['Params']
) => {
const instance = this.getNode(atom, params as G['Params'])
const node = getEvaluationContext().n

// 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()
if (node) {
bufferEdge(instance, 'get', 0)
}

return instance.get()
}
) => this.getNode(atom, params as G['Params']).get()

const getInstance: AtomGetters['getInstance'] = <G extends AtomGenerics>(
atom: AtomTemplateBase<G>,
Expand Down Expand Up @@ -444,15 +432,15 @@ export class Ecosystem<Context extends Record<string, any> | undefined = any>
params?: ParamsOf<A>
) {
if ((atom as GraphNode).izn) {
return (atom as GraphNode).get()
return (atom as GraphNode).v
}

const instance = this.getInstance(
atom as A,
params as ParamsOf<A>
) as AnyAtomInstance

return instance.get()
return instance.v
}

public getInstance<A extends AnyAtomTemplate>(
Expand Down Expand Up @@ -633,7 +621,6 @@ export class Ecosystem<Context extends Record<string, any> | undefined = any>
instance = new SelectorInstance(this, id, selectorOrConfig, params || [])

this.n.set(id, instance)
runSelector(instance, true)

return instance
}
Expand Down
262 changes: 46 additions & 216 deletions packages/atoms/src/classes/GraphNode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
AnyAtomTemplate,
AtomGenerics,
AnyNodeGenerics,
Cleanup,
DehydrationFilter,
GraphEdge,
Expand All @@ -9,229 +9,27 @@ import {
LifecycleStatus,
NodeFilter,
NodeFilterOptions,
NodeGenerics,
} from '@zedux/atoms/types/index'
import { is, Job } from '@zedux/core'
import { Ecosystem } from './Ecosystem'
import { pluginActions } from '../utils/plugin-actions'
import { Destroy, EventSent, ExplicitExternal, Static } from '../utils/general'
import {
EventSent,
ExplicitExternal,
makeReasonReadable,
} from '../utils/general'
import { AtomTemplateBase } from './templates/AtomTemplateBase'
import {
ExplicitEvents,
CatchAllListener,
EventEmitter,
SingleEventListener,
ListenableEvents,
} from '../types/events'
import { bufferEdge, getEvaluationContext } from '../utils/evaluationContext'
import { addEdge, removeEdge, setNodeStatus } from '../utils/graph'

/**
* Actually add an edge to the graph. When we buffer graph updates, we're
* really just deferring the calling of this method.
*/
export const addEdge = (
dependent: GraphNode,
dependency: GraphNode,
newEdge: GraphEdge
) => {
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)
dependency.o.set(dependent, newEdge)
dependency.c?.()

// static dependencies don't change a node's weight
if (!(newEdge.flags & Static)) {
recalculateNodeWeight(dependency.W, dependent)
}

if (_mods.edgeCreated) {
modBus.dispatch(
pluginActions.edgeCreated({
dependency,
dependent: dependent, // unfortunate but not changing for now
edge: newEdge,
})
)
}

return newEdge
}

export const destroyNodeStart = (node: GraphNode, force?: boolean) => {
// If we're not force-destroying, don't destroy if there are dependents
if (node.l === 'Destroyed' || (!force && node.o.size)) return

node.c?.()
node.c = undefined

setNodeStatus(node, 'Destroyed')

if (node.w.length) node.e._scheduler.unschedule(node)

return true
}

// TODO: merge this into destroyNodeStart. We should be able to
export const destroyNodeFinish = (node: GraphNode) => {
// first remove all edges between this node and its dependencies
for (const dependency of node.s.keys()) {
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)) {
recalculateNodeWeight(-node.W, observer)
}

observer.s.delete(node)

// we _probably_ don't need to send edgeRemoved mod events to plugins for
// these - it's better that they receive the duplicate edgeCreated event
// when the dependency is recreated by its dependent(s) so they can infer
// that the edge was "moved"
}

node.e.n.delete(node.id)
}

export const handleStateChange = <
G extends Pick<AtomGenerics, 'Events' | 'State'>
>(
node: GraphNode<G & { Params: any; Template: any }>,
oldState: G['State'],
events?: Partial<G['Events'] & ExplicitEvents>
) => {
scheduleDependents({ e: events, p: oldState, r: node.w, s: node }, false)

if (node.e._mods.stateChanged) {
node.e.modBus.dispatch(
pluginActions.stateChanged({
node,
newState: node.get(),
oldState,
reasons: node.w,
})
)
}

// run the scheduler synchronously after any node state update
events?.batch || node.e._scheduler.flush()
}

export const normalizeNodeFilter = (options?: NodeFilter) =>
typeof options === 'object' && !is(options, AtomTemplateBase)
? (options as NodeFilterOptions)
: { include: options ? [options as string | AnyAtomTemplate] : [] }

const recalculateNodeWeight = (weightDiff: number, node?: GraphNode) => {
if (!node) return // happens when node is external

node.W += weightDiff

for (const observer of node.o.keys()) {
recalculateNodeWeight(weightDiff, observer)
}
}

/**
* Remove the graph edge between two nodes. The dependent may not exist as a
* node in the graph if it's external, e.g. a React component
*
* For some reason in React 18+, React destroys parents before children. This
* means a parent EcosystemProvider may have already unmounted and wiped the
* whole graph; this edge may already be destroyed.
*/
export const removeEdge = (dependent: GraphNode, dependency: GraphNode) => {
// erase graph edge between dependent and dependency
dependent && 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
// that though)
if (!dependency) return

const edge = dependency.o.get(dependent)

// happens in React 18+ (see this method's jsdoc above)
if (!edge) return

dependency.o.delete(dependent)

// static dependencies don't change a node's weight
if (!(edge.flags & Static)) {
recalculateNodeWeight(-dependency.W, dependent)
}

if (dependency.e._mods.edgeRemoved) {
dependency.e.modBus.dispatch(
pluginActions.edgeRemoved({
dependency,
dependent: dependent,
edge: edge,
})
)
}

scheduleNodeDestruction(dependency)
}

export const scheduleDependents = (
reason: Omit<InternalEvaluationReason, 's'> & {
s: NonNullable<InternalEvaluationReason['s']>
},
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)
}
}

/**
* 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()

export const setNodeStatus = (node: GraphNode, newStatus: LifecycleStatus) => {
const oldStatus = node.l
node.l = newStatus

if (node.e._mods.statusChanged) {
node.e.modBus.dispatch(
pluginActions.statusChanged({
newStatus,
node,
oldStatus,
})
)
}
}

export abstract class GraphNode<
G extends Pick<AtomGenerics, 'Events' | 'Params' | 'State' | 'Template'> = {
Events: any
Params: any
State: any
Template: any
}
> implements Job, EventEmitter<G>
export abstract class GraphNode<G extends NodeGenerics = AnyNodeGenerics>
implements Job, EventEmitter<G>
{
/**
* TS drops the entire `G`enerics type unless it's used somewhere in this
Expand All @@ -257,6 +55,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.
Expand All @@ -271,8 +76,31 @@ export abstract class GraphNode<

/**
* Get the current value of this node.
*
* This is reactive! When called inside a reactive context (e.g. an atom state
* factory or atom selector function), calling this method creates a graph
* edge between the evaluating node and the node whose value this returns.
*
* Outside reactive contexts, this behaves exactly the same as `.getOnce()`
*
* To retrieve the node's value non-reactively, use `.getOnce()` instead.
*/
public abstract get(): G['State']
public get() {
// 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)

return this.v
}

/**
* Get the current value of this node without registering any graph
* dependencies in reactive contexts.
*/
public getOnce() {
return this.v
}

/**
* The unique id of this node in the graph. Zedux always tries to make this
Expand Down Expand Up @@ -322,7 +150,7 @@ export abstract class GraphNode<
? reason.e ?? {}
: {
...reason.e,
change: { newState: this.get(), oldState: reason.p },
change: makeReasonReadable(reason, observer),
}
) as ListenableEvents<G>

Expand Down Expand Up @@ -408,7 +236,9 @@ export abstract class GraphNode<
excludeFlags = [],
include = [],
includeFlags = [],
} = normalizeNodeFilter(options)
} = typeof options === 'object' && !is(options, AtomTemplateBase)
? (options as NodeFilterOptions)
: { include: options ? [options as string | AnyAtomTemplate] : [] }

const isExcluded =
exclude.some(templateOrKey =>
Expand Down
5 changes: 3 additions & 2 deletions packages/atoms/src/classes/MappedSignal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Settable } from '@zedux/core'
import {
AnyNodeGenerics,
AtomGenerics,
ExplicitEvents,
InternalEvaluationReason,
Expand All @@ -17,7 +18,7 @@ import { Ecosystem } from './Ecosystem'
import { Signal } from './Signal'
import { recursivelyMutate, recursivelyProxy } from './proxies'

export type SignalMap = Record<string, Signal>
export type SignalMap = Record<string, Signal<AnyNodeGenerics>>

export class MappedSignal<
G extends Pick<AtomGenerics, 'Events' | 'State'> & {
Expand Down Expand Up @@ -208,7 +209,7 @@ export class MappedSignal<
if (reason.s) this.N = { ...this.v }
}

if (reason.s) this.N![this.I[reason.s.id]] = reason.s.get()
if (reason.s) this.N![this.I[reason.s.id]] = reason.s.v

// forward events from wrapped signals to observers of this wrapper signal.
// Use `super.send` for this 'cause `this.send` intercepts events and passes
Expand Down
Loading
Loading