diff --git a/packages/atoms/README.md b/packages/atoms/README.md index e1e7cd16..9d179357 100644 --- a/packages/atoms/README.md +++ b/packages/atoms/README.md @@ -76,7 +76,6 @@ On top of this, `@zedux/atoms` exports the following APIs and many helper types - [`injectCallback()`](https://omnistac.github.io/zedux/docs/api/injectors/injectCallback) - [`injectEcosystem()`](https://omnistac.github.io/zedux/docs/api/injectors/injectEcosystem) - [`injectEffect()`](https://omnistac.github.io/zedux/docs/api/injectors/injectEffect) -- [`injectInvalidate()`](https://omnistac.github.io/zedux/docs/api/injectors/injectInvalidate) - [`injectMappedSignal()`](https://omnistac.github.io/zedux/docs/api/injectors/injectMappedSignal) - [`injectMemo()`](https://omnistac.github.io/zedux/docs/api/injectors/injectMemo) - [`injectPromise()`](https://omnistac.github.io/zedux/docs/api/injectors/injectPromise) @@ -90,6 +89,7 @@ On top of this, `@zedux/atoms` exports the following APIs and many helper types - [`getEcosystem()`](https://omnistac.github.io/zedux/docs/api/utils/internal-utils#getecosystem) - [`getInternals()`](https://omnistac.github.io/zedux/docs/api/utils/internal-utils#getinternals) - [`setInternals()`](https://omnistac.github.io/zedux/docs/api/utils/internal-utils#setinternals) +- [`untrack()`](https://omnistac.github.io/zedux/docs/api/utils/internal-utils#untrack) - [`wipe()`](https://omnistac.github.io/zedux/docs/api/utils/internal-utils#wipe) ## For Authors diff --git a/packages/atoms/src/classes/instances/AtomInstance.ts b/packages/atoms/src/classes/instances/AtomInstance.ts index 8c8b77ba..1901ad56 100644 --- a/packages/atoms/src/classes/instances/AtomInstance.ts +++ b/packages/atoms/src/classes/instances/AtomInstance.ts @@ -9,7 +9,6 @@ import { AtomGenericsToAtomApiGenerics, Cleanup, ExportsInfusedSetter, - LifecycleStatus, PromiseState, PromiseStatus, DehydrationFilter, @@ -33,7 +32,6 @@ import { getInitialPromiseState, getSuccessPromiseState, } from '@zedux/atoms/utils/promiseUtils' -import { InjectorDescriptor } from '@zedux/atoms/utils/types' import { Ecosystem } from '../Ecosystem' import { AtomApi } from '../AtomApi' import { AtomTemplateBase } from '../templates/AtomTemplateBase' @@ -60,6 +58,33 @@ import { sendImplicitEcosystemEvent, } from '@zedux/atoms/utils/events' +export type InjectorDescriptor = { + /** + * `c`leanup - tracks cleanup functions, e.g. those returned from + * `injectEffect` callbacks. + */ + c: (() => void) | undefined + + /** + * `i`nit - a callback that we need to call immediately after evaluation. This + * is how `injectEffect` works (without `synchronous: true`). + */ + i: (() => void) | undefined + + /** + * `t`ype - a unique injector name string. This is how we ensure the user + * didn't add, remove, or reorder injector calls in the state factory. + */ + t: string + + /** + * `v`alue - can be anything. For `injectRef`, this is the ref object. For + * `injectMemo` and `injectEffect`, this keeps track of the memoized value + * and/or dependency arrays. + */ + v: T +} + /** * A standard atom's value can be one of: * @@ -178,11 +203,6 @@ export class AtomInstance< > extends Signal { public static $$typeof = Symbol.for(`${prefix}/AtomInstance`) - /** - * @see Signal.l - */ - public l: LifecycleStatus = 'Initializing' - public api?: AtomApi> // @ts-expect-error this is set in `this.i`nit, right after instantiation, so @@ -198,6 +218,22 @@ export class AtomInstance< */ public a: boolean | undefined = undefined + /** + * @see Signal.c + */ + public c?: Cleanup + + /** + * `I`njectors - tracks injector calls from the last time the state factory + * ran. Initialized on-demand + */ + public I: InjectorDescriptor[] | undefined = undefined + + /** + * `N`extInjectors - tracks injector calls as they're made during evaluation + */ + public N: InjectorDescriptor[] | undefined = undefined + /** * `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. @@ -205,13 +241,7 @@ export class AtomInstance< */ public S?: Signal - /** - * @see Signal.c - */ - public c?: Cleanup - public _injectors?: InjectorDescriptor[] public _isEvaluating?: boolean - public _nextInjectors?: InjectorDescriptor[] public _promiseError?: Error public _promiseStatus?: PromiseStatus @@ -242,20 +272,11 @@ export class AtomInstance< public destroy(force?: boolean) { if (!destroyNodeStart(this, force)) return - // Clean up effect injectors first, then everything else - const nonEffectInjectors: InjectorDescriptor[] = [] - - this._injectors?.forEach(injector => { - if (injector.type !== '@@zedux/effect') { - nonEffectInjectors.push(injector) - return + if (this.I) { + for (const injector of this.I) { + injector.c?.() } - injector.cleanup?.() - }) - - nonEffectInjectors.forEach(injector => { - injector.cleanup?.() - }) + } destroyNodeFinish(this) } @@ -386,7 +407,6 @@ export class AtomInstance< return } - this._nextInjectors = [] this._isEvaluating = true const prevNode = startBuffer(this) @@ -434,10 +454,13 @@ export class AtomInstance< } } } catch (err) { - this._nextInjectors.forEach(injector => { - injector.cleanup?.() - }) + if (this.N) { + for (const injector of this.N) { + injector.c?.() + } + } + this.N = undefined destroyBuffer(prevNode) throw err @@ -456,7 +479,17 @@ export class AtomInstance< this.w = [] } - this._injectors = this._nextInjectors + // kick off side effects and store the new injectors + if (this.N) { + if (!this.e.ssr) { + for (const injector of this.N) { + injector.i?.() + } + } + + this.I = this.N + this.N = undefined + } // let this.i flush updates after status is set to Active this.l === 'Initializing' || flushBuffer(prevNode) diff --git a/packages/atoms/src/factories/createInjector.ts b/packages/atoms/src/factories/createInjector.ts deleted file mode 100644 index 741320c2..00000000 --- a/packages/atoms/src/factories/createInjector.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { PartialAtomInstance } from '../types/index' -import { readInstance } from '../utils/evaluationContext' -import { InjectorDescriptor } from '../utils/types' - -export const createInjector = < - A extends [...any], - T extends InjectorDescriptor ->( - operation: string, - first: (instance: PartialAtomInstance, ...args: A) => T, - next?: (prevDescriptor: T, instance: PartialAtomInstance, ...args: A) => T -) => { - let type: string - - const injector = (...args: A) => { - const instance = readInstance() - const { _injectors, _nextInjectors, l, t } = instance - - // TODO: these `!`s should be replaced by making these properties not - // optional with a ts-expect-error in the AtomInstance class - if (l === 'Initializing') { - const descriptor = first(instance, ...args) - type = descriptor.type - _nextInjectors!.push(descriptor) - - return descriptor.result - } - - const prevDescriptor = _injectors![_nextInjectors!.length as number] as T - - if (DEV && (!prevDescriptor || prevDescriptor.type !== type)) { - throw new Error( - `Zedux: ${operation} in atom "${t.key}" - injectors cannot be added, removed, or reordered` - ) - } - - const descriptor = next - ? next(prevDescriptor, instance, ...args) - : prevDescriptor - - _nextInjectors!.push(descriptor) - - return descriptor.result - } - - return injector -} diff --git a/packages/atoms/src/index.ts b/packages/atoms/src/index.ts index 885bb722..b4d5e554 100644 --- a/packages/atoms/src/index.ts +++ b/packages/atoms/src/index.ts @@ -1,4 +1,3 @@ -import { createInjector } from './factories/createInjector' import { destroyBuffer, flushBuffer, @@ -18,12 +17,12 @@ export * from './factories/index' export * from './injectors/index' export { getEcosystem, getInternals, setInternals, wipe } from './store/index' export * from './types/index' +export { untrack } from './utils/evaluationContext' // 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, e: destroyNodeFinish, i: sendImplicitEcosystemEvent, diff --git a/packages/atoms/src/injectors/index.ts b/packages/atoms/src/injectors/index.ts index 7d3225f6..e35e7a4d 100644 --- a/packages/atoms/src/injectors/index.ts +++ b/packages/atoms/src/injectors/index.ts @@ -6,7 +6,6 @@ export * from './injectAtomValue' export * from './injectCallback' export * from './injectEcosystem' export * from './injectEffect' -export * from './injectInvalidate' export * from './injectMappedSignal' export * from './injectMemo' export * from './injectPromise' diff --git a/packages/atoms/src/injectors/injectAtomGetters.ts b/packages/atoms/src/injectors/injectAtomGetters.ts index b3a5e3dd..1ff50120 100644 --- a/packages/atoms/src/injectors/injectAtomGetters.ts +++ b/packages/atoms/src/injectors/injectAtomGetters.ts @@ -1,5 +1,4 @@ -import type { Ecosystem } from '../classes/Ecosystem' -import { readInstance } from '../utils/evaluationContext' +import { injectEcosystem } from './injectEcosystem' /** * injectAtomGetters @@ -31,4 +30,4 @@ import { readInstance } from '../utils/evaluationContext' * * @see Ecosystem */ -export const injectAtomGetters = (): Ecosystem => readInstance().e +export const injectAtomGetters = injectEcosystem diff --git a/packages/atoms/src/injectors/injectAtomInstance.ts b/packages/atoms/src/injectors/injectAtomInstance.ts index 0482681e..ecb256cf 100644 --- a/packages/atoms/src/injectors/injectAtomInstance.ts +++ b/packages/atoms/src/injectors/injectAtomInstance.ts @@ -8,7 +8,7 @@ import { ParamsOf, Selectable, } from '../types/index' -import { readInstance } from '../utils/evaluationContext' +import { injectSelf } from './injectSelf' const defaultOperation = 'injectAtomInstance' @@ -65,7 +65,7 @@ export const injectAtomInstance: { params?: ParamsOf, config?: InjectAtomInstanceConfig ) => - readInstance().e.getNode(template, params, { + injectSelf().e.getNode(template, params, { f: config?.subscribe ? Eventless : EventlessStatic, op: config?.operation || defaultOperation, }) diff --git a/packages/atoms/src/injectors/injectAtomSelector.ts b/packages/atoms/src/injectors/injectAtomSelector.ts index e7c8a95d..f358d185 100644 --- a/packages/atoms/src/injectors/injectAtomSelector.ts +++ b/packages/atoms/src/injectors/injectAtomSelector.ts @@ -1,15 +1,15 @@ import { ParamsOf, Selectable, StateOf } from '../types/index' -import { readInstance } from '../utils/evaluationContext' +import { injectSelf } from './injectSelf' /** + * @deprecated use `injectAtomValue` instead + * * ```ts * injectAtomSelector(mySelector, arg1, arg2) // before * injectAtomValue(mySelector, [arg1, arg2]) // after * ``` - * - * @deprecated use `injectAtomValue` instead */ export const injectAtomSelector = ( selectable: S, ...args: ParamsOf -): StateOf => readInstance().e.get(selectable, args) +): StateOf => injectSelf().e.get(selectable, args) diff --git a/packages/atoms/src/injectors/injectEcosystem.ts b/packages/atoms/src/injectors/injectEcosystem.ts index 0d4815d4..680a467c 100644 --- a/packages/atoms/src/injectors/injectEcosystem.ts +++ b/packages/atoms/src/injectors/injectEcosystem.ts @@ -1,5 +1,5 @@ import type { Ecosystem } from '../classes/Ecosystem' -import { readInstance } from '../utils/evaluationContext' +import { injectSelf } from './injectSelf' /** * injectEcosystem @@ -29,4 +29,4 @@ import { readInstance } from '../utils/evaluationContext' * * @see Ecosystem */ -export const injectEcosystem = (): Ecosystem => readInstance().e +export const injectEcosystem = (): Ecosystem => injectSelf().e diff --git a/packages/atoms/src/injectors/injectEffect.ts b/packages/atoms/src/injectors/injectEffect.ts index f32b1b22..46737776 100644 --- a/packages/atoms/src/injectors/injectEffect.ts +++ b/packages/atoms/src/injectors/injectEffect.ts @@ -1,27 +1,10 @@ -import { createInjector } from '../factories/createInjector' +import type { InjectorDescriptor } from '../classes/instances/AtomInstance' import { EffectCallback, InjectorDeps } from '../types/index' -import { compare, prefix } from '../utils/general' -import type { InjectorDescriptor } from '../utils/types' +import { untrack } from '../utils/evaluationContext' +import { compare } from '../utils/general' +import { injectPrevDescriptor, setNextInjector } from './injectPrevDescriptor' -interface EffectInjectorDescriptor extends InjectorDescriptor { - deps: InjectorDeps -} - -const getTask = ( - effect: EffectCallback, - descriptor: EffectInjectorDescriptor -) => { - const task = () => { - const cleanup = effect() - - // now that the task has run, there's no need for the scheduler cleanup - // function; replace it with the cleanup logic returned from the effect - // (if any). If a promise was returned, ignore it. - descriptor.cleanup = typeof cleanup === 'function' ? cleanup : undefined - } - - return task -} +const TYPE = 'injectEffect' /** * Runs a deferred side effect. This is just like React's `useEffect`. When @@ -36,74 +19,35 @@ const getTask = ( * don't have anything to cleanup, as you'll be unable to clean up resources if * you return a promise. */ -export const injectEffect = createInjector( - 'injectEffect', - ( - instance, - effect: EffectCallback, - deps?: InjectorDeps, - config?: { synchronous?: boolean } - ) => { - const descriptor: EffectInjectorDescriptor = { - deps, - type: `${prefix}/effect`, - } - - if (!instance.e.ssr) { - const job = { - j: getTask(effect, descriptor), - T: 4 as const, // RunEffect (4) - } - - descriptor.cleanup = () => { - instance.e._scheduler.unschedule(job) - descriptor.cleanup = undefined - } - - if (config?.synchronous) { - job.j() - } else { - instance.e._scheduler.schedule(job) - } - } - - return descriptor - }, - ( - prevDescriptor, - instance, - effect: EffectCallback, - deps?: InjectorDeps, - config?: { synchronous?: boolean } - ) => { - if (instance.e.ssr) return prevDescriptor +export const injectEffect = ( + effect: EffectCallback, + deps?: InjectorDeps, + config?: { synchronous?: boolean } +) => { + const prevDescriptor = injectPrevDescriptor(TYPE) + const depsUnchanged = compare(deps, prevDescriptor?.v) - const depsUnchanged = compare(prevDescriptor?.deps, deps) + if (depsUnchanged) { + setNextInjector(prevDescriptor!) - if (depsUnchanged) return prevDescriptor + return + } - prevDescriptor.cleanup?.() + const nextDescriptor: InjectorDescriptor = { + c: undefined, + i: () => { + prevDescriptor?.c?.() + nextDescriptor.i = undefined // allow this closure to be garbage collected - const job = { - j: getTask(effect, prevDescriptor), - T: 4 as const, // RunEffect (4) - } + const cleanup = untrack(effect) // let this throw - // this cleanup should be unnecessary since effects run immediately every - // time except init. Leave this though in case we add a way to update an - // atom instance without flushing the scheduler - prevDescriptor.cleanup = () => { - instance.e._scheduler.unschedule(job) - prevDescriptor.cleanup = undefined - } - prevDescriptor.deps = deps + if (typeof cleanup === 'function') nextDescriptor.c = cleanup + }, + t: TYPE, + v: deps, + } - if (config?.synchronous) { - job.j() - } else { - instance.e._scheduler.schedule(job) - } + if (config?.synchronous) nextDescriptor.i!() - return prevDescriptor - } -) + setNextInjector(nextDescriptor) +} diff --git a/packages/atoms/src/injectors/injectInvalidate.ts b/packages/atoms/src/injectors/injectInvalidate.ts deleted file mode 100644 index 191272b9..00000000 --- a/packages/atoms/src/injectors/injectInvalidate.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { readInstance } from '../utils/evaluationContext' - -/** - * Returns a function that can be called to invalidate the current atom - * instance. - * - * @deprecated This injector will be removed in the next major release. Use the - * following pattern instead: - * - * ```ts - * const self = injectSelf() - * // then in a callback or effect: - * self.invalidate() - * ``` - */ -export const injectInvalidate = () => { - const instance = readInstance() - - return () => instance.invalidate() -} diff --git a/packages/atoms/src/injectors/injectMappedSignal.ts b/packages/atoms/src/injectors/injectMappedSignal.ts index 7b2bfb48..207668a4 100644 --- a/packages/atoms/src/injectors/injectMappedSignal.ts +++ b/packages/atoms/src/injectors/injectMappedSignal.ts @@ -8,9 +8,9 @@ import { Prettify, StateOf, } from '../types/index' -import { readInstance } from '../utils/evaluationContext' import { Eventless, EventlessStatic } from '../utils/general' import { injectMemo } from './injectMemo' +import { injectSelf } from './injectSelf' type MapAll = MapEventsToPayloads<{ [K in keyof M]: M[K] extends Signal ? EventsOf : None @@ -72,7 +72,7 @@ export const injectMappedSignal = ( map: M, config?: Pick>, 'reactive'> ) => { - const instance = readInstance() + const instance = injectSelf() const signal = injectMemo(() => { return new MappedSignal<{ diff --git a/packages/atoms/src/injectors/injectMemo.ts b/packages/atoms/src/injectors/injectMemo.ts index ad0776fe..32fc78e5 100644 --- a/packages/atoms/src/injectors/injectMemo.ts +++ b/packages/atoms/src/injectors/injectMemo.ts @@ -1,42 +1,42 @@ -import { createInjector } from '../factories/createInjector' -import { InjectorDeps, PartialAtomInstance } from '../types/index' -import { compare, prefix } from '../utils/general' +import { InjectorDeps } from '../types/index' +import { untrack } from '../utils/evaluationContext' +import { compare } from '../utils/general' +import { injectPrevDescriptor, setNextInjector } from './injectPrevDescriptor' -type MemoInjectorDescriptor = { - deps: InjectorDeps - result: T - type: string -} +interface MemoValue { + /** + * `d`eps - the cached `injectMemo` deps array + */ + d: InjectorDeps -export const injectMemo: ( - valueFactory: () => Value, - deps?: InjectorDeps -) => Value = createInjector( - 'injectMemo', - ( - instance: PartialAtomInstance, - valueFactory: () => Value, - deps?: InjectorDeps - ) => - ({ - type: `${prefix}/memo`, - deps, - // TODO: wrap all injector callback calls in `ecosystem.untrack` - result: valueFactory(), - } as MemoInjectorDescriptor), - ( - prevDescriptor: MemoInjectorDescriptor, - instance: PartialAtomInstance, - valueFactory: () => Value, - deps?: InjectorDeps - ) => { - const depsUnchanged = compare(prevDescriptor.deps, deps) + /** + * `v`alue - the cached `injectMemo` result + */ + v: T +} - const result = depsUnchanged ? prevDescriptor.result : valueFactory() +const TYPE = 'injectMemo' - prevDescriptor.deps = deps - prevDescriptor.result = result +/** + * The injector equivalent of React's `useMemo` hook. Memoizes a value. Only + * calls the valueFactory to produce a new value when deps change on subsequent + * evaluations. + */ +export const injectMemo = ( + valueFactory: () => T, + deps?: InjectorDeps +): T => { + const prevDescriptor = injectPrevDescriptor>(TYPE) - return prevDescriptor - } -) + return setNextInjector({ + c: undefined, + i: undefined, + t: TYPE, + v: { + d: deps, + v: compare(prevDescriptor?.v.d, deps) + ? prevDescriptor!.v.v + : untrack(valueFactory), + }, + }).v.v +} diff --git a/packages/atoms/src/injectors/injectPrevDescriptor.ts b/packages/atoms/src/injectors/injectPrevDescriptor.ts new file mode 100644 index 00000000..4254d197 --- /dev/null +++ b/packages/atoms/src/injectors/injectPrevDescriptor.ts @@ -0,0 +1,49 @@ +import { InjectorDescriptor } from '../classes/instances/AtomInstance' +import { AnyAtomInstance } from '../types' +import { getEvaluationContext } from '../utils/evaluationContext' +import { injectSelf } from './injectSelf' + +/** + * A low-level injector only used internally. + * + * Tracks injector usages in the currently-evaluating atom and ensures injectors + * are called in the same order every evaluation, just like React hooks. + * + * `injectSelf` is the source of all injectors - restricted or unrestricted. + * This is the source of all restricted injectors. All Zedux's restricted + * injectors use this internally. + * + * Always pair this with a call to `setNextInjector`. + */ +export const injectPrevDescriptor = ( + type: string +): InjectorDescriptor | undefined => { + const instance = injectSelf() + + // a restricted injector was used. Initialize `N`extInjectors if it isn't yet + instance.N ??= [] + + const { I, id, l, N } = instance + + if (l === 'Initializing') return + + const prevDescriptor = I?.[N.length] + + if (!prevDescriptor || prevDescriptor.t !== type) { + throw new Error( + `Zedux: ${type} in atom "${id}" - injectors cannot be added, removed, or reordered` + ) + } + + return prevDescriptor +} + +/** + * Only used internally after a previous call to `injectPrevDescriptor`. Tracks + * an injector call in the currently-evaluating atom instance's `N`extInjectors. + */ +export const setNextInjector = (descriptor: InjectorDescriptor) => { + ;(getEvaluationContext().n! as AnyAtomInstance).N!.push(descriptor) + + return descriptor +} diff --git a/packages/atoms/src/injectors/injectPromise.ts b/packages/atoms/src/injectors/injectPromise.ts index 4a6f376c..5a2581ee 100644 --- a/packages/atoms/src/injectors/injectPromise.ts +++ b/packages/atoms/src/injectors/injectPromise.ts @@ -20,8 +20,8 @@ import { injectSignal } from './injectSignal' import { injectRef } from './injectRef' import { AtomApi } from '../classes/AtomApi' import { Invalidate } from '../utils/general' -import { readInstance } from '../utils/evaluationContext' import { Signal } from '../classes/Signal' +import { injectSelf } from './injectSelf' /** * Create a memoized promise reference. Kicks off the promise immediately @@ -118,7 +118,7 @@ export const injectPromise: { if ( runOnInvalidate && // injectWhy is an unrestricted injector - using it conditionally is fine: - readInstance().w.some(reason => reason.t === Invalidate) + injectSelf().w.some(reason => reason.t === Invalidate) ) { refs.current.counter++ } diff --git a/packages/atoms/src/injectors/injectRef.ts b/packages/atoms/src/injectors/injectRef.ts index 6990c2f4..237a329f 100644 --- a/packages/atoms/src/injectors/injectRef.ts +++ b/packages/atoms/src/injectors/injectRef.ts @@ -1,19 +1,18 @@ -import { createInjector } from '../factories/createInjector' -import type { - MutableRefObject, - PartialAtomInstance, - RefObject, -} from '../types/index' -import { prefix } from '../utils/general' +import type { MutableRefObject, RefObject } from '../types/index' +import { injectPrevDescriptor, setNextInjector } from './injectPrevDescriptor' + +const TYPE = 'injectRef' export const injectRef: { (initialVal: T): MutableRefObject (initialVal: T | null): RefObject (): MutableRefObject -} = createInjector( - 'injectRef', - (instance: PartialAtomInstance, initialVal?: T) => ({ - result: { current: initialVal as T }, - type: `${prefix}/ref`, - }) -) +} = (initialVal?: T) => + setNextInjector( + injectPrevDescriptor<{ current: T }>(TYPE) || { + c: undefined, + i: undefined, + t: TYPE, + v: { current: initialVal as T }, + } + ).v diff --git a/packages/atoms/src/injectors/injectSelf.ts b/packages/atoms/src/injectors/injectSelf.ts index 20bbeb52..6c882ed9 100644 --- a/packages/atoms/src/injectors/injectSelf.ts +++ b/packages/atoms/src/injectors/injectSelf.ts @@ -1,12 +1,25 @@ +import { is } from '@zedux/core' import { AnyAtomInstance, PartialAtomInstance } from '../types/index' -import { readInstance } from '../utils/evaluationContext' +import { getEvaluationContext } from '../utils/evaluationContext' +import { AtomInstance } from '../classes/instances/AtomInstance' /** * An unrestricted injector (can actually be used in loops and if statements). * * Returns the currently-evaluating AtomInstance. Note that this instance will * not have its `exports`, `promise`, or `signal` set yet on initial evaluation. + * + * This is the entry point for all Zedux injectors. If a function calls this, + * it's an injector. + * + * Throws an error if called outside an atom state factory. */ -export const injectSelf = readInstance as () => - | AnyAtomInstance - | PartialAtomInstance +export const injectSelf = (): AnyAtomInstance | PartialAtomInstance => { + const node = getEvaluationContext().n + + if (DEV && !is(node, AtomInstance)) { + throw new Error('Zedux: Injectors can only be used in atom state factories') + } + + return node as AnyAtomInstance +} diff --git a/packages/atoms/src/injectors/injectSignal.ts b/packages/atoms/src/injectors/injectSignal.ts index 5f0ddf49..38d86183 100644 --- a/packages/atoms/src/injectors/injectSignal.ts +++ b/packages/atoms/src/injectors/injectSignal.ts @@ -1,8 +1,9 @@ import { Signal } from '../classes/Signal' import { EventMap, InjectSignalConfig, MapEvents, None } from '../types/index' -import { readInstance } from '../utils/evaluationContext' +import { untrack } from '../utils/evaluationContext' import { Eventless, EventlessStatic } from '../utils/general' import { injectMemo } from './injectMemo' +import { injectSelf } from './injectSelf' /** * A TS utility for typing custom events. @@ -29,7 +30,7 @@ export const injectSignal = ( state: (() => State) | State, config?: InjectSignalConfig ) => { - const instance = readInstance() + const instance = injectSelf() const signal = injectMemo(() => { const id = instance.e._idGenerator.generateId(`@signal(${instance.id})`) @@ -40,7 +41,7 @@ export const injectSignal = ( }>( instance.e, id, - typeof state === 'function' ? (state as () => State)() : state, // TODO: should hydration be passed to the `state()` factory? + typeof state === 'function' ? untrack(state as () => State) : state, // TODO: should hydration be passed to the `state()` factory? config?.events ) diff --git a/packages/atoms/src/injectors/injectWhy.ts b/packages/atoms/src/injectors/injectWhy.ts index f25c8804..666c4974 100644 --- a/packages/atoms/src/injectors/injectWhy.ts +++ b/packages/atoms/src/injectors/injectWhy.ts @@ -1,5 +1,5 @@ -import { readInstance } from '../utils/evaluationContext' import { makeReasonsReadable } from '../utils/general' +import { injectSelf } from './injectSelf' /** * An "unrestricted" injector (can actually be used in loops and if statements). @@ -10,4 +10,4 @@ import { makeReasonsReadable } from '../utils/general' * const reasons = ecosystem.why() * ``` */ -export const injectWhy = () => makeReasonsReadable(readInstance())! +export const injectWhy = () => makeReasonsReadable(injectSelf())! diff --git a/packages/atoms/src/utils/evaluationContext.ts b/packages/atoms/src/utils/evaluationContext.ts index 86dc3eeb..09819a63 100644 --- a/packages/atoms/src/utils/evaluationContext.ts +++ b/packages/atoms/src/utils/evaluationContext.ts @@ -1,7 +1,4 @@ -import { is } from '@zedux/core' -import { AnyAtomInstance } from '../types/index' import { type GraphNode } from '../classes/GraphNode' -import { AtomInstance } from '../classes/instances/AtomInstance' import { EDGE, ExplicitExternal, @@ -133,32 +130,17 @@ export const flushBuffer = (previousNode: GraphNode | undefined) => { export const getEvaluationContext = () => evaluationContext -export const readInstance = () => { - const node = evaluationContext.n - - if (DEV && !is(node, AtomInstance)) { - throw new Error('Zedux: Injectors can only be used in atom state factories') - } - - return node as AnyAtomInstance -} - export const setEvaluationContext = (newContext: EvaluationContext) => (evaluationContext = newContext) /** * Prevent new graph edges from being added immediately. Instead, buffer them so - * we can prevent duplicates or unnecessary edges. Call `flushBuffer()` to - * finish buffering. + * we can prevent duplicates or unnecessary edges. Call `flushBuffer(prevNode)` + * (or `destroyBuffer(prevNode)` on error) with the value returned from + * `startBuffer()` to finish buffering. * - * This is used during atom and AtomSelector evaluation to make the graph as + * This is used during atom and selector evaluation to make the graph as * efficient as possible. - * - * Capture the current top of the "stack" before calling this. Example: - * - * ```ts - * const { n, s } = getEvaluationContext() - * ``` */ export const startBuffer = (node: GraphNode) => { const prevNode = evaluationContext.n @@ -171,3 +153,21 @@ export const startBuffer = (node: GraphNode) => { return prevNode } + +/** + * Runs the callback with no reactive tracking and returns its value. + * + * This is a common utility of reactive libraries. It prevents any reactive + * function calls (like `ecosystem.get`, `ecosystem.getNode`, and `signal.get`) + * from registering graph dependencies while the passed callback runs. + */ +export const untrack = (callback: () => T) => { + const { n } = evaluationContext + evaluationContext.n = undefined + + try { + return callback() + } finally { + evaluationContext.n = n + } +} diff --git a/packages/atoms/src/utils/types.ts b/packages/atoms/src/utils/types.ts deleted file mode 100644 index 7d3476c7..00000000 --- a/packages/atoms/src/utils/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: optimize this internal type with single-letter property names -export type InjectorDescriptor = T extends undefined - ? { - cleanup?: () => void - result?: T - type: string - } - : { - cleanup?: () => void - result: T - type: string - } diff --git a/packages/react/test/integrations/injectors.test.tsx b/packages/react/test/integrations/injectors.test.tsx index 43dc0de8..0d7c006d 100644 --- a/packages/react/test/integrations/injectors.test.tsx +++ b/packages/react/test/integrations/injectors.test.tsx @@ -10,7 +10,6 @@ import { injectAtomValue, injectCallback, injectEffect, - injectInvalidate, injectMemo, injectPromise, injectRef, @@ -30,7 +29,6 @@ describe('injectors', () => { injectAtomValue, injectCallback, injectEffect, - injectInvalidate, injectMemo, injectPromise, injectRef, @@ -89,16 +87,16 @@ describe('injectors', () => { expect(instance.s.size).toBe(1) expect(vals).toEqual(['a', 'a', 'a', 'b', 'a', 'b']) expect(cbs).toEqual(['aa', 'aa', 'aa', 'bb', 'aa', 'bb']) - expect(effects).toEqual(['b']) - expect(cleanups).toEqual([]) + expect(effects).toEqual(['a', 'b']) + expect(cleanups).toEqual(['b']) expect(refs).toEqual([ref, ref]) instance.set('c') expect(vals).toEqual(['a', 'a', 'a', 'b', 'a', 'b', 'c', 'a', 'c']) expect(cbs).toEqual(['aa', 'aa', 'aa', 'bb', 'aa', 'bb', 'bb', 'aa', 'bb']) - expect(effects).toEqual(['b', 'c']) - expect(cleanups).toEqual(['c']) + expect(effects).toEqual(['a', 'b', 'c']) + expect(cleanups).toEqual(['b', 'c']) expect(refs).toEqual([ref, ref, ref]) }) @@ -115,7 +113,7 @@ describe('injectors', () => { }) const atom3 = atom('3', () => { - const invalidate = injectInvalidate() + const self = injectSelf() const signal = injectSignal('a') const one = injectAtomValue(atom1) const [two, setTwo] = injectAtomState(atom2) @@ -123,7 +121,11 @@ describe('injectors', () => { vals.push([signal.get(), one, two]) - return api(signal).setExports({ invalidate, set2, setTwo }) + return api(signal).setExports({ + invalidate: () => self.invalidate(), + set2, + setTwo, + }) }) const instance = ecosystem.getInstance(atom3) @@ -162,7 +164,7 @@ describe('injectors', () => { const atom2 = atom('2', () => 2) const atom3 = atom('3', () => { - const invalidate = injectInvalidate() + const self = injectSelf() const instance1 = injectAtomInstance(atom1) const [isReactive, setIsReactive] = injectAtomState(instance1) const signal = injectSignal('a', { reactive: isReactive }) @@ -171,7 +173,7 @@ describe('injectors', () => { vals.push([signal.get(), isReactive, instance2.getOnce()]) return api(signal).setExports({ - invalidate, + invalidate: () => self.invalidate(), setIsReactive, setTwo: (val: StateOf) => instance2.set(val), }) @@ -289,4 +291,44 @@ describe('injectors', () => { ]) expect(whys[2]).toEqual(whys[3]) }) + + test('injectMemo() callback does not track signal usages', () => { + const signal = ecosystem.signal(0) + + const atom1 = atom('1', () => { + return injectMemo(() => signal.get(), [signal.getOnce()]) + }) + + const node1 = ecosystem.getNode(atom1) + + expect(node1.get()).toBe(0) + + signal.set(1) + + expect(node1.get()).toBe(0) + }) + + test('injectEffect() callback does not track signal usages', () => { + const signal = ecosystem.signal(0) + + const atom1 = atom('1', () => { + injectEffect( + () => { + signal.get() + }, + [signal.getOnce()], + { synchronous: true } + ) + + return signal.getOnce() + }) + + const node1 = ecosystem.getNode(atom1) + + expect(node1.get()).toBe(0) + + signal.set(1) + + expect(node1.get()).toBe(0) + }) }) diff --git a/packages/react/test/snippets/ttl.tsx b/packages/react/test/snippets/ttl.tsx index 7683a90d..c39779d2 100644 --- a/packages/react/test/snippets/ttl.tsx +++ b/packages/react/test/snippets/ttl.tsx @@ -4,7 +4,7 @@ import { injectAtomGetters, injectAtomValue, injectEffect, - injectInvalidate, + injectSelf, useAtomValue, } from '@zedux/react' import { atom, injectStore } from '@zedux/stores' @@ -51,14 +51,14 @@ const atom4 = atom( const roll = Math.random() > 0.5 console.log('evaluating atom4', { roll }) const { get } = injectAtomGetters() - const invalidate = injectInvalidate() + const self = injectSelf() const atom3val = get(atom3, ['1']) const otherVal = roll ? get(atom1) : get(atom2) injectEffect(() => { console.log('setting interval for atom4!') const intervalId = setInterval(() => { - invalidate() + self.invalidate() }, 5000) return () => { diff --git a/packages/react/test/stores/dependency-injection.test.tsx b/packages/react/test/stores/dependency-injection.test.tsx index cfebe7e3..fecdf379 100644 --- a/packages/react/test/stores/dependency-injection.test.tsx +++ b/packages/react/test/stores/dependency-injection.test.tsx @@ -47,16 +47,20 @@ const composedStoresAtom = atom('composedStores', () => { describe('using atoms in components', () => { describe('useAtomValue()', () => { - test('returns current state of the atom', () => { - const Test: FC = () => { - const val = useAtomValue(normalAtom) + test('returns current state of the atom', async () => { + let val: number | undefined - expect(val).toBe(0) + const Test: FC = () => { + val = useAtomValue(normalAtom) - return null + return
{val}
} - renderInEcosystem() + const { findByTestId } = renderInEcosystem() + + expect(await findByTestId('a')).toHaveTextContent('0') + + expect(val).toBe(0) }) test('creates a dynamic graph dependency that renders component when atom state changes', async () => { diff --git a/packages/react/test/stores/injectors.test.tsx b/packages/react/test/stores/injectors.test.tsx index 8848e6a4..e9d0dd58 100644 --- a/packages/react/test/stores/injectors.test.tsx +++ b/packages/react/test/stores/injectors.test.tsx @@ -8,7 +8,6 @@ import { injectCallback, injectEcosystem, injectEffect, - injectInvalidate, injectMemo, injectPromise, injectRef, @@ -28,7 +27,6 @@ describe('injectors', () => { injectAtomValue, injectCallback, injectEffect, - injectInvalidate, injectMemo, injectPromise, injectRef, @@ -84,16 +82,16 @@ describe('injectors', () => { expect(vals).toEqual(['a', 'a', 'a', 'b', 'a', 'b']) expect(cbs).toEqual(['aa', 'aa', 'aa', 'bb', 'aa', 'bb']) - expect(effects).toEqual(['b']) - expect(cleanups).toEqual([]) + expect(effects).toEqual(['a', 'b']) + expect(cleanups).toEqual(['b']) expect(refs).toEqual([ref, ref]) instance.setState('c') expect(vals).toEqual(['a', 'a', 'a', 'b', 'a', 'b', 'c', 'a', 'c']) expect(cbs).toEqual(['aa', 'aa', 'aa', 'bb', 'aa', 'bb', 'bb', 'aa', 'bb']) - expect(effects).toEqual(['b', 'c']) - expect(cleanups).toEqual(['c']) + expect(effects).toEqual(['a', 'b', 'c']) + expect(cleanups).toEqual(['b', 'c']) expect(refs).toEqual([ref, ref, ref]) }) @@ -110,7 +108,7 @@ describe('injectors', () => { }) const atom3 = atom('3', () => { - const invalidate = injectInvalidate() + const self = injectSelf() const store = injectStore('a') const one = injectAtomValue(atom1) const [two, setTwo] = injectAtomState(atom2) @@ -118,7 +116,11 @@ describe('injectors', () => { vals.push([store.getState(), one, two]) - return api(store).setExports({ invalidate, set2, setTwo }) + return api(store).setExports({ + invalidate: () => self.invalidate(), + set2, + setTwo, + }) }) const instance = ecosystem.getInstance(atom3) @@ -157,7 +159,7 @@ describe('injectors', () => { const atom2 = atom('2', () => 2) const atom3 = atom('3', () => { - const invalidate = injectInvalidate() + const self = injectSelf() const instance1 = injectAtomInstance(atom1) const [subscribe, setSubscribe] = injectAtomState(instance1) const store = injectStore('a', { subscribe }) @@ -166,7 +168,7 @@ describe('injectors', () => { vals.push([store.getState(), subscribe, instance2.getState()]) return api(store).setExports({ - invalidate, + invalidate: () => self.invalidate(), setSubscribe, setTwo: instance2.setState, }) diff --git a/packages/react/test/units/untrack.test.tsx b/packages/react/test/units/untrack.test.tsx new file mode 100644 index 00000000..7627fdad --- /dev/null +++ b/packages/react/test/units/untrack.test.tsx @@ -0,0 +1,30 @@ +import { atom } from '@zedux/atoms' +import { untrack } from '@zedux/atoms/utils/evaluationContext' +import { ecosystem } from '../utils/ecosystem' + +describe('untrack', () => { + test('prevents graph edges from being created in reactive contexts', () => { + let isTracking = true + const signal = ecosystem.signal(0) + + const atom1 = atom('1', () => { + return isTracking ? signal.get() : untrack(() => signal.get()) + }) + + const node1 = ecosystem.getNode(atom1) + + expect(node1.get()).toBe(0) + expect(node1.s.size).toBe(1) + + signal.set(1) + + expect(node1.get()).toBe(1) + + isTracking = false + node1.invalidate() + signal.set(2) + + expect(node1.get()).toBe(1) + expect(node1.s.size).toBe(0) + }) +}) diff --git a/packages/stores/src/AtomInstance.ts b/packages/stores/src/AtomInstance.ts index 28d39dcf..76aded8c 100644 --- a/packages/stores/src/AtomInstance.ts +++ b/packages/stores/src/AtomInstance.ts @@ -16,11 +16,9 @@ import { } from './types' import { AtomInstance as NewAtomInstance, - Cleanup, Ecosystem, ExportsInfusedSetter, PromiseState, - PromiseStatus, InternalEvaluationReason, Transaction, zi, @@ -84,15 +82,6 @@ export class AtomInstance< // @ts-expect-error same as exports public store: G['Store'] - /** - * @see NewAtomInstance.c - */ - public c?: Cleanup - public _injectors?: InjectorDescriptor[] - public _isEvaluating?: boolean - public _nextInjectors?: InjectorDescriptor[] - public _promiseError?: Error - public _promiseStatus?: PromiseStatus public _stateType?: typeof StoreState | typeof RawState private _bufferedUpdate?: { @@ -131,15 +120,15 @@ export class AtomInstance< // Clean up effect injectors first, then everything else const nonEffectInjectors: InjectorDescriptor[] = [] - this._injectors?.forEach(injector => { - if (injector.type !== '@@zedux/effect') { + this.I?.forEach(injector => { + if (injector.t !== 'injectEffect') { nonEffectInjectors.push(injector) return } - injector.cleanup?.() + injector.c?.() }) nonEffectInjectors.forEach(injector => { - injector.cleanup?.() + injector.c?.() }) this._subscription?.unsubscribe() @@ -208,7 +197,7 @@ export class AtomInstance< * @see NewAtomInstance.j */ public j() { - this._nextInjectors = [] + this.N = [] this._isEvaluating = true // all stores created during evaluation automatically belong to the @@ -269,8 +258,8 @@ export class AtomInstance< } } } catch (err) { - this._nextInjectors.forEach(injector => { - injector.cleanup?.() + this.N.forEach(injector => { + injector.c?.() }) zi.d(prevNode) @@ -297,7 +286,17 @@ export class AtomInstance< this.w = [] } - this._injectors = this._nextInjectors + // kick off side effects and store the new injectors + if (this.N) { + if (!this.e.ssr) { + for (const injector of this.N) { + injector.i?.() + } + } + + this.I = this.N + this.N = undefined + } if (this.l !== 'Initializing') { // let this.i flush updates after status is set to Active diff --git a/packages/stores/src/atoms-port.ts b/packages/stores/src/atoms-port.ts index d5247555..0b90690c 100644 --- a/packages/stores/src/atoms-port.ts +++ b/packages/stores/src/atoms-port.ts @@ -5,17 +5,32 @@ // duplicated classes e.g. in `dist/esm/atoms/classes/...` import type { PromiseState } from '@zedux/atoms' -export type InjectorDescriptor = T extends undefined - ? { - cleanup?: () => void - result?: T - type: string - } - : { - cleanup?: () => void - result: T - type: string - } +export type InjectorDescriptor = { + /** + * `c`leanup - tracks cleanup functions, e.g. those returned from + * `injectEffect` callbacks. + */ + c: (() => void) | undefined + + /** + * `i`nit - a callback that we need to call immediately after evaluation. This + * is how `injectEffect` works (without `synchronous: true`). + */ + i: (() => void) | undefined + + /** + * `t`ype - a unique injector name string. This is how we ensure the user + * didn't add, remove, or reorder injector calls in the state factory. + */ + t: string + + /** + * `v`alue - can be anything. For `injectRef`, this is the ref object. For + * `injectMemo` and `injectEffect`, this keeps track of the memoized value + * and/or dependency arrays. + */ + v: T +} export const prefix = '@@zedux' diff --git a/packages/stores/src/injectStore.ts b/packages/stores/src/injectStore.ts index 82806f58..8a152af3 100644 --- a/packages/stores/src/injectStore.ts +++ b/packages/stores/src/injectStore.ts @@ -1,6 +1,11 @@ import { createStore, zeduxTypes, Store } from '@zedux/core' -import { InjectStoreConfig, PartialAtomInstance, zi } from '@zedux/atoms' -import { InjectorDescriptor, prefix } from './atoms-port' +import { + injectEffect, + injectRef, + injectSelf, + InjectStoreConfig, + PartialAtomInstance, +} from '@zedux/atoms' export const doSubscribe = ( instance: PartialAtomInstance, @@ -84,55 +89,33 @@ export const injectStore: { config?: InjectStoreConfig ): Store (): Store -} = zi.c( - 'injectStore', - ( - instance: PartialAtomInstance, - storeFactory?: State | ((hydration?: State) => Store), - config?: InjectStoreConfig - ) => { - const subscribe = config?.subscribe ?? true +} = ( + storeFactory?: State | ((hydration?: State) => Store), + config?: InjectStoreConfig +) => { + const instance = injectSelf() + const subscribe = config?.subscribe ?? true + + const ref = injectRef>() + + if (!ref.current) { const getStore = typeof storeFactory === 'function' ? (storeFactory as () => Store) : (hydration?: State) => createStore(null, hydration ?? storeFactory) - const store = getStore( + ref.current = getStore( config?.hydrate ? instance.e.hydration?.[instance.id] : undefined ) + } - const subscription = subscribe && doSubscribe(instance, store) - - return { - cleanup: subscription ? () => subscription.unsubscribe() : undefined, - result: store, - type: `${prefix}/store`, - } as InjectorDescriptor> - }, - ( - prevDescriptor: InjectorDescriptor>, - instance: PartialAtomInstance, - storeFactory?: State | ((hydration?: State) => Store), - config?: InjectStoreConfig - ) => { - const subscribe = config?.subscribe ?? true - const prevsubscribe = !!prevDescriptor.cleanup - - if (prevsubscribe === subscribe) return prevDescriptor - - // we were subscribed, now we're not - if (!subscribe) { - // cleanup must be defined here. This cast is fine: - ;(prevDescriptor.cleanup as () => void)() - prevDescriptor.cleanup = undefined - return prevDescriptor - } - - // we weren't subscribed, now we are - const subscription = doSubscribe(instance, prevDescriptor.result) - prevDescriptor.cleanup = () => subscription.unsubscribe() + injectEffect( + () => + subscribe ? doSubscribe(instance, ref.current!).unsubscribe : undefined, + [subscribe], + { synchronous: true } + ) - return prevDescriptor - } -) + return ref.current! +}