From 6785d582f0dd925453fc27ac0bfa7021e49164d7 Mon Sep 17 00:00:00 2001 From: Josh C Date: Mon, 6 Jan 2025 11:43:25 -0500 Subject: [PATCH] feat!: implement proxies, signals, and mapped signals (#147) @affects atoms, core, machines, react, stores --- .vscode/launch.json | 4 +- .vscode/settings.json | 3 + bench/src/frameworks/zedux.ts | 2 +- jest.config.ts | 4 + package.json | 3 +- packages/atoms/README.md | 16 +- packages/atoms/src/classes/AtomApi.ts | 12 +- packages/atoms/src/classes/Ecosystem.ts | 146 ++-- packages/atoms/src/classes/GraphNode.ts | 346 ++++++-- packages/atoms/src/classes/MappedSignal.ts | 218 +++++ packages/atoms/src/classes/Scheduler.ts | 20 +- .../atoms/src/classes/SelectorInstance.ts | 46 +- packages/atoms/src/classes/Signal.ts | 250 ++++++ packages/atoms/src/classes/index.ts | 4 +- .../src/classes/instances/AtomInstance.ts | 600 ++++++------- packages/atoms/src/classes/proxies.ts | 329 +++++++ .../src/classes/templates/AtomTemplate.ts | 9 +- .../src/classes/templates/AtomTemplateBase.ts | 9 +- packages/atoms/src/factories/api.ts | 74 +- packages/atoms/src/factories/atom.ts | 41 +- packages/atoms/src/factories/ion.ts | 61 +- packages/atoms/src/index.ts | 25 + packages/atoms/src/injectors/index.ts | 3 +- .../atoms/src/injectors/injectAtomInstance.ts | 30 +- .../atoms/src/injectors/injectAtomSelector.ts | 6 +- .../atoms/src/injectors/injectAtomState.ts | 18 +- .../atoms/src/injectors/injectAtomValue.ts | 15 +- .../atoms/src/injectors/injectMappedSignal.ts | 67 ++ packages/atoms/src/injectors/injectPromise.ts | 87 +- packages/atoms/src/injectors/injectSignal.ts | 60 ++ packages/atoms/src/types/atoms.ts | 127 +-- packages/atoms/src/types/events.ts | 106 +++ packages/atoms/src/types/index.ts | 199 +++-- packages/atoms/src/utils/evaluationContext.ts | 14 +- packages/atoms/src/utils/general.ts | 4 + packages/core/src/types.ts | 8 +- packages/machines/package.json | 6 +- packages/machines/src/MachineStore.ts | 2 +- .../test/integrations/state-machines.test.tsx | 2 +- packages/machines/test/snippets/api.tsx | 10 +- packages/react/src/hooks/useAtomContext.ts | 12 +- packages/react/src/hooks/useAtomInstance.ts | 16 +- packages/react/src/hooks/useAtomSelector.ts | 8 +- packages/react/src/hooks/useAtomState.ts | 18 +- packages/react/src/hooks/useAtomValue.ts | 14 +- .../test/__snapshots__/types.test.tsx.snap | 72 ++ .../__snapshots__/ecosystem.test.tsx.snap | 139 ++- .../__snapshots__/selection.test.tsx.snap | 10 +- .../test/integrations/ecosystem.test.tsx | 7 +- .../test/integrations/injectors.test.tsx | 87 +- .../test/integrations/lifecycle.test.tsx | 4 +- .../react/test/integrations/plugins.test.tsx | 4 +- .../react/test/integrations/promises.test.tsx | 40 +- .../test/integrations/react-context.test.tsx | 6 +- .../test/integrations/selection.test.tsx | 10 +- .../react/test/integrations/signals.test.tsx | 194 +++++ .../react/test/integrations/suspense.test.tsx | 2 +- packages/react/test/legacy-types.test.tsx | 806 ++++++++++++++++++ packages/react/test/snippets/big-graph.tsx | 9 +- packages/react/test/snippets/context.tsx | 3 +- .../react/test/snippets/contextual-atoms.tsx | 138 +++ packages/react/test/snippets/ecosystem.tsx | 3 +- .../react/test/snippets/inject-promise.tsx | 4 +- packages/react/test/snippets/ions.tsx | 5 +- packages/react/test/snippets/persistence.tsx | 11 +- packages/react/test/snippets/selectors.tsx | 6 +- .../react/test/snippets/stress-testing.tsx | 2 +- packages/react/test/snippets/suspense.tsx | 2 +- packages/react/test/snippets/ttl.tsx | 3 +- .../{units => stores}/AtomInstance.test.tsx | 3 +- .../__snapshots__/graph.test.tsx.snap | 12 +- .../atom-stores.test.tsx | 8 +- .../batching.test.tsx | 6 +- .../dependency-injection.test.tsx | 5 +- .../{integrations => stores}/graph.test.tsx | 6 +- .../{units => stores}/injectStore.test.tsx | 2 +- packages/react/test/stores/injectors.test.tsx | 281 ++++++ .../{integrations => stores}/ssr.test.tsx | 3 +- packages/react/test/types.test.tsx | 665 ++++++++------- packages/react/test/units/Ecosystem.test.tsx | 4 +- .../react/test/units/useAtomInstance.test.tsx | 16 +- .../react/test/units/useAtomSelector.test.tsx | 10 +- packages/react/test/utils/ecosystem.ts | 10 +- packages/stores/README.md | 74 ++ packages/stores/package.json | 57 ++ packages/stores/src/AtomApi.ts | 82 ++ packages/stores/src/AtomInstance.ts | 456 ++++++++++ packages/stores/src/AtomTemplate.ts | 59 ++ packages/stores/src/IonTemplate.ts | 52 ++ packages/stores/src/api.ts | 141 +++ packages/stores/src/atom.ts | 106 +++ packages/stores/src/atoms-port.ts | 52 ++ packages/stores/src/index.ts | 11 + packages/stores/src/injectPromise.ts | 174 ++++ .../injectors => stores/src}/injectStore.ts | 8 +- packages/stores/src/ion.ts | 135 +++ packages/stores/src/types.ts | 181 ++++ packages/stores/tsconfig.build.json | 9 + packages/stores/tsconfig.json | 7 + packages/stores/vite.config.ts | 18 + scripts/release.js | 1 - tsconfig.json | 5 +- 102 files changed, 5842 insertions(+), 1438 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 packages/atoms/src/classes/MappedSignal.ts create mode 100644 packages/atoms/src/classes/Signal.ts create mode 100644 packages/atoms/src/classes/proxies.ts create mode 100644 packages/atoms/src/injectors/injectMappedSignal.ts create mode 100644 packages/atoms/src/injectors/injectSignal.ts create mode 100644 packages/atoms/src/types/events.ts create mode 100644 packages/react/test/__snapshots__/types.test.tsx.snap create mode 100644 packages/react/test/integrations/signals.test.tsx create mode 100644 packages/react/test/legacy-types.test.tsx create mode 100644 packages/react/test/snippets/contextual-atoms.tsx rename packages/react/test/{units => stores}/AtomInstance.test.tsx (96%) rename packages/react/test/{integrations => stores}/__snapshots__/graph.test.tsx.snap (96%) rename packages/react/test/{integrations => stores}/atom-stores.test.tsx (96%) rename packages/react/test/{integrations => stores}/batching.test.tsx (97%) rename packages/react/test/{integrations => stores}/dependency-injection.test.tsx (98%) rename packages/react/test/{integrations => stores}/graph.test.tsx (98%) rename packages/react/test/{units => stores}/injectStore.test.tsx (95%) create mode 100644 packages/react/test/stores/injectors.test.tsx rename packages/react/test/{integrations => stores}/ssr.test.tsx (98%) create mode 100644 packages/stores/README.md create mode 100644 packages/stores/package.json create mode 100644 packages/stores/src/AtomApi.ts create mode 100644 packages/stores/src/AtomInstance.ts create mode 100644 packages/stores/src/AtomTemplate.ts create mode 100644 packages/stores/src/IonTemplate.ts create mode 100644 packages/stores/src/api.ts create mode 100644 packages/stores/src/atom.ts create mode 100644 packages/stores/src/atoms-port.ts create mode 100644 packages/stores/src/index.ts create mode 100644 packages/stores/src/injectPromise.ts rename packages/{atoms/src/injectors => stores/src}/injectStore.ts (94%) create mode 100644 packages/stores/src/ion.ts create mode 100644 packages/stores/src/types.ts create mode 100644 packages/stores/tsconfig.build.json create mode 100644 packages/stores/tsconfig.json create mode 100644 packages/stores/vite.config.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index c09746cb..db9456ff 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,10 +6,10 @@ "type": "node", "request": "launch", "runtimeExecutable": "yarn", - "args": ["jest", "--runInBand"], + "args": ["jest", "--runInBand", "--coverage=false"], "console": "integratedTerminal", "cwd": "${workspaceRoot}", "internalConsoleOptions": "neverOpen" - }, + } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..25fa6215 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/bench/src/frameworks/zedux.ts b/bench/src/frameworks/zedux.ts index 7b89dd2c..014c8a96 100644 --- a/bench/src/frameworks/zedux.ts +++ b/bench/src/frameworks/zedux.ts @@ -12,7 +12,7 @@ export const zeduxFramework: ReactiveFramework = { ) return { - write: v => instance.setState(v), + write: v => instance.set(v), read: () => ecosystem.live.get(instance), } }, diff --git a/jest.config.ts b/jest.config.ts index 5460587c..21e8dbae 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -16,6 +16,8 @@ const jestCompilerOptions: Omit & { '@zedux/machines/*': ['./packages/machines/src/*'], '@zedux/react': ['./packages/react/src'], '@zedux/react/*': ['./packages/react/src/*'], + '@zedux/stores': ['./packages/stores/src'], + '@zedux/stores/*': ['./packages/stores/src/*'], }, } @@ -40,6 +42,8 @@ const config: Config.InitialOptions = { '/packages/machines/test', '/packages/react/src', '/packages/react/test', + '/packages/core/src', + '/packages/core/test', ], setupFilesAfterEnv: ['./jest.setup.ts'], testEnvironment: 'jsdom', diff --git a/package.json b/package.json index 2c1c2dc0..cd6314e0 100644 --- a/package.json +++ b/package.json @@ -70,5 +70,6 @@ }, "workspaces": [ "packages/*" - ] + ], + "packageManager": "yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18" } diff --git a/packages/atoms/README.md b/packages/atoms/README.md index 4e44134d..c9286107 100644 --- a/packages/atoms/README.md +++ b/packages/atoms/README.md @@ -1,6 +1,6 @@ # `@zedux/atoms` -The core atomic model of Zedux. This is a standalone package, meaning it's the only package you need to install to use Zedux's atomic model. It includes the Zedux core store package as well as all APIs related to atoms and ecosystems. +The core atomic model of Zedux. This is a standalone package, meaning it's the only package you need to install to use Zedux's atomic model. It includes the Zedux core store package as well as all APIs related to signals, atoms, and ecosystems. This package is framework-independent, though many of its APIs are heavily inspired by React. @@ -32,10 +32,10 @@ import { atom, createEcosystem } from '@zedux/atoms' const greetingAtom = atom('greeting', 'Hello, World!') const ecosystem = createEcosystem({ id: 'root' }) -const instance = ecosystem.getInstance(greetingAtom) +const instance = ecosystem.getNode(greetingAtom) -instance.store.subscribe(newState => console.log('state updated:', newState)) -instance.setState('Goodbye, World!') +instance.on('change', ({ newState }) => console.log('state updated:', newState)) +instance.set('Goodbye, World!') instance.destroy() ``` @@ -51,12 +51,13 @@ On top of this, `@zedux/atoms` exports the following APIs and many helper types - [`AtomApi`](https://omnistac.github.io/zedux/docs/api/classes/AtomApi) - [`AtomInstance`](https://omnistac.github.io/zedux/docs/api/classes/AtomInstance) -- [`AtomInstanceBase`](https://omnistac.github.io/zedux/docs/api/classes/AtomInstanceBase) - [`AtomTemplate`](https://omnistac.github.io/zedux/docs/api/classes/AtomTemplate) - [`AtomTemplateBase`](https://omnistac.github.io/zedux/docs/api/classes/AtomTemplateBase) - [`Ecosystem`](https://omnistac.github.io/zedux/docs/api/classes/Ecosystem) - [`IonTemplate`](https://omnistac.github.io/zedux/docs/api/classes/IonTemplate) -- [`SelectorCache`](https://omnistac.github.io/zedux/docs/api/classes/SelectorCache) +- [`MappedSignal`](https://omnistac.github.io/zedux/docs/api/classes/MappedSignal) +- [`SelectorInstance`](https://omnistac.github.io/zedux/docs/api/classes/SelectorInstance) +- [`Signal`](https://omnistac.github.io/zedux/docs/api/classes/Signal) - [`ZeduxPlugin`](https://omnistac.github.io/zedux/docs/api/classes/ZeduxPlugin) ### Factories @@ -76,11 +77,12 @@ On top of this, `@zedux/atoms` exports the following APIs and many helper types - [`injectCallback()`](https://omnistac.github.io/zedux/docs/api/injectors/injectCallback) - [`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) - [`injectRef()`](https://omnistac.github.io/zedux/docs/api/injectors/injectRef) - [`injectSelf()`](https://omnistac.github.io/zedux/docs/api/injectors/injectSelf) -- [`injectStore()`](https://omnistac.github.io/zedux/docs/api/injectors/injectStore) +- [`injectSignal()`](https://omnistac.github.io/zedux/docs/api/injectors/injectSignal) - [`injectWhy()`](https://omnistac.github.io/zedux/docs/api/injectors/injectWhy) ### Utils diff --git a/packages/atoms/src/classes/AtomApi.ts b/packages/atoms/src/classes/AtomApi.ts index 9c419bea..0b071d7f 100644 --- a/packages/atoms/src/classes/AtomApi.ts +++ b/packages/atoms/src/classes/AtomApi.ts @@ -1,4 +1,4 @@ -import { is, Store } from '@zedux/core' +import { is } from '@zedux/core' import { AtomInstanceTtl, AtomApiGenerics, @@ -11,14 +11,14 @@ export class AtomApi { public exports?: G['Exports'] public promise: G['Promise'] - public store: G['Store'] + public signal: G['Signal'] public ttl?: AtomInstanceTtl | (() => AtomInstanceTtl) - public value: G['State'] | G['Store'] + public value: G['State'] | G['Signal'] - constructor(value: AtomApi | G['Store'] | G['State']) { + constructor(value: AtomApi | G['Signal'] | G['State']) { this.promise = undefined as G['Promise'] - this.value = value as G['Store'] | G['State'] - this.store = (is(value, Store) ? value : undefined) as G['Store'] + this.value = value as G['Signal'] | G['State'] + this.signal = (value?.izn ? value : undefined) as G['Signal'] if (is(value, AtomApi)) { Object.assign(this, value as AtomApi) diff --git a/packages/atoms/src/classes/Ecosystem.ts b/packages/atoms/src/classes/Ecosystem.ts index 4a8ab09e..fc175d00 100644 --- a/packages/atoms/src/classes/Ecosystem.ts +++ b/packages/atoms/src/classes/Ecosystem.ts @@ -7,15 +7,15 @@ import { AtomGenerics, AtomGetters, AtomGettersBase, - AtomInstanceType, - AtomParamsType, + NodeOf, + ParamsOf, AtomSelectorConfig, AtomSelectorOrConfig, - AtomStateType, + StateOf, Cleanup, DehydrationFilter, EcosystemConfig, - GraphEdgeDetails, + GraphEdgeConfig, GraphViewRecursive, MaybeCleanup, NodeFilter, @@ -23,6 +23,10 @@ import { PartialAtomInstance, Selectable, SelectorGenerics, + EventMap, + None, + InjectSignalConfig, + MapEvents, } from '../types/index' import { External, @@ -46,6 +50,7 @@ import { } from './SelectorInstance' import { AtomTemplateBase } from './templates/AtomTemplateBase' import { AtomInstance } from './instances/AtomInstance' +import { Signal } from './Signal' const defaultMods = Object.keys(pluginActions).reduce((map, mod) => { map[mod as Mod] = 0 @@ -149,9 +154,15 @@ export class Ecosystem | undefined = any> const getInstance: AtomGetters['getInstance'] = ( atom: AtomTemplateBase, params?: G['Params'], - edgeInfo?: GraphEdgeDetails + edgeConfig?: GraphEdgeConfig + ) => getNode(atom, params, edgeConfig) + + const getNode: AtomGetters['getNode'] = ( + template: AtomTemplateBase | GraphNode | AtomSelectorOrConfig, + params?: G['Params'], + edgeConfig?: GraphEdgeConfig ) => { - const instance = this.getNode(atom, params as G['Params']) + 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 @@ -161,8 +172,8 @@ export class Ecosystem | undefined = any> if (node) { bufferEdge( instance, - edgeInfo?.op || 'getInstance', - edgeInfo?.f ?? Static + edgeConfig?.op || 'getNode', + edgeConfig?.f ?? Static ) } @@ -171,8 +182,8 @@ export class Ecosystem | undefined = any> const select: AtomGetters['select'] = ( selectable: S, - ...args: AtomParamsType - ): AtomStateType => { + ...args: ParamsOf + ): StateOf => { const node = getEvaluationContext().n // when called outside a reactive context, select() is just an alias for @@ -189,6 +200,7 @@ export class Ecosystem | undefined = any> ecosystem: this, get, getInstance, + getNode, select, } } @@ -291,8 +303,8 @@ export class Ecosystem | undefined = any> * internal store. * * Destruction will bail out by default if this ecosystem is still being - * provided via an . Pass `true` as the first parameter to - * force destruction anyway. + * provided via an ''. Pass `true` as the first parameter + * to force destruction anyway. */ public destroy(force?: boolean) { if (!force && this._refCount > 0) return @@ -410,18 +422,18 @@ export class Ecosystem | undefined = any> public get( template: A, - params: AtomParamsType - ): AtomStateType + params: ParamsOf + ): StateOf - public get>( - template: A - ): AtomStateType + public get>(template: A): StateOf public get( template: ParamlessTemplate - ): AtomStateType + ): StateOf + + // public get(template: AnyAtomTemplate): G['State'] - public get(instance: I): AtomStateType + public get(node: N): StateOf /** * Returns an atom instance's value. Creates the atom instance if it doesn't @@ -429,7 +441,7 @@ export class Ecosystem | undefined = any> */ public get( atom: A | AnyAtomInstance, - params?: AtomParamsType + params?: ParamsOf ) { if ((atom as GraphNode).izn) { return (atom as GraphNode).get() @@ -437,41 +449,41 @@ export class Ecosystem | undefined = any> const instance = this.getInstance( atom as A, - params as AtomParamsType + params as ParamsOf ) as AnyAtomInstance - return instance.store.getState() + return instance.get() } public getInstance( template: A, - params: AtomParamsType, - edgeInfo?: GraphEdgeDetails // only here for AtomGetters type compatibility - ): AtomInstanceType + params: ParamsOf, + edgeConfig?: GraphEdgeConfig // only here for AtomGetters type compatibility + ): NodeOf public getInstance>( template: A - ): AtomInstanceType + ): NodeOf public getInstance( template: ParamlessTemplate - ): AtomInstanceType + ): NodeOf public getInstance( instance: I, params?: [], - edgeInfo?: GraphEdgeDetails // only here for AtomGetters type compatibility + edgeConfig?: GraphEdgeConfig // only here for AtomGetters type compatibility ): I /** * Returns an atom instance. Creates the atom instance if it doesn't exist * yet. Doesn't register any graph dependencies. * - * TODO: deprecate this in favor of `this.getNode` + * @deprecated in favor of `getNode` */ public getInstance( atom: A | AnyAtomInstance, - params?: AtomParamsType + params?: ParamsOf ) { return this.getNode(atom, params) } @@ -479,16 +491,17 @@ export class Ecosystem | undefined = any> // TODO: Dedupe these overloads // atoms public getNode( - templateOrInstance: AtomTemplateBase | AtomInstance, - params: G['Params'] + templateOrNode: AtomTemplateBase | GraphNode, + params: G['Params'], + edgeConfig?: GraphEdgeConfig // only here for AtomGetters type compatibility ): G['Node'] public getNode>( - templateOrInstance: AtomTemplateBase | AtomInstance + templateOrNode: AtomTemplateBase | GraphNode ): G['Node'] public getNode( - templateOrInstance: ParamlessTemplate | AtomInstance> + templateOrInstance: ParamlessTemplate | GraphNode> ): G['Node'] public getNode(instance: I, params?: []): I @@ -496,11 +509,12 @@ export class Ecosystem | undefined = any> // selectors public getNode( selectable: S, - params: AtomParamsType + params: ParamsOf, + edgeConfig?: GraphEdgeConfig // only here for AtomGetters type compatibility ): S extends AtomSelectorOrConfig ? SelectorInstance<{ - Params: AtomParamsType - State: AtomStateType + Params: ParamsOf + State: StateOf Template: S }> : S @@ -509,8 +523,8 @@ export class Ecosystem | undefined = any> selectable: S ): S extends AtomSelectorOrConfig ? SelectorInstance<{ - Params: AtomParamsType - State: AtomStateType + Params: ParamsOf + State: StateOf Template: S }> : S @@ -519,13 +533,24 @@ export class Ecosystem | undefined = any> selectable: ParamlessTemplate ): S extends AtomSelectorOrConfig ? SelectorInstance<{ - Params: AtomParamsType - State: AtomStateType + Params: ParamsOf + State: StateOf Template: S }> : S - public getNode(instance: I, params?: []): I + public getNode( + node: N, + params?: [], + edgeConfig?: GraphEdgeConfig // only here for AtomGetters type compatibility + ): N + + // catch-all + public getNode( + template: AtomTemplateBase | GraphNode | AtomSelectorOrConfig, + params?: G['Params'], + edgeConfig?: GraphEdgeConfig // only here for AtomGetters type compatibility + ): G['Node'] /** * Returns a graph node. The type is determined by the passed value. @@ -542,21 +567,15 @@ export class Ecosystem | undefined = any> * Doesn't register any graph dependencies. */ public getNode( - template: - | AtomTemplateBase - | AtomInstance - | AtomSelectorOrConfig - | SelectorInstance, + template: AtomTemplateBase | GraphNode | AtomSelectorOrConfig, params?: G['Params'] ) { if ((template as GraphNode).izn) { // if the passed atom instance is Destroyed, get(/create) the // non-Destroyed instance - return (template as AtomInstance).l === 'Destroyed' - ? this.getNode( - (template as AtomInstance).t, - (template as AtomInstance).p - ) + return (template as GraphNode).l === 'Destroyed' && + (template as GraphNode).t + ? this.getNode((template as GraphNode).t, (template as GraphNode).p) : template } @@ -774,8 +793,8 @@ export class Ecosystem | undefined = any> */ public select( selectable: S, - ...args: AtomParamsType - ): AtomStateType { + ...args: ParamsOf + ): StateOf { if (is(selectable, SelectorInstance)) { return (selectable as SelectorInstance).v } @@ -794,6 +813,7 @@ export class Ecosystem | undefined = any> ecosystem: this, get: this.get.bind(this), getInstance: this.getInstance.bind(this), + getNode: this.getNode.bind(this), select: this.select.bind(this), }, ...args @@ -832,6 +852,22 @@ export class Ecosystem | undefined = any> }) } + public signal( + state: State, + config?: Pick, 'events'> + ) { + const id = this._idGenerator.generateId('@signal') + + const signal = new Signal<{ + Events: MapEvents + State: State + }>(this, id, state, config?.events) + + this.n.set(id, signal) + + return signal + } + /** * `u`pdateSelectorRef - swaps out the `t`emplate of a selector instance if * needed. Bails out if args have changed or the selector template ref hasn't @@ -870,7 +906,7 @@ export class Ecosystem | undefined = any> return isSwappingRefs ? instance - : this.getNode(template, resolvedArgs as AtomParamsType) + : this.getNode(template, resolvedArgs as ParamsOf) } /** diff --git a/packages/atoms/src/classes/GraphNode.ts b/packages/atoms/src/classes/GraphNode.ts index 49d06703..a2f9bec4 100644 --- a/packages/atoms/src/classes/GraphNode.ts +++ b/packages/atoms/src/classes/GraphNode.ts @@ -3,10 +3,8 @@ import { AtomGenerics, Cleanup, DehydrationFilter, - DependentCallback, GraphEdge, - GraphEdgeDetails, - GraphEdgeSignal, + GraphEdgeConfig, InternalEvaluationReason, LifecycleStatus, NodeFilter, @@ -15,13 +13,15 @@ import { import { is, Job } from '@zedux/core' import { Ecosystem } from './Ecosystem' import { pluginActions } from '../utils/plugin-actions' -import { - Destroy, - ExplicitExternal, - InternalEvaluationType, - Static, -} from '../utils/general' +import { Destroy, EventSent, ExplicitExternal, Static } from '../utils/general' import { AtomTemplateBase } from './templates/AtomTemplateBase' +import { + ExplicitEvents, + CatchAllListener, + EventEmitter, + SingleEventListener, + ListenableEvents, +} from '../types/events' /** * Actually add an edge to the graph. When we buffer graph updates, we're @@ -81,7 +81,15 @@ export const destroyNodeFinish = (node: GraphNode) => { // if an atom instance is force-destroyed, it could still have dependents. // Inform them of the destruction - scheduleDependents(node, undefined, true, Destroy, true) + 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) { @@ -100,6 +108,30 @@ export const destroyNodeFinish = (node: GraphNode) => { node.e.n.delete(node.id) } +export const handleStateChange = < + G extends Pick +>( + node: GraphNode, + oldState: G['State'], + events?: Partial +) => { + 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) @@ -158,25 +190,16 @@ export const removeEdge = (dependent: GraphNode, dependency: GraphNode) => { } export const scheduleDependents = ( - node: GraphNode, - p: any, + reason: Omit & { + s: NonNullable + }, defer?: boolean, - t?: InternalEvaluationType, - scheduleStaticDeps = false + scheduleStaticDeps?: boolean ) => { - const reason: InternalEvaluationReason = { - p, - r: node.w, - s: node, - t, - } - - for (const [observer, edge] of node.o) { - // Static deps don't update on state change, only on promise change or - // instance force-destruction - if (edge.flags & Static && !scheduleStaticDeps) continue - - observer.r(reason, defer) + 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) } } @@ -202,13 +225,23 @@ export const setNodeStatus = (node: GraphNode, newStatus: LifecycleStatus) => { } export abstract class GraphNode< - G extends Pick = { + G extends Pick = { + Events: any Params: any State: any Template: any } -> implements Job +> implements Job, EventEmitter { + /** + * TS drops the entire `G`enerics type unless it's used somewhere in this + * class definition. With this here, type helpers are able to infer any atom + * generic (and even extra properties) from anything that extends GraphNode. + * + * This will never be populated. It's just for TS. + */ + public _generics?: G + /** * isZeduxNode - used internally to determine if objects are graph nodes. */ @@ -247,60 +280,71 @@ export abstract class GraphNode< */ public abstract id: string - // TODO: Add overloads for specific events and change names to `change` and - // `cycle`. Also add a `passive` option for listeners that don't prevent - // destruction - public on( - eventName: GraphEdgeSignal, - callback: DependentCallback, - edgeDetails?: GraphEdgeDetails & { i?: string } - ): Cleanup - - public on( - callback: DependentCallback, - edgeDetails?: GraphEdgeDetails & { i?: string } + on>( + eventName: E, + callback: SingleEventListener, + edgeDetails?: GraphEdgeConfig ): Cleanup - // Putting this here for now. TS drops the entire `G`enerics type unless it's - // used somewhere in this class definition. With this here, type helpers are - // able to infer any atom generic (and even extra properties) from anything - // that extends GraphNode - public on(placeholder: never): G + on(callback: CatchAllListener, edgeDetails?: GraphEdgeConfig): Cleanup /** - * Register a listener that will be called on this node's events. + * 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. + * + * 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). */ - public on( - eventNameOrCallback: string | DependentCallback, - callbackOrOperation?: - | DependentCallback - | (GraphEdgeDetails & { i?: string }), - maybeConfig?: GraphEdgeDetails & { i?: string } - ): Cleanup | G { - const isFirstOverload = typeof eventNameOrCallback === 'string' - const eventName = isFirstOverload ? eventNameOrCallback : '' - - const callback = isFirstOverload - ? (callbackOrOperation as DependentCallback) - : (eventNameOrCallback as DependentCallback) - - const config = ((isFirstOverload ? maybeConfig : callbackOrOperation) || - {}) as GraphEdgeDetails & { i?: string } - - const id = config.i || this.e._idGenerator.generateNodeId() - const node = new ExternalNode(this.e, id, () => { - const signal = node.i ? 'Destroyed' : 'Updated' - - ;(!eventName || eventName === signal) && - callback(signal, this.get(), node.w[0]) // TODO: make DependentCallbacks receive the full reason list - }) + public on>( + eventNameOrCallback: E | ((eventMap: Partial>) => void), + callbackOrConfig?: SingleEventListener | GraphEdgeConfig, + maybeConfig?: GraphEdgeConfig + ): Cleanup { + const isSingleListener = typeof eventNameOrCallback === 'string' + const eventName = isSingleListener ? eventNameOrCallback : '' + + const callback = isSingleListener + ? (callbackOrConfig as SingleEventListener) + : (eventNameOrCallback as CatchAllListener) + + const { f, op } = ((isSingleListener ? maybeConfig : callbackOrConfig) || + {}) as GraphEdgeConfig + + const operation = op || 'on' + + 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: { newState: this.get(), oldState: reason.p }, + } + ) as ListenableEvents + + // if it's a single event listener and the event isn't in the map, ignore + eventName in eventMap + ? callback(eventMap[eventName] as any, eventMap) + : isSingleListener || (callback as CatchAllListener)(eventMap) + } + + // External nodes can be disabled by setting this `m`ounted property to false + notify.m = true + + const observer = new ExternalNode( + this.e, + this.e._idGenerator.generateNodeId(), + notify, + true + ) - node.u(this, config.op || 'on', config.f ?? ExplicitExternal) + observer.u(this, operation, f ?? ExplicitExternal) - return () => node.k(this) + return () => observer.k(this) } /** @@ -352,8 +396,40 @@ export abstract class GraphNode< * `f`ilter - a function called internally by `ecosystem.findAll()` to * determine whether this node should be included in the output. Also * typically called by `node.d`ehydrate to perform its filtering logic. + * + * This is made to be universally compatible with all Zedux's built-in node + * classes. Custom nodes (e.g. that extend `Signal`) may need to override it */ - public abstract f(options?: NodeFilter): boolean | undefined | void + public f(options?: NodeFilter): boolean | undefined | void { + const { id, t } = this + const lowerCaseId = id.toLowerCase() + const { + exclude = [], + excludeFlags = [], + include = [], + includeFlags = [], + } = normalizeNodeFilter(options) + + const isExcluded = + exclude.some(templateOrKey => + typeof templateOrKey === 'string' + ? lowerCaseId.includes(templateOrKey.toLowerCase()) + : (t?.key && (templateOrKey as AtomTemplateBase)?.key === t?.key) || + templateOrKey === t + ) || excludeFlags.some(flag => t.flags?.includes(flag)) + + return ( + !isExcluded && + ((!include.length && !includeFlags.length) || + include.some(templateOrKey => + typeof templateOrKey === 'string' + ? lowerCaseId.includes(templateOrKey.toLowerCase()) + : (t?.key && (templateOrKey as AtomTemplateBase)?.key === t?.key) || + templateOrKey === t + ) || + includeFlags.some(flag => t.flags?.includes(flag))) + ) + } /** * `h`ydrate - a function called internally by `ecosystem.hydrate()` to @@ -418,8 +494,7 @@ export abstract class GraphNode< * `r`un - evaluate the graph node. If its value "changes", this in turn runs * this node's dependents, recursing down the graph tree. * - * If `defer` is true, schedule the evaluation rather than running it right - * away + * If `defer` is true, make the scheduler set a timeout to run the evaluation. */ public abstract r(reason: InternalEvaluationReason, defer?: boolean): void @@ -446,32 +521,79 @@ export abstract class GraphNode< } export class ExternalNode 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 */ public i?: GraphNode - p: undefined - t: undefined + + /** + * @see GraphNode.p external nodes don't have params + */ + public p: undefined + + /** + * @see GraphNode.t external nodes don't have templates + */ + public t: undefined constructor( + /** + * @see GraphNode.e + */ public readonly e: Ecosystem, + + /** + * @see GraphNode.id + */ public readonly id: string, /** - * `n`otify - tell the external node there's an update + * `n`otify - tell the creator of this external node there's an update. */ - public readonly n: ((newObjectReference: Record) => void) & { + 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') } - destroy(skipUpdate?: boolean) { + /** + * @see GraphNode.destroy + */ + public destroy(skipUpdate?: boolean) { if (!this.i) return if (this.w.length) this.e._scheduler.unschedule(this) @@ -487,13 +609,36 @@ export class ExternalNode extends GraphNode { skipUpdate || this.j() } - get() {} - d() {} - f() {} - h() {} + /** + * @see GraphNode.get external nodes have no own value. Their "value" is the + * value of the single node they depend on. Access that directly instead. + */ + public get() {} + + /** + * @see GraphNode.d can't dehydrate external nodes + */ + public d() {} + + /** + * @see GraphNode.f `ecosystem.findAll()` never returns external nodes + */ + public f() {} + + /** + * @see GraphNode.j can't hydrate external nodes + */ + public h() {} - j() { - this.n.m && this.n({}) + /** + * @see GraphNode.j + */ + public j() { + if (this.n.m) { + for (const reason of this.w) { + this.n(reason) + } + } this.w = [] } @@ -515,19 +660,36 @@ export class ExternalNode extends GraphNode { source === this.i && this.destroy(true) } - m() { + /** + * @see GraphNode.m + */ + public m() { this.destroy() } - r(reason: InternalEvaluationReason, defer?: boolean) { - this.w.push(reason) === 1 && this.e._scheduler.schedule(this, defer) + /** + * @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) + } } /** * `u`pdateEdge - ExternalNodes maintain a single edge on a source node. But * the source can change. Call this to update it if needed. */ - u(source: GraphNode, operation: string, flags: number) { + public u(source: GraphNode, operation: string, flags: number) { this.i && removeEdge(this, this.i) this.i = source diff --git a/packages/atoms/src/classes/MappedSignal.ts b/packages/atoms/src/classes/MappedSignal.ts new file mode 100644 index 00000000..a7e6d1cc --- /dev/null +++ b/packages/atoms/src/classes/MappedSignal.ts @@ -0,0 +1,218 @@ +import { Settable } from '@zedux/core' +import { + AtomGenerics, + ExplicitEvents, + InternalEvaluationReason, + Mutatable, + SendableEvents, + Transaction, +} from '../types/index' +import { + destroyBuffer, + flushBuffer, + getEvaluationContext, + startBuffer, +} from '../utils/evaluationContext' +import { Ecosystem } from './Ecosystem' +import { Signal } from './Signal' +import { recursivelyMutate, recursivelyProxy } from './proxies' + +export type SignalMap = Record + +export class MappedSignal< + G extends Pick & { + Params?: any + Template?: any + } = { + Events: any + State: any + } +> extends Signal { + /** + * `I`dsToKeys - maps wrapped signal ids to the keys they control in this + * wrapper signal's state. + */ + public I: Record = {} + + /** + * `N`extState - tracks changes in wrapped signals as they cause updates in + * this signal so we can efficiently set the new state when this signal + * evaluates. + */ + public N?: G['State'] + + constructor( + /** + * @see Signal.e + */ + public readonly e: Ecosystem, + + /** + * @see Signal.id + */ + public readonly 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) + + throw e + } finally { + flushBuffer(n, s) + } + } + + public mutate( + mutatable: Mutatable, + 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 + } + + // public send>(eventName: E): void + + // public send( + // eventName: E, + // payload: G['Events'][E] + // ): void + + /** + * @see Signal.send + * + * like atoms, mapped signals don't have events themselves, but they inherit + * them from the signals they wrap. + * + * This forwards events on to all inner signals that expect them. + */ + public send( + eventName: E, + payload?: G['Events'][E] + ) { + for (const signal of Object.values(this.M)) { + signal.E?.[eventName] && signal.send(eventName, payload, true) + } + + // flush once now that all nodes are scheduled + this.e._scheduler.flush() + } + + public set( + settable: Settable, + events?: Partial + ) { + const newState = + typeof settable === 'function' + ? (settable as (state: G['State']) => G['State'])(this.v) + : settable + + 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) + } + } + } + + /** + * @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) + this.w = [] + } + + /** + * @see Signal.r + */ + public r(reason: InternalEvaluationReason, defer?: boolean) { + 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.I[reason.s.id]] = reason.s.get() + + // 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) + } +} diff --git a/packages/atoms/src/classes/Scheduler.ts b/packages/atoms/src/classes/Scheduler.ts index 042e72d4..5a0c4199 100644 --- a/packages/atoms/src/classes/Scheduler.ts +++ b/packages/atoms/src/classes/Scheduler.ts @@ -129,19 +129,15 @@ export class Scheduler implements SchedulerInterface { * Schedule an EvaluateGraphNode (2) or UpdateExternalDependent (3) job */ private insertJob(newJob: Job) { - const flags = newJob.F ?? 0 const weight = newJob.W ?? 0 const index = this.findIndex(job => { if (job.T !== newJob.T) return +(newJob.T - job.T > 0) || -1 // 1 or -1 - // EvaluateGraphNode (2) jobs use weight comparison - if (job.W) { - return weight < job.W ? -1 : +(weight > job.W) // + = 0 or 1 - } - - // UpdateExternalDependent (3) jobs use flags comparison - return flags < (job.F as number) ? -1 : +(flags > (job.F as number)) + // EvaluateGraphNode (2) and UpdateExternalDependent (3) jobs use weight + // comparison. `W` will always be defined here. TODO: use discriminated + // union types to reflect this + return weight < job.W! ? -1 : +(weight > job.W!) // + = 0 or 1 }) if (index === -1) { @@ -176,20 +172,12 @@ export class Scheduler implements SchedulerInterface { : '_isRunning' const nows = this.nows - // this._runStartTime = performance.now() - // let counter = 0 this[runningKey] = true try { while (jobs.length) { const job = (nows.length ? nows : jobs).shift() as Job job.j() - - // this "break" idea could only break for "full" jobs, not "now" jobs - // if (!(++counter % 20) && performance.now() - this._runStartTime >= 100) { - // setTimeout(() => this.runJobs()) - // break - // } } } finally { this[runningKey] = false diff --git a/packages/atoms/src/classes/SelectorInstance.ts b/packages/atoms/src/classes/SelectorInstance.ts index 78f6ff5e..fe9bd0f8 100644 --- a/packages/atoms/src/classes/SelectorInstance.ts +++ b/packages/atoms/src/classes/SelectorInstance.ts @@ -1,11 +1,9 @@ -import { is } from '@zedux/core' import { AtomSelector, AtomSelectorConfig, AtomSelectorOrConfig, DehydrationFilter, InternalEvaluationReason, - NodeFilter, SelectorGenerics, } from '../types/index' import { @@ -21,11 +19,9 @@ import { destroyNodeFinish, destroyNodeStart, GraphNode, - normalizeNodeFilter, scheduleDependents, setNodeStatus, } from './GraphNode' -import { AtomTemplateBase } from './templates/AtomTemplateBase' const defaultResultsComparator = (a: any, b: any) => a === b @@ -79,7 +75,7 @@ export const runSelector = ( if (isInitializing) { setNodeStatus(node, 'Active') } else if (!resultsComparator(result, oldState)) { - if (!suppressNotify) scheduleDependents(node, oldState) + if (!suppressNotify) scheduleDependents({ p: oldState, s: node }) if (_mods.stateChanged) { modBus.dispatch( @@ -130,9 +126,14 @@ export class SelectorInstance< State: any Template: any } -> extends GraphNode { +> extends GraphNode { public static $$typeof = Symbol.for(`${prefix}/SelectorInstance`) + /** + * `v`alue - the current cached selector result + */ + public v?: G['State'] + constructor( /** * @see GraphNode.e @@ -149,6 +150,9 @@ export class SelectorInstance< * @see GraphNode.t */ public t: G['Template'], + /** + * @see GraphNode.p + */ public p: G['Params'] ) { super() @@ -179,31 +183,6 @@ export class SelectorInstance< if (this.f(options)) return this.get() } - /** - * @see GraphNode.f - */ - public f(options?: NodeFilter) { - const { id, t } = this - const lowerCaseId = id.toLowerCase() - const { exclude = [], include = [] } = normalizeNodeFilter(options) - - return ( - !exclude.some(templateOrKey => - typeof templateOrKey === 'string' - ? lowerCaseId.includes(templateOrKey.toLowerCase()) - : !is(templateOrKey, AtomTemplateBase) && - (templateOrKey as AtomSelectorOrConfig) === t - ) && - (!include.length || - include.some(templateOrKey => - typeof templateOrKey === 'string' - ? lowerCaseId.includes(templateOrKey.toLowerCase()) - : !is(templateOrKey, AtomTemplateBase) && - (templateOrKey as AtomSelectorOrConfig) === t - )) - ) - } - /** * @see GraphNode.h * @@ -232,9 +211,4 @@ export class SelectorInstance< public r(reason: InternalEvaluationReason, defer?: boolean) { this.w.push(reason) === 1 && this.e._scheduler.schedule(this, defer) } - - /** - * `v`alue - the current cached selector result - */ - public v?: G['State'] } diff --git a/packages/atoms/src/classes/Signal.ts b/packages/atoms/src/classes/Signal.ts new file mode 100644 index 00000000..15870759 --- /dev/null +++ b/packages/atoms/src/classes/Signal.ts @@ -0,0 +1,250 @@ +import { Settable } from '@zedux/core' +import { + AtomGenerics, + ExplicitEvents, + InternalEvaluationReason, + Mutatable, + SendableEvents, + Transaction, + UndefinedEvents, +} from '../types/index' +import { EventSent } from '../utils/general' +import { Ecosystem } from './Ecosystem' +import { + destroyNodeFinish, + destroyNodeStart, + GraphNode, + handleStateChange, + scheduleDependents, +} from './GraphNode' +import { recursivelyMutate, recursivelyProxy } from './proxies' + +export class Signal< + G extends Pick & { + Params?: any + Template?: any + } = { + Events: any + State: any + } +> extends GraphNode< + G & { + Params: G extends { Params: infer P } ? P : undefined + Template: G extends { Template: infer T } ? T : undefined + } +> { + /** + * @see GraphNode.p + */ + // @ts-expect-error params are not defined by signals, so this will always be + // undefined here, doesn't matter that we don't specify it in the constructor. + // Subclasses like `AtomInstance` do specify it + public p: G['Params'] + + /** + * @see GraphNode.t + */ + // @ts-expect-error this is undefined for signals, only defined by subclasses + public t: G['Template'] + + public constructor( + /** + * @see GraphNode.e + */ + public readonly e: Ecosystem, + + /** + * @see GraphNode.id + */ + public readonly id: string, + + /** + * `v`alue - the current state of this signal. + */ + public v: G['State'], + + /** + * `E`ventMap - an object mapping all custom event names of this signal to + * 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] } + ) { + super() + } + + /** + * @see GraphNode.destroy + */ + public destroy(force?: boolean) { + destroyNodeStart(this, force) && destroyNodeFinish(this) + } + + /** + * @see GraphNode.get + */ + public get() { + return this.v + } + + /** + * Sets up a proxy that listens to all mutations on this signal's state in the + * passed callback. + * + * If the state shape is a normal JS object, this method also accepts an + * object shorthand (nested indefinitely as long as all nested fields are + * normal JS objects): + * + * ```ts + * mySignal.mutate({ a: { b: 1 } }) + * // is equivalent to: + * mySignal.mutate(state => { + * state.a.b = 1 + * }) + * ``` + * + * Accepts an optional second `events` object param. Any events specified here + * will be sent (along with the native `change` and `mutate` events if state + * changed) to event listeners of this signal. + */ + public mutate( + mutatable: Mutatable, + 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 + } + + public send>>(eventName: E): void + + public send>( + eventName: E, + payload: SendableEvents[E], + defer?: boolean + ): 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 + ) { + // TODO: maybe safeguard against users sending unrecognized events here + // (especially `send`ing an ImplicitEvent would break everything) + const events = + typeof eventNameOrMap === 'object' + ? eventNameOrMap + : { [eventNameOrMap]: payload } + + scheduleDependents({ e: events, s: this, t: EventSent }) + + defer || this.e._scheduler.flush() + } + + /** + * Completely overwrites the previous value of this signal with the passed + * value. + * + * Accepts a function overload to set new state given the current state. + * + * Accepts an optional second `events` object param. Any events specified here + * will be sent (along with the native `change` event if state changed) to + * event listeners of this signal. + */ + public set( + settable: Settable, + events?: Partial + ) { + const oldState = this.v + const newState = (this.v = + typeof settable === 'function' + ? (settable as (state: G['State']) => G['State'])(oldState) + : settable) + + newState === oldState || handleStateChange(this, oldState, events) + } + + /** + * @see GraphNode.d + * + * TODO: When dehydrating, we could specifically not dehydrate atoms that wrap + * signals and instead dehydrate the signal. Then that signal would rehydrate + * itself. Would require signals to only use an incrementing id like + * `@signal(atom-id)-1` + */ + public d() {} + + /** + * @see GraphNode.h + */ + public h(val: any) {} + + /** + * @see GraphNode.j a noop - signals are never scheduled as jobs - they have + * no sources and nothing to evaluate + */ + public j() {} + + /** + * @see GraphNode.m Signals are always destroyed when no longer in use + */ + public m() { + this.destroy() + } + + /** + * @see GraphNode.r a noop - signals have nothing to evaluate + */ + public r(reason: InternalEvaluationReason, defer?: boolean) {} +} diff --git a/packages/atoms/src/classes/index.ts b/packages/atoms/src/classes/index.ts index 917e5df1..744bc1d7 100644 --- a/packages/atoms/src/classes/index.ts +++ b/packages/atoms/src/classes/index.ts @@ -4,6 +4,8 @@ export * from './templates/AtomTemplate' export * from './templates/AtomTemplateBase' export * from './AtomApi' export * from './Ecosystem' -export * from './GraphNode' +export { ExternalNode, GraphNode } from './GraphNode' +export * from './MappedSignal' export * from './SelectorInstance' +export * from './Signal' export * from './ZeduxPlugin' diff --git a/packages/atoms/src/classes/instances/AtomInstance.ts b/packages/atoms/src/classes/instances/AtomInstance.ts index 20d9f51b..988f83b5 100644 --- a/packages/atoms/src/classes/instances/AtomInstance.ts +++ b/packages/atoms/src/classes/instances/AtomInstance.ts @@ -1,15 +1,5 @@ -import { - ActionChain, - createStore, - Dispatchable, - zeduxTypes, - is, - Observable, - RecursivePartial, - Settable, - Store, - Subscription, -} from '@zedux/core' +import { is, Observable, Settable } from '@zedux/core' +import { Mutatable, SendableEvents } from '@zedux/atoms/types/events' import { AtomGenerics, AtomGenericsToAtomApiGenerics, @@ -19,12 +9,17 @@ import { PromiseState, PromiseStatus, DehydrationFilter, - NodeFilter, DehydrationOptions, AnyAtomGenerics, InternalEvaluationReason, + ExplicitEvents, } from '@zedux/atoms/types/index' -import { Invalidate, prefix, PromiseChange } from '@zedux/atoms/utils/general' +import { + EventSent, + Invalidate, + prefix, + PromiseChange, +} from '@zedux/atoms/utils/general' import { pluginActions } from '@zedux/atoms/utils/plugin-actions' import { getErrorPromiseState, @@ -38,8 +33,7 @@ import { AtomTemplateBase } from '../templates/AtomTemplateBase' import { destroyNodeFinish, destroyNodeStart, - GraphNode, - normalizeNodeFilter, + handleStateChange, scheduleDependents, } from '../GraphNode' import { @@ -48,40 +42,101 @@ import { getEvaluationContext, startBuffer, } from '@zedux/atoms/utils/evaluationContext' +import { Signal } from '../Signal' + +/** + * A standard atom's value can be one of: + * + * - A raw value + * - A signal instance + * - A function that returns a raw value + * - A function that returns a signal instance + * - A function that returns an atom api + */ +const evaluate = >( + instance: AtomInstance +) => { + const { _value } = instance.t -const StoreState = 1 -const RawState = 2 + if (typeof _value !== 'function') { + return _value + } -const getStateType = (val: any) => { - if (is(val, Store)) return StoreState + try { + const val = ( + _value as ( + ...params: G['Params'] + ) => Signal | G['State'] | AtomApi> + )(...instance.p) - return RawState -} + if (!is(val, AtomApi)) return val as Signal | G['State'] -const getStateStore = < - State = any, - StoreType extends Store = Store, - P extends State | StoreType = State | StoreType ->( - factoryResult: P -) => { - const stateType = getStateType(factoryResult) - - const stateStore = - stateType === StoreState - ? (factoryResult as unknown as StoreType) - : (createStore() as StoreType) - - // define how we populate our store (doesn't apply to user-supplied stores) - if (stateType === RawState) { - stateStore.setState( - typeof factoryResult === 'function' - ? () => factoryResult as State - : (factoryResult as unknown as State) + const api = (instance.api = val as AtomApi< + AtomGenericsToAtomApiGenerics + >) + + // Exports can only be set on initial evaluation + if (instance.l === 'Initializing' && api.exports) { + instance.exports = api.exports + } + + // if api.value is a promise, we ignore api.promise + if (typeof (api.value as unknown as Promise)?.then === 'function') { + return setPromise(instance, api.value as unknown as Promise, true) + } else if (api.promise) { + setPromise(instance, api.promise) + } + + return api.value as Signal | G['State'] + } catch (err) { + console.error( + `Zedux: Error while evaluating atom "${instance.t.key}" with params:`, + instance.p, + err ) + + throw err } +} - return [stateType, stateStore] as const +const setPromise = >( + instance: AtomInstance, + promise: Promise, + isStateUpdater?: boolean +) => { + const currentState = instance.get() + if (promise === instance.promise) return currentState + + instance.promise = promise as G['Promise'] + + // since we're the first to chain off the returned promise, we don't need to + // track the chained promise - it will run first, before React suspense's + // `.then` on the thrown promise, for example + promise + .then(data => { + if (instance.promise !== promise) return + + instance._promiseStatus = 'success' + if (!isStateUpdater) return + + instance.set(getSuccessPromiseState(data) as unknown as G['State']) + }) + .catch(error => { + if (instance.promise !== promise) return + + instance._promiseStatus = 'error' + instance._promiseError = error + if (!isStateUpdater) return + + instance.set(getErrorPromiseState(error) as unknown as G['State']) + }) + + const state: PromiseState = getInitialPromiseState(currentState?.data) + instance._promiseStatus = state.status + + scheduleDependents({ s: instance, t: PromiseChange }, true, true) + + return state as unknown as G['State'] } export class AtomInstance< @@ -90,8 +145,12 @@ export class AtomInstance< } = AnyAtomGenerics<{ Node: any }> -> extends GraphNode { +> extends Signal { public static $$typeof = Symbol.for(`${prefix}/AtomInstance`) + + /** + * @see Signal.l + */ public l: LifecycleStatus = 'Initializing' public api?: AtomApi> @@ -103,11 +162,20 @@ export class AtomInstance< // @ts-expect-error same as exports public promise: G['Promise'] - // @ts-expect-error same as exports - public store: G['Store'] + /** + * `b`ufferedEvents - when the wrapped signal emits events, we + */ + public b?: Partial /** - * @see GraphNode.c + * `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. + * If this is defined, this atom becomes a thin wrapper around this signal. + */ + public S?: Signal + + /** + * @see Signal.c */ public c?: Cleanup public _createdAt: number @@ -116,45 +184,38 @@ export class AtomInstance< public _nextInjectors?: InjectorDescriptor[] public _promiseError?: Error public _promiseStatus?: PromiseStatus - public _stateType?: typeof StoreState | typeof RawState - - private _bufferedUpdate?: { - newState: G['State'] - oldState?: G['State'] - action: ActionChain - } - private _subscription?: Subscription constructor( /** - * @see GraphNode.e + * @see Signal.e */ public readonly e: Ecosystem, /** - * @see GraphNode.t + * @see Signal.t */ public readonly t: G['Template'], /** - * @see GraphNode.id + * @see Signal.id */ public readonly id: string, /** - * @see GraphNode.p + * @see Signal.p */ public readonly p: G['Params'] ) { - super() + super(e, id, undefined) // TODO NOW: fix this undefined this._createdAt = e._idGenerator.now() } /** - * @see GraphNode.destroy + * @see Signal.destroy */ 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) @@ -162,36 +223,25 @@ export class AtomInstance< } injector.cleanup?.() }) + nonEffectInjectors.forEach(injector => { injector.cleanup?.() }) - this._subscription?.unsubscribe() destroyNodeFinish(this) } /** - * An alias for `.store.dispatch()` - */ - public dispatch = (action: Dispatchable) => this.store.dispatch(action) - - /** - * An alias for `instance.store.getState()`. Returns the current state of this - * atom instance's store. - * - * @deprecated - use `instance.get()` instead - */ - public getState(): G['State'] { - return this.store.getState() - } - - /** - * @see GraphNode.get + * @see Signal.get * - * An alias for `instance.store.getState()`. + * If this atom is wrapping an internal signal, returns the current value of + * that signal. Otherwise, this atom _is_ the signal, and this returns its + * value. */ public get() { - return this.store.getState() + const { S, v } = this + + return S ? S.get() : v } /** @@ -205,21 +255,50 @@ export class AtomInstance< } /** - * An alias for `.store.setState()` + * @see Signal.mutate + * + * If this atom is wrapping an internal signal, calls `mutate` on the wrapped + * signal. Otherwise, this atom _is_ the signal, and the mutation is applied + * to this atom's own value. + */ + public mutate( + mutatable: Mutatable, + events?: Partial + ) { + return this.S + ? this.S.mutate(mutatable, events) + : super.mutate(mutatable, events) + } + + /** + * @see Signal.send atoms don't have events themselves, but they + * inherit them from any signal returned from the state factory. + * + * This is a noop if no signal was returned (the atom's types reflect this). */ - public setState = (settable: Settable, meta?: any): G['State'] => - this.store.setState(settable, meta) + public send>( + eventName: E, + payload?: G['Events'][E] + ) { + this.S?.send(eventName, payload as SendableEvents[E]) + } /** - * An alias for `.store.setStateDeep()` + * @see Signal.set + * + * If this atom is wrapping an internal signal, calls `set` on the wrapped + * signal. Otherwise, this atom _is_ the signal, and the state change is + * applied to this atom's own value. */ - public setStateDeep = ( - settable: Settable, G['State']>, - meta?: any - ): G['State'] => this.store.setStateDeep(settable, meta) + public set( + settable: Settable, + events?: Partial + ) { + return this.S ? this.S.set(settable, events) : super.set(settable, events) + } /** - * @see GraphNode.d + * @see Signal.d */ public d(options?: DehydrationFilter) { if (!this.f(options)) return @@ -236,51 +315,10 @@ export class AtomInstance< } /** - * @see GraphNode.f - */ - public f(options?: NodeFilter) { - const { id, t } = this - const lowerCaseId = id.toLowerCase() - const { - exclude = [], - excludeFlags = [], - include = [], - includeFlags = [], - } = normalizeNodeFilter(options) - - if ( - exclude.some(templateOrKey => - typeof templateOrKey === 'string' - ? lowerCaseId.includes(templateOrKey.toLowerCase()) - : is(templateOrKey, AtomTemplateBase) && - t.key === (templateOrKey as AtomTemplateBase).key - ) || - excludeFlags.some(flag => t.flags?.includes(flag)) - ) { - return false - } - - if ( - (!include.length && !includeFlags.length) || - include.some(templateOrKey => - typeof templateOrKey === 'string' - ? lowerCaseId.includes(templateOrKey.toLowerCase()) - : is(templateOrKey, AtomTemplateBase) && - t.key === (templateOrKey as AtomTemplateBase).key - ) || - includeFlags.some(flag => t.flags?.includes(flag)) - ) { - return true - } - - return false - } - - /** - * @see GraphNode.h + * @see Signal.h */ public h(val: any) { - this.setState(this.t.hydrate ? this.t.hydrate(val) : val) + this.set(this.t.hydrate ? this.t.hydrate(val) : val) } /** @@ -289,7 +327,7 @@ export class AtomInstance< */ public i() { const { n, s } = getEvaluationContext() - this._doEvaluate() + this.j() this._setStatus('Active') flushBuffer(n, s) @@ -301,18 +339,77 @@ export class AtomInstance< return } - this.store.setState(hydration) + this.set(hydration) } /** - * @see GraphNode.j + * @see Signal.j */ public j() { - this._doEvaluate() + const { n, s } = getEvaluationContext() + this._nextInjectors = [] + this._isEvaluating = true + startBuffer(this) + + try { + const newFactoryResult = evaluate(this) + + if (this.l === 'Initializing') { + if ((newFactoryResult as Signal)?.izn) { + this.S = newFactoryResult + this.v = (newFactoryResult as Signal).v + } else { + this.v = newFactoryResult + } + } else { + if ( + DEV && + (this.S + ? newFactoryResult !== this.S + : (newFactoryResult as Signal)?.izn) + ) { + throw new Error( + `Zedux: state factories must either return the same signal or a non-signal value every evaluation. Check the implementation of atom "${this.id}".` + ) + } + + const oldState = this.v + this.v = this.S ? newFactoryResult.v : newFactoryResult + + this.v === oldState || handleStateChange(this, oldState, this.b) + } + } catch (err) { + this._nextInjectors.forEach(injector => { + injector.cleanup?.() + }) + + destroyBuffer(n, s) + + throw err + } finally { + this._isEvaluating = false + + // even if evaluation errored, we need to update dependents if the store's + // state changed + if (this.S && this.S.v !== this.v) { + const oldState = this.v + this.v = this.S.v + + handleStateChange(this, oldState, this.b) + } + + this.b = undefined + this.w = [] + } + + this._injectors = this._nextInjectors + + // let this.i flush updates after status is set to Active + this.l === 'Initializing' || flushBuffer(n, s) } /** - * @see GraphNode.m + * @see Signal.m */ public m() { const ttl = this._getTtl() @@ -368,16 +465,35 @@ export class AtomInstance< } /** - * @see GraphNode.r + * @see Signal.r */ - public r(reason: InternalEvaluationReason, shouldSetTimeout?: boolean) { + public r(reason: InternalEvaluationReason, defer?: boolean) { + if (reason.t === EventSent) { + // forward events from `this.S`ignal to observers of this atom instance. + // Ignore events from other sources (shouldn't happen, but either way + // those shouldn't be forwarded). Use `super.send` for this 'cause + // `this.send` captures events and sends them the other way (up to + // `this.S`ignal) + reason.s === this.S && super.send(reason.e as SendableEvents) + + return + } + // TODO: Any calls in this case probably indicate a memory leak on the // user's part. Notify them. TODO: Can we pause evaluations while // status is Stale (and should we just always evaluate once when // waking up a stale atom)? if (this.l !== 'Destroyed' && this.w.push(reason) === 1) { // refCount just hit 1; we haven't scheduled a job for this node yet - this.e._scheduler.schedule(this, shouldSetTimeout) + this.e._scheduler.schedule(this, defer) + + // 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.s === this.S && reason.e) { + this.b = this.b + ? { ...this.b, ...(reason.e as typeof this.b) } + : (reason.e as unknown as typeof this.b) + } } } @@ -385,148 +501,11 @@ export class AtomInstance< public get _infusedSetter() { if (this._set) return this._set const setState: any = (settable: any, meta?: any) => - this.setState(settable, meta) + this.set(settable, meta) return (this._set = Object.assign(setState, this.exports)) } - private _doEvaluate() { - const { n, s } = getEvaluationContext() - this._nextInjectors = [] - let newFactoryResult: G['Store'] | G['State'] - this._isEvaluating = true - startBuffer(this) - - try { - newFactoryResult = this._evaluate() - - if (this.l === 'Initializing') { - ;[this._stateType, this.store] = getStateStore(newFactoryResult) - - this._subscription = this.store.subscribe( - (newState, oldState, action) => { - // buffer updates (with cache size of 1) if this instance is currently - // evaluating - if (this._isEvaluating) { - this._bufferedUpdate = { newState, oldState, action } - return - } - - this._handleStateChange(newState, oldState, action) - } - ) - } else { - const newStateType = getStateType(newFactoryResult) - - if (DEV && newStateType !== this._stateType) { - throw new Error( - `Zedux: atom factory for atom "${this.t.key}" returned a different type than the previous evaluation. This can happen if the atom returned a store initially but then returned a non-store value on a later evaluation or vice versa` - ) - } - - if ( - DEV && - newStateType === StoreState && - newFactoryResult !== this.store - ) { - throw new Error( - `Zedux: atom factory for atom "${this.t.key}" returned a different store. Did you mean to use \`injectStore()\`, or \`injectMemo()\`?` - ) - } - - // there is no way to cause an evaluation loop when the StateType is Value - if (newStateType === RawState) { - this.store.setState( - typeof newFactoryResult === 'function' - ? () => newFactoryResult as G['State'] - : (newFactoryResult as G['State']) - ) - } - } - } catch (err) { - this._nextInjectors.forEach(injector => { - injector.cleanup?.() - }) - - destroyBuffer(n, s) - - throw err - } finally { - this._isEvaluating = false - - // even if evaluation errored, we need to update dependents if the store's - // state changed - if (this._bufferedUpdate) { - this._handleStateChange( - this._bufferedUpdate.newState, - this._bufferedUpdate.oldState, - this._bufferedUpdate.action - ) - this._bufferedUpdate = undefined - } - - this.w = [] - } - - this._injectors = this._nextInjectors - - if (this.l !== 'Initializing') { - // let this.i flush updates after status is set to Active - flushBuffer(n, s) - } - } - - /** - * A standard atom's value can be one of: - * - * - A raw value - * - A Zedux store - * - A function that returns a raw value - * - A function that returns a Zedux store - * - A function that returns an AtomApi - */ - private _evaluate() { - const { _value } = this.t - - if (typeof _value !== 'function') { - return _value - } - - try { - const val = ( - _value as ( - ...params: G['Params'] - ) => G['Store'] | G['State'] | AtomApi> - )(...this.p) - - if (!is(val, AtomApi)) return val as G['Store'] | G['State'] - - const api = (this.api = val as AtomApi>) - - // Exports can only be set on initial evaluation - if (this.l === 'Initializing' && api.exports) { - this.exports = api.exports - } - - // if api.value is a promise, we ignore api.promise - if (typeof (api.value as unknown as Promise)?.then === 'function') { - return this._setPromise(api.value as unknown as Promise, true) - } else if (api.promise) { - this._setPromise(api.promise) - } - - return api.value as G['Store'] | G['State'] - } catch (err) { - console.error( - `Zedux: Error while evaluating atom "${this.t.key}" with params:`, - this.p, - err - ) - - throw err - } - } - private _getTtl() { if (this.api?.ttl == null) { return this.t.ttl ?? this.e.atomDefaults?.ttl @@ -538,31 +517,6 @@ export class AtomInstance< return typeof ttl === 'function' ? ttl() : ttl } - private _handleStateChange( - newState: G['State'], - oldState: G['State'] | undefined, - action: ActionChain - ) { - scheduleDependents(this, oldState, false) - - if (this.e._mods.stateChanged) { - this.e.modBus.dispatch( - pluginActions.stateChanged({ - action, - node: this, - newState, - oldState, - reasons: this.w, - }) - ) - } - - // run the scheduler synchronously after any atom instance state update - if (action.meta !== zeduxTypes.batch) { - this.e._scheduler.flush() - } - } - private _setStatus(newStatus: LifecycleStatus) { const oldStatus = this.l this.l = newStatus @@ -577,44 +531,4 @@ export class AtomInstance< ) } } - - private _setPromise(promise: Promise, isStateUpdater?: boolean) { - const currentState = this.store?.getState() - if (promise === this.promise) return currentState - - this.promise = promise as G['Promise'] - - // since we're the first to chain off the returned promise, we don't need to - // track the chained promise - it will run first, before React suspense's - // `.then` on the thrown promise, for example - promise - .then(data => { - if (this.promise !== promise) return - - this._promiseStatus = 'success' - if (!isStateUpdater) return - - this.store.setState( - getSuccessPromiseState(data) as unknown as G['State'] - ) - }) - .catch(error => { - if (this.promise !== promise) return - - this._promiseStatus = 'error' - this._promiseError = error - if (!isStateUpdater) return - - this.store.setState( - getErrorPromiseState(error) as unknown as G['State'] - ) - }) - - const state: PromiseState = getInitialPromiseState(currentState?.data) - this._promiseStatus = state.status - - scheduleDependents(this, undefined, true, PromiseChange, true) - - return state as unknown as G['State'] - } } diff --git a/packages/atoms/src/classes/proxies.ts b/packages/atoms/src/classes/proxies.ts new file mode 100644 index 00000000..b8fa4ecf --- /dev/null +++ b/packages/atoms/src/classes/proxies.ts @@ -0,0 +1,329 @@ +import { RecursivePartial } from '@zedux/core' +import { Transaction, MutatableTypes } from '../types/index' + +export type ParentProxy = { + t: Transaction[] + u: (val: State, key?: string) => void +} + +const addTransaction = ( + proxyWrapper: ProxyWrapper, + transaction: Transaction +) => { + proxyWrapper.t.push(transaction) + + // once a change happens in a child proxy, tell the parent that it does need + // to update the child's key. Technically, since we mutate the object + // reference, this only needs to happen once. We could optimize this by adding + // a `hasPropagated` property to ProxyWrapper and flipping it on in + // `ProxyWrapper.u`pdateParent. Not sure if it's necessary + proxyWrapper.c.u(proxyWrapper.v, proxyWrapper.k?.at(-1)) +} + +/** + * There are several array mutation operations that are not at all worth + * supporting in the current transaction model because the resulting transaction + * list would be as big (or bigger) than the entire state array. + * + * So it's more efficient for us to not code handlers for those and tell the + * user to use `.set` to manually clone state and perform that operation + * themselves. There are usually better solutions that can still use `.mutate` + * e.g. manually doing an insertion sort and using `.mutate` to update specific + * indices rather than calling `arr.sort()`. + * + * TODO: We can technically support these operations by expanding the + * transactions API to include special array `sort`/`reverse` and set `clear` + * types with unique properties, kind of like we do with the `i`nsert type - + * @see Transaction + */ +const notSupported = (operation: string) => () => { + throw new Error( + `${operation} this proxy is not supported. Use \`.set\` instead of \`.mutate\`` + ) +} + +const withPath = (key: PropertyKey, path?: PropertyKey[]) => + path ? [...path, key] : key + +const unsupportedOperator = 'This data type does not support this operation' + +export abstract class ProxyWrapper + implements ParentProxy, ProxyHandler +{ + /** + * `p`roxy - the actual proxy this class is wrapping + */ + p = new Proxy(this.v, this) + + constructor( + /** + * `v`alue - the already-cloned state that the proxy is proxying + */ + public v: State, + + /** + * `c`reator - the parent proxy wrapper or top-level proxy-wrapper-like + * object that created this proxy wrapper. + */ + public c: ParentProxy, + + /** + * `k`eyPath - the property path to this potentially indefinitely-nested + * object inside the root state object of the signal. `undefined` if this is + * the top-level object. + */ + public k?: string[], + + /** + * `t`ransactions - the ordered list of every transaction tracked by the + * top-level `signal.mutate` call. Every ProxyWrapper node mutates this + * directly. + */ + public t = c.t + ) {} + + /** + * `a`ddTransaction - add an `add` or `update` transaction to the list + */ + public a(key: PropertyKey, v?: any) { + addTransaction(this, { k: withPath(key, this.k), v }) + } + + /** + * `d`eletePropertyImpl - handle the `delete` operator. Native JS objects + * should be the only data type to override this. Everything else should let + * this error throw. + */ + public d(state: State, key: PropertyKey) { + throw new Error(unsupportedOperator) + } + + public deleteProperty(state: State, key: PropertyKey) { + this.d(state, key) + + return true + } + + /** + * All data types should override this. The proxy class instances themselves + * are passed as the second argument to `new Proxy` + */ + public abstract get(state: State, key: PropertyKey): any + + /** + * `i`nsertTransaction - add an `i`nsert transaction to the list + */ + public i(key: PropertyKey, v: any) { + addTransaction(this, { k: withPath(key, this.k), v, t: 'i' }) + } + + /** + * `r`emoveTransaction - add a `d`elete transaction to the list + */ + public r(key: PropertyKey) { + addTransaction(this, { k: withPath(key, this.k), t: 'd' }) + } + + /** + * `s`etImpl - handle the `=` assignment operator. Only native JS objects and + * arrays should override this. + */ + public s(state: State, key: PropertyKey, val: any) { + throw new Error(unsupportedOperator) + } + + public set(state: State, key: PropertyKey, val: any) { + this.s(state, key, val) + + return true + } + + /** + * `u`pdateParent - propagates a change to this proxy wrapper from a nested + * ("child") proxy wrapper and recursively up the proxy wrapper tree. + * + * `key` is optional to maintain compatibility with the top-level + * `ParentProxy` "ProxyWrapper-like" object initially passed to + * `recursivelyProxy` + */ + public u(val: any, key?: string) { + ;(this.v as Record)[key!] = val + + // only array and object types have nested proxies. And this method can only + // be called by a nested proxy, which will always pass `key` to this method, + // so don't bother checking for undefined + this.c.u(this.v, this.k?.at(-1)) + } +} + +export class ArrayProxy extends ProxyWrapper { + constructor(rawState: State, creator: ParentProxy, path?: string[]) { + super([...rawState] as State, creator, path) + } + + get(state: any[], key: PropertyKey): any { + const methods = { + pop: () => { + this.r(state.length - 1) + + return state.pop() + }, + push: (...items: any[]) => { + for (let i = 0; i < items.length; i++) { + this.a(state.length + i, items[i]) + } + + return state.push(...items) + }, + reverse: notSupported('Reversing'), + shift: () => { + this.r(0) + + return state.shift() + }, + sort: notSupported('Sorting'), + splice: (index: number, deleteCount: number, ...items: any[]) => { + const splice = state.splice(index, deleteCount, ...items) + + for (let i = 0; i < splice.length; i++) { + this.r(index + i) + } + + for (let i = 0; i < items.length; i++) { + this.i(index + i, items[i]) + } + + return splice + }, + unshift: (...items: any[]) => { + for (let i = 0; i < items.length; i++) { + this.i(i, items[i]) + } + + return state.unshift(...items) + }, + } + + return ( + methods[key as keyof typeof methods] ?? + (typeof key === 'symbol' || isNaN(+key) + ? state[key as keyof typeof state] + : maybeRecursivelyProxy( + state[key as keyof typeof state], + this, + this.k ? [...this.k, key] : [key] + )) + ) + } + + s(state: State, key: PropertyKey, val: any) { + this.a(key, val) + state[key as keyof State] = val + } +} + +export class ObjectProxy< + State extends Record +> extends ProxyWrapper { + constructor(rawState: State, creator: ParentProxy, path?: string[]) { + super({ ...rawState }, creator, path) + } + + d(state: State, key: PropertyKey) { + this.r(key) + delete state[key] + } + + get(state: State, key: K): State[K] { + return maybeRecursivelyProxy( + state[key], + this, + this.k ? [...this.k, key] : [key] + ) + } + + s(state: State, key: PropertyKey, val: any) { + this.a(key, val) + state[key as keyof State] = val + } +} + +export class SetProxy> extends ProxyWrapper { + constructor(rawState: State, creator: ParentProxy, path?: string[]) { + super(new Set(rawState), creator, path) + } + + get(state: State, key: PropertyKey): any { + const methods = { + add: (val: any) => { + this.a(val) + return state.add(val) + }, + clear: notSupported('Clearing'), + delete: (val: any) => { + this.r(val) + return state.delete(val) + }, + } + + return ( + methods[key as keyof typeof methods] ?? state[key as keyof typeof state] + ) + } +} + +const maybeRecursivelyProxy = ( + ...args: Parameters> +) => { + const result = recursivelyProxy(...args) + + return result instanceof ProxyWrapper ? result.p : result +} + +/** + * Handles the object shorthand of `signal.mutate`, translating every field in + * the passed `update` object into a proxied mutation on the state object + * + * Only supports JS objects (and therefore arrays too). + * + * @param state should be a proxy e.g. `recursivelyProxy(...).p` + * @param update the indefinitely-nested fields we're modifying + */ +export const recursivelyMutate = >( + state: State, + update: RecursivePartial +) => { + for (const [key, val] of Object.entries(update)) { + if (val && typeof val === 'object') { + recursivelyMutate(state[key], val) + continue + } + + // ignore undefined (see https://github.com/Omnistac/zedux/issues/95) + if (typeof val !== 'undefined') state[key as keyof State] = val + } +} + +export const recursivelyProxy = ( + oldState: State, + parent: ParentProxy, + path?: PropertyKey[] +): ProxyWrapper => { + const isArr = Array.isArray(oldState) + const isSet = oldState instanceof Set + + if (!isArr && !isSet && (typeof oldState !== 'object' || !oldState)) { + return oldState // not proxy-able + } + + const newState = ( + isArr ? [...oldState] : isSet ? new Set(oldState) : { ...oldState } + ) as State + + // @ts-expect-error ts can't handle this apparently + return new (isArr ? ArrayProxy : isSet ? SetProxy : ObjectProxy)( + newState, + parent, + path + ) +} diff --git a/packages/atoms/src/classes/templates/AtomTemplate.ts b/packages/atoms/src/classes/templates/AtomTemplate.ts index 1477c05f..d9d97441 100644 --- a/packages/atoms/src/classes/templates/AtomTemplate.ts +++ b/packages/atoms/src/classes/templates/AtomTemplate.ts @@ -7,6 +7,7 @@ import { import { AtomInstance } from '../instances/AtomInstance' import { Ecosystem } from '../Ecosystem' import { AtomTemplateBase } from './AtomTemplateBase' +import { Signal } from '../Signal' export type AtomInstanceRecursive< G extends Omit @@ -56,7 +57,13 @@ export class AtomTemplate< )}` } - public override(newValue: AtomValueOrFactory): AtomTemplate { + public override( + newValue: AtomValueOrFactory< + G & { + Signal: Signal<{ State: G['State']; Events: G['Events'] }> | undefined + } + > + ): AtomTemplate { const newAtom = atom(this.key, newValue, this._config) newAtom._isOverride = true return newAtom as any diff --git a/packages/atoms/src/classes/templates/AtomTemplateBase.ts b/packages/atoms/src/classes/templates/AtomTemplateBase.ts index 94e9b5f8..03b10c00 100644 --- a/packages/atoms/src/classes/templates/AtomTemplateBase.ts +++ b/packages/atoms/src/classes/templates/AtomTemplateBase.ts @@ -6,6 +6,7 @@ import { } from '@zedux/atoms/types/index' import { prefix } from '@zedux/atoms/utils/general' import { Ecosystem } from '../Ecosystem' +import { Signal } from '../Signal' export abstract class AtomTemplateBase< G extends AtomGenerics & { Node: any } = AnyAtomGenerics<{ @@ -33,8 +34,12 @@ export abstract class AtomTemplateBase< constructor( public readonly key: string, - public readonly _value: AtomValueOrFactory, - protected readonly _config?: AtomConfig + public readonly _value: AtomValueOrFactory< + G & { + Signal: Signal<{ State: G['State']; Events: G['Events'] }> | undefined + } + >, + public readonly _config?: AtomConfig ) { Object.assign(this, _config) diff --git a/packages/atoms/src/factories/api.ts b/packages/atoms/src/factories/api.ts index 48579b8c..fc8999f8 100644 --- a/packages/atoms/src/factories/api.ts +++ b/packages/atoms/src/factories/api.ts @@ -1,6 +1,6 @@ -import { Store, StoreStateType } from '@zedux/core' import { AtomApi } from '../classes/AtomApi' -import { AtomApiPromise } from '../types/index' +import { AtomApiPromise, StateOf } from '../types/index' +import { Signal } from '../classes/Signal' /** * Create an AtomApi @@ -17,41 +17,42 @@ import { AtomApiPromise } from '../types/index' * triggers the appropriate updates in all the atom's dynamic dependents. */ export const api: { - // Custom Stores (AtomApi cloning) + // Signals (AtomApi cloning) < - StoreType extends Store = Store, + SignalType extends Signal = Signal, Exports extends Record = Record, PromiseType extends AtomApiPromise = undefined >( value: AtomApi<{ Exports: Exports Promise: PromiseType - State: StoreStateType - Store: StoreType + Signal: SignalType + State: StateOf }> ): AtomApi<{ Exports: Exports Promise: PromiseType - State: StoreStateType - Store: StoreType + Signal: SignalType + State: StateOf }> - // Custom Stores (normal) - = Store>(value: StoreType): AtomApi<{ + // Signals (normal) + = Signal>(value: SignalType): AtomApi<{ Exports: Record Promise: undefined - State: StoreStateType - Store: StoreType + Signal: SignalType + State: StateOf }> // No Value (): AtomApi<{ Exports: Record Promise: undefined + Signal: undefined State: undefined - Store: undefined }> + // No Value (passing generics manually) < State = undefined, Exports extends Record = Record, @@ -59,11 +60,11 @@ export const api: { >(): AtomApi<{ Exports: Exports Promise: PromiseType + Signal: undefined State: State - Store: undefined }> - // No Store (AtomApi cloning) + // No Signal (AtomApi cloning) < State = undefined, Exports extends Record = Record, @@ -72,60 +73,46 @@ export const api: { value: AtomApi<{ Exports: Exports Promise: PromiseType + Signal: undefined State: State - Store: undefined }> ): AtomApi<{ Exports: Exports Promise: PromiseType + Signal: undefined State: State - Store: undefined }> - // No Store (normal) - (value: State): AtomApi<{ - Exports: Record - Promise: undefined - State: State - Store: undefined - }> - - // Catch-all + // Normal Value < State = undefined, Exports extends Record = Record, - StoreType extends Store = Store, - PromiseType extends AtomApiPromise = undefined + SignalType extends + | Signal<{ Events: any; State: State }> + | undefined = undefined >( - value: - | State - | StoreType - | AtomApi<{ - Exports: Exports - Promise: PromiseType - State: State - Store: StoreType - }> + value: State ): AtomApi<{ Exports: Exports - Promise: PromiseType + Promise: undefined + Signal: SignalType State: State - Store: StoreType }> } = < State = undefined, Exports extends Record = Record, - StoreType extends Store | undefined = undefined, + SignalType extends + | Signal<{ Events: any; State: State }> + | undefined = undefined, PromiseType extends AtomApiPromise = undefined >( value?: | AtomApi<{ Exports: Exports Promise: PromiseType + Signal: SignalType State: State - Store: StoreType }> - | StoreType | State ) => new AtomApi( @@ -133,9 +120,8 @@ export const api: { | AtomApi<{ Exports: Exports Promise: PromiseType + Signal: SignalType State: State - Store: StoreType }> - | StoreType | State ) diff --git a/packages/atoms/src/factories/atom.ts b/packages/atoms/src/factories/atom.ts index bdc34a28..6744fa89 100644 --- a/packages/atoms/src/factories/atom.ts +++ b/packages/atoms/src/factories/atom.ts @@ -1,15 +1,18 @@ -import { Store, StoreStateType } from '@zedux/core' import { AtomConfig, AtomApiPromise, AtomValueOrFactory, PromiseState, + StateOf, + EventsOf, + None, } from '../types/index' import { AtomTemplate, AtomTemplateRecursive, } from '../classes/templates/AtomTemplate' import { AtomApi } from '../classes/AtomApi' +import { Signal } from '../classes/Signal' export const atom: { // Query Atoms @@ -22,40 +25,40 @@ export const atom: { value: (...params: Params) => AtomApi<{ Exports: Exports Promise: any + Signal: undefined State: Promise - Store: undefined }>, config?: AtomConfig ): AtomTemplateRecursive<{ State: PromiseState Params: Params + Events: None // TODO: give query atoms unique events Exports: Exports - Store: Store> Promise: Promise }> - // Custom Stores + // Signals < - StoreType extends Store = Store, + SignalType extends Signal = Signal, Params extends any[] = [], Exports extends Record = Record, PromiseType extends AtomApiPromise = undefined >( key: string, value: (...params: Params) => - | StoreType + | SignalType | AtomApi<{ Exports: Exports Promise: PromiseType - State: StoreStateType - Store: StoreType + Signal: SignalType + State: StateOf }>, - config?: AtomConfig> + config?: AtomConfig> ): AtomTemplateRecursive<{ - State: StoreStateType + State: StateOf Params: Params + Events: EventsOf Exports: Exports - Store: StoreType Promise: PromiseType }> @@ -64,7 +67,10 @@ export const atom: { State = any, Params extends any[] = [], Exports extends Record = Record, - StoreType extends Store = Store, + Events extends Record = None, + SignalType extends + | Signal<{ State: State; Events: Events }> + | undefined = undefined, PromiseType extends AtomApiPromise = undefined >( key: string, @@ -72,22 +78,25 @@ export const atom: { Exports: Exports Params: Params Promise: PromiseType + Signal: SignalType State: State - Store: StoreType }>, config?: AtomConfig ): AtomTemplateRecursive<{ + Events: Events Exports: Exports Params: Params Promise: PromiseType State: State - Store: StoreType }> } = < State = any, Params extends any[] = [], Exports extends Record = Record, - StoreType extends Store = Store, + Events extends Record = None, + SignalType extends + | Signal<{ State: State; Events: Events }> + | undefined = undefined, PromiseType extends AtomApiPromise = undefined >( key: string, @@ -95,8 +104,8 @@ export const atom: { Exports: Exports Params: Params Promise: PromiseType + Signal: SignalType State: State - Store: StoreType }>, config?: AtomConfig ) => { diff --git a/packages/atoms/src/factories/ion.ts b/packages/atoms/src/factories/ion.ts index c7fd2a96..8112f7e2 100644 --- a/packages/atoms/src/factories/ion.ts +++ b/packages/atoms/src/factories/ion.ts @@ -1,4 +1,3 @@ -import { Store, StoreStateType } from '@zedux/core' import { AtomApi } from '../classes/AtomApi' import { IonTemplate, @@ -10,14 +9,18 @@ import { AtomApiPromise, IonStateFactory, PromiseState, + StateOf, + EventsOf, + None, } from '../types/index' +import { Signal } from '../classes/Signal' export const ion: { // Query Atoms < State = any, Params extends any[] = [], - Exports extends Record = Record + Exports extends Record = None >( key: string, value: ( @@ -26,71 +29,71 @@ export const ion: { ) => AtomApi<{ Exports: Exports Promise: any + Signal: undefined State: Promise - Store: undefined }>, config?: AtomConfig ): IonTemplateRecursive<{ State: PromiseState Params: Params + Events: None Exports: Exports - Store: Store> Promise: Promise }> - // Custom Stores + // Signals < - StoreType extends Store = Store, + SignalType extends Signal = Signal, Params extends any[] = [], - Exports extends Record = Record, + Exports extends Record = None, PromiseType extends AtomApiPromise = undefined >( key: string, - get: ( + value: ( getters: AtomGetters, ...params: Params ) => - | StoreType + | SignalType | AtomApi<{ Exports: Exports Promise: PromiseType - State: StoreStateType - Store: StoreType + Signal: SignalType + State: StateOf }>, - config?: AtomConfig> + config?: AtomConfig> ): IonTemplateRecursive<{ - State: StoreStateType + State: StateOf Params: Params + Events: EventsOf Exports: Exports - Store: StoreType Promise: PromiseType }> - // No Store + // No Signal (TODO: Is this overload unnecessary? `atom` doesn't have it) < State = any, Params extends any[] = [], - Exports extends Record = Record, + Exports extends Record = None, PromiseType extends AtomApiPromise = undefined >( key: string, - get: ( + value: ( getters: AtomGetters, ...params: Params ) => | AtomApi<{ Exports: Exports Promise: PromiseType + Signal: undefined State: State - Store: undefined }> | State, config?: AtomConfig ): IonTemplateRecursive<{ State: State Params: Params + Events: None Exports: Exports - Store: Store Promise: PromiseType }> @@ -98,40 +101,40 @@ export const ion: { < State = any, Params extends any[] = [], - Exports extends Record = Record, - StoreType extends Store = Store, + Exports extends Record = None, + EventsType extends Record = None, PromiseType extends AtomApiPromise = undefined >( key: string, - get: IonStateFactory<{ + value: IonStateFactory<{ State: State Params: Params + Events: EventsType Exports: Exports - Store: StoreType Promise: PromiseType }>, config?: AtomConfig ): IonTemplateRecursive<{ State: State Params: Params + Events: EventsType Exports: Exports - Store: StoreType Promise: PromiseType }> } = < State = any, Params extends any[] = [], - Exports extends Record = Record, - StoreType extends Store = Store, + Exports extends Record = None, + EventsType extends Record = None, PromiseType extends AtomApiPromise = undefined >( key: string, - get: IonStateFactory<{ + value: IonStateFactory<{ State: State Params: Params + Events: EventsType Exports: Exports - Store: StoreType Promise: PromiseType }>, config?: AtomConfig -) => new IonTemplate(key, get, config) as any +) => new IonTemplate(key, value, config) as any diff --git a/packages/atoms/src/index.ts b/packages/atoms/src/index.ts index 9551e4b6..7d5be04c 100644 --- a/packages/atoms/src/index.ts +++ b/packages/atoms/src/index.ts @@ -1,6 +1,31 @@ +import { + destroyNodeFinish, + destroyNodeStart, + scheduleDependents, +} from './classes/GraphNode' +import { createInjector } from './factories/createInjector' +import { + destroyBuffer, + flushBuffer, + getEvaluationContext, + startBuffer, +} from './utils/evaluationContext' + export * from '@zedux/core' export * from './classes/index' export * from './factories/index' export * from './injectors/index' export { getEcosystem, getInternals, setInternals, wipe } from './store/index' export * from './types/index' + +// These are very obfuscated on purpose. Don't use! They're for Zedux packages. +export const zi = { + b: destroyNodeStart, + c: createInjector, + d: destroyBuffer, + e: destroyNodeFinish, + f: flushBuffer, + g: getEvaluationContext, + s: startBuffer, + u: scheduleDependents, +} diff --git a/packages/atoms/src/injectors/index.ts b/packages/atoms/src/injectors/index.ts index e4934550..2b267096 100644 --- a/packages/atoms/src/injectors/index.ts +++ b/packages/atoms/src/injectors/index.ts @@ -6,9 +6,10 @@ export * from './injectAtomValue' export * from './injectCallback' export * from './injectEffect' export * from './injectInvalidate' +export * from './injectMappedSignal' export * from './injectMemo' export * from './injectPromise' export * from './injectRef' export * from './injectSelf' -export { injectStore } from './injectStore' +export * from './injectSignal' export * from './injectWhy' diff --git a/packages/atoms/src/injectors/injectAtomInstance.ts b/packages/atoms/src/injectors/injectAtomInstance.ts index c7d249d1..7445d246 100644 --- a/packages/atoms/src/injectors/injectAtomInstance.ts +++ b/packages/atoms/src/injectors/injectAtomInstance.ts @@ -4,10 +4,10 @@ import type { InjectorDescriptor } from '../utils/types' import { AnyAtomInstance, AnyAtomTemplate, - AtomInstanceType, - AtomParamsType, InjectAtomInstanceConfig, + NodeOf, ParamlessTemplate, + ParamsOf, PartialAtomInstance, } from '../types/index' @@ -38,15 +38,13 @@ const defaultOperation = 'injectAtomInstance' export const injectAtomInstance: { ( template: A, - params: AtomParamsType, + params: ParamsOf, config?: InjectAtomInstanceConfig - ): AtomInstanceType + ): NodeOf - >(template: A): AtomInstanceType + >(template: A): NodeOf - ( - template: ParamlessTemplate - ): AtomInstanceType + (template: ParamlessTemplate): NodeOf ( instance: I, @@ -58,12 +56,12 @@ export const injectAtomInstance: { ( instance: PartialAtomInstance, atom: A | AnyAtomInstance, - params?: AtomParamsType, + params?: ParamsOf, config?: InjectAtomInstanceConfig ) => { const injectedInstance = instance.e.live.getInstance( atom as A, - params as AtomParamsType, + params as ParamsOf, { f: config?.subscribe ? 0 : Static, op: config?.operation || defaultOperation, @@ -71,28 +69,28 @@ export const injectAtomInstance: { ) return { - result: injectedInstance as AtomInstanceType, + result: injectedInstance as NodeOf, type: `${prefix}/atom`, - } as InjectorDescriptor> + } as InjectorDescriptor> }, ( - prevDescriptor: InjectorDescriptor>, + prevDescriptor: InjectorDescriptor>, instance: PartialAtomInstance, atom: A | AnyAtomInstance, - params?: AtomParamsType, + params?: ParamsOf, config?: InjectAtomInstanceConfig ) => { // make sure the dependency gets registered for this evaluation const injectedInstance = instance.e.live.getInstance( atom as A, - params as AtomParamsType, + params as ParamsOf, { f: config?.subscribe ? 0 : Static, op: config?.operation || defaultOperation, } ) - prevDescriptor.result = injectedInstance as AtomInstanceType + prevDescriptor.result = injectedInstance as NodeOf return prevDescriptor } diff --git a/packages/atoms/src/injectors/injectAtomSelector.ts b/packages/atoms/src/injectors/injectAtomSelector.ts index 19eb82dc..2fc9b6c3 100644 --- a/packages/atoms/src/injectors/injectAtomSelector.ts +++ b/packages/atoms/src/injectors/injectAtomSelector.ts @@ -1,7 +1,7 @@ -import { AtomParamsType, AtomStateType, Selectable } from '../types/index' +import { ParamsOf, Selectable, StateOf } from '../types/index' import { readInstance } from '../utils/evaluationContext' export const injectAtomSelector = ( selectable: S, - ...args: AtomParamsType -): AtomStateType => readInstance().e.live.select(selectable, ...args) + ...args: ParamsOf +): StateOf => readInstance().e.live.select(selectable, ...args) diff --git a/packages/atoms/src/injectors/injectAtomState.ts b/packages/atoms/src/injectors/injectAtomState.ts index c28bc029..54c9951a 100644 --- a/packages/atoms/src/injectors/injectAtomState.ts +++ b/packages/atoms/src/injectors/injectAtomState.ts @@ -3,11 +3,11 @@ import { AtomTemplateBase } from '../classes/templates/AtomTemplateBase' import { AnyAtomGenerics, AnyAtomTemplate, - AtomExportsType, - AtomParamsType, - AtomStateType, + ExportsOf, ParamlessTemplate, + ParamsOf, StateHookTuple, + StateOf, } from '../types/index' import { injectAtomInstance } from './injectAtomInstance' @@ -17,20 +17,20 @@ import { injectAtomInstance } from './injectAtomInstance' export const injectAtomState: { >( template: A, - params: AtomParamsType - ): StateHookTuple, AtomExportsType> + params: ParamsOf + ): StateHookTuple, ExportsOf> >( template: A - ): StateHookTuple, AtomExportsType> + ): StateHookTuple, ExportsOf> >( template: ParamlessTemplate - ): StateHookTuple, AtomExportsType> + ): StateHookTuple, ExportsOf> (instance: I): StateHookTuple< - AtomStateType, - AtomExportsType + StateOf, + ExportsOf > } = >( atom: AtomTemplateBase, diff --git a/packages/atoms/src/injectors/injectAtomValue.ts b/packages/atoms/src/injectors/injectAtomValue.ts index 25b998a9..4db64a86 100644 --- a/packages/atoms/src/injectors/injectAtomValue.ts +++ b/packages/atoms/src/injectors/injectAtomValue.ts @@ -3,23 +3,20 @@ import { AnyAtomGenerics, AnyAtomInstance, AnyAtomTemplate, - AtomParamsType, - AtomStateType, ParamlessTemplate, + ParamsOf, + StateOf, } from '../types/index' import { injectAtomInstance } from './injectAtomInstance' export const injectAtomValue: { - ( - template: A, - params: AtomParamsType - ): AtomStateType + (template: A, params: ParamsOf): StateOf - >(template: A): AtomStateType + >(template: A): StateOf - (template: ParamlessTemplate): AtomStateType + (template: ParamlessTemplate): StateOf - (instance: I): AtomStateType + (instance: I): StateOf } = >( atom: AtomTemplateBase, params?: G['Params'] diff --git a/packages/atoms/src/injectors/injectMappedSignal.ts b/packages/atoms/src/injectors/injectMappedSignal.ts new file mode 100644 index 00000000..f48999ba --- /dev/null +++ b/packages/atoms/src/injectors/injectMappedSignal.ts @@ -0,0 +1,67 @@ +import { MappedSignal, SignalMap } from '../classes/MappedSignal' +import { + AnyNonNullishValue, + EventsOf, + InjectSignalConfig, + Prettify, + StateOf, +} from '../types/index' +import { readInstance } from '../utils/evaluationContext' +import { Static } from '../utils/general' +import { injectAtomGetters } from './injectAtomGetters' +import { injectMemo } from './injectMemo' + +type MapEventsToPayloads> = TupleToEvents< + Events, + UnionToTuple +> + +type TupleToEvents< + Events extends Record, + T extends any[] +> = T extends [infer K, ...infer Rest] + ? K extends keyof Events + ? Events[K] & TupleToEvents, Rest> + : never + : AnyNonNullishValue + +type UnionToIntersection = ( + U extends never ? never : (arg: U) => never +) extends (arg: infer I) => void + ? I + : never + +type UnionToTuple = UnionToIntersection< + T extends never ? never : (t: T) => T +> extends (_: never) => infer W + ? [...UnionToTuple>, W] + : [] + +export const injectMappedSignal = ( + map: M, + config?: Pick< + InjectSignalConfig }>>, + 'reactive' + > +) => { + const instance = readInstance() + + const signal = injectMemo(() => { + return new MappedSignal<{ + Events: Prettify }>> + State: { [K in keyof M]: StateOf } + }>( + instance.e, + instance.e._idGenerator.generateId(`@signal(${instance.id})`), + map + ) + }, []) + + // create a graph edge between the current atom and the new signal + injectAtomGetters().getNode(signal, undefined, { + f: config?.reactive === false ? Static : 0, + op: 'injectMappedSignal', + }) + + return signal +} diff --git a/packages/atoms/src/injectors/injectPromise.ts b/packages/atoms/src/injectors/injectPromise.ts index f918a0cb..4a6f376c 100644 --- a/packages/atoms/src/injectors/injectPromise.ts +++ b/packages/atoms/src/injectors/injectPromise.ts @@ -1,4 +1,4 @@ -import { detailedTypeof, RecursivePartial, Store } from '@zedux/core' +import { detailedTypeof, RecursivePartial } from '@zedux/core' import { api } from '../factories/api' import { getErrorPromiseState, @@ -6,23 +6,27 @@ import { getSuccessPromiseState, } from '../utils/promiseUtils' import { + EventMap, InjectorDeps, InjectPromiseConfig, - InjectStoreConfig, + InjectSignalConfig, + MapEvents, + None, PromiseState, } from '../types/index' import { injectEffect } from './injectEffect' import { injectMemo } from './injectMemo' -import { injectStore } from './injectStore' +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' /** * Create a memoized promise reference. Kicks off the promise immediately - * (unlike injectEffect which waits a tick). Creates a store to track promise - * state. This store's state shape is based off React Query: + * (unlike injectEffect which waits a tick). Creates a signal to track promise + * state. This signal's state shape is based off React Query: * * ```ts * { @@ -35,22 +39,22 @@ import { readInstance } from '../utils/evaluationContext' * } * ``` * - * Returns an Atom API with `.store` and `.promise` set. + * Returns an Atom API with `.signal` and `.promise` set. * * The 2nd `deps` param is just like `injectMemo` - these deps determine when * the promise's reference should change. * * The 3rd `config` param can take the following options: * - * - `dataOnly`: Set this to true to prevent the store from tracking promise + * - `dataOnly`: Set this to true to prevent the signal from tracking promise * status and make your promise's `data` the entire state. * - * - `initialState`: Set the initial state of the store (e.g. a placeholder + * - `initialState`: Set the initial state of the signal (e.g. a placeholder * value before the promise resolves) * - * - store config: Any other config options will be passed directly to - * `injectStore`'s config. For example, pass `subscribe: false` to - * prevent the store from reevaluating the current atom on update. + * - signal config: Any other config options will be passed directly to + * `injectSignal`'s config. For example, pass `reactive: false` to + * prevent the signal from reevaluating the current atom on update. * * ```ts * const promiseApi = injectPromise(async () => { @@ -59,53 +63,56 @@ import { readInstance } from '../utils/evaluationContext' * }, [url], { * dataOnly: true, * initialState: '', - * subscribe: false + * reactive: false * }) * ``` */ export const injectPromise: { - ( - promiseFactory: (controller?: AbortController) => Promise, + ( + promiseFactory: (controller?: AbortController) => Promise, deps: InjectorDeps, config: Omit & { dataOnly: true - } & InjectStoreConfig + } & InjectSignalConfig ): AtomApi<{ Exports: Record - Promise: Promise - State: T - Store: Store + Promise: Promise + Signal: Signal<{ Events: MapEvents; State: Data }> + State: Data }> - ( - promiseFactory: (controller?: AbortController) => Promise, + ( + promiseFactory: (controller?: AbortController) => Promise, deps?: InjectorDeps, - config?: InjectPromiseConfig & InjectStoreConfig + config?: InjectPromiseConfig & InjectSignalConfig ): AtomApi<{ Exports: Record - Promise: Promise - State: PromiseState - Store: Store> + Promise: Promise + Signal: Signal<{ + Events: MapEvents + State: PromiseState + }> + State: PromiseState }> -} = ( - promiseFactory: (controller?: AbortController) => Promise, +} = ( + promiseFactory: (controller?: AbortController) => Promise, deps?: InjectorDeps, { dataOnly, initialState, runOnInvalidate, - ...storeConfig - }: InjectPromiseConfig & InjectStoreConfig = {} + ...signalConfig + }: InjectPromiseConfig & InjectSignalConfig = {} ) => { const refs = injectRef({ counter: 0 } as { controller?: AbortController counter: number - promise: Promise + promise: Promise }) - const store = injectStore( - dataOnly ? initialState : getInitialPromiseState(initialState), - storeConfig + const signal = injectSignal( + dataOnly ? initialState : getInitialPromiseState(initialState), + signalConfig ) if ( @@ -138,12 +145,12 @@ export const injectPromise: { if (prevController) (prevController as any).abort('updated') if (!dataOnly) { - // preserve previous data and error using setStateDeep: - store.setStateDeep( + // preserve previous data and error using mutate: + signal.mutate( state => getInitialPromiseState( - (state as PromiseState).data - ) as RecursivePartial> + (state as PromiseState).data + ) as RecursivePartial> ) } @@ -151,13 +158,13 @@ export const injectPromise: { .then(data => { if (nextController?.signal.aborted) return - store.setState(dataOnly ? data : getSuccessPromiseState(data)) + signal.set(dataOnly ? data : getSuccessPromiseState(data)) }) .catch(error => { if (dataOnly || nextController?.signal.aborted) return - // preserve previous data using setStateDeep: - store.setStateDeep(getErrorPromiseState(error)) + // preserve previous data using mutate: + signal.mutate(getErrorPromiseState(error)) }) return promise @@ -171,5 +178,5 @@ export const injectPromise: { [] ) - return api(store).setPromise(refs.current.promise) + return api(signal).setPromise(refs.current.promise) } diff --git a/packages/atoms/src/injectors/injectSignal.ts b/packages/atoms/src/injectors/injectSignal.ts new file mode 100644 index 00000000..9895d558 --- /dev/null +++ b/packages/atoms/src/injectors/injectSignal.ts @@ -0,0 +1,60 @@ +import { Signal } from '../classes/Signal' +import { EventMap, InjectSignalConfig, MapEvents, None } from '../types/index' +import { readInstance } from '../utils/evaluationContext' +import { Static } from '../utils/general' +import { injectAtomGetters } from './injectAtomGetters' +import { injectMemo } from './injectMemo' + +/** + * A TS utility for typing custom events. + * + * ```ts + * const signal = injectSignal('state', { + * events: { + * customEvent: As<{ customPayload: string }> + * } + * }) + * ``` + */ +export const As = () => 0 as T + +/** + * The main API for creating signals in Zedux. Returns a stable instance of the + * Signal class. + * + * By default, this makes the current atom react to state updates in the + * injected signal. Pass `{ reactive: false }` as the second argument to disable + * this. + */ +export const injectSignal = ( + state: (() => State) | State, + config?: InjectSignalConfig +) => { + const instance = readInstance() + + const signal = injectMemo(() => { + const id = instance.e._idGenerator.generateId(`@signal(${instance.id})`) + + const signal = new Signal<{ + Events: MapEvents + State: State + }>( + instance.e, + id, + typeof state === 'function' ? (state as () => State)() : state, // TODO: should hydration be passed to the `state()` factory? + config?.events + ) + + instance.e.n.set(id, signal) + + return signal + }, []) + + // create a graph edge between the current atom and the new signal + injectAtomGetters().getNode(signal, undefined, { + f: config?.reactive === false ? Static : 0, + op: 'injectSignal', + }) + + return signal +} diff --git a/packages/atoms/src/types/atoms.ts b/packages/atoms/src/types/atoms.ts index 8ecf6b48..8c0a428e 100644 --- a/packages/atoms/src/types/atoms.ts +++ b/packages/atoms/src/types/atoms.ts @@ -1,10 +1,11 @@ -import { Store } from '@zedux/core' import { AtomInstance } from '../classes/instances/AtomInstance' import { AtomTemplateBase } from '../classes/templates/AtomTemplateBase' import { AtomApi } from '../classes/AtomApi' import { GraphNode } from '../classes/GraphNode' import { AnyNonNullishValue, AtomSelectorOrConfig, Prettify } from './index' import { SelectorInstance } from '../classes/SelectorInstance' +import { Signal } from '../classes/Signal' +import { MappedSignal } from '../classes/MappedSignal' export type AtomApiGenericsPartial> = Omit< AnyAtomApiGenerics, @@ -39,45 +40,71 @@ export type AtomApiGenerics = Pick< AtomGenerics, 'Exports' | 'Promise' | 'State' > & { - Store: Store | undefined + Signal: Signal | undefined } export type AtomGenericsToAtomApiGenerics< - G extends Pick -> = Pick & { Store: G['Store'] | undefined } + G extends Pick +> = Pick & { + Signal: Signal> | undefined +} export interface AtomGenerics { + Events: Record Exports: Record Node: any - Params: any[] + Params: any Promise: AtomApiPromise State: any - Store: Store Template: any } export type AtomApiPromise = Promise | undefined -export type AtomExportsType< - A extends AnyAtomApi | AnyAtomTemplate | GraphNode -> = A extends AtomTemplateBase - ? G['Exports'] - : A extends GraphNode - ? G extends { Exports: infer Exports } - ? Exports +export type EventsOf = + A extends AtomTemplateBase + ? G['Events'] + : A extends GraphNode + ? G['Events'] + : A extends Signal + ? G['Events'] + : A extends MappedSignal + ? G['Events'] + : A extends AtomApi + ? G['Signal'] extends Signal + ? EventsOf + : never + : A extends AtomSelectorOrConfig + ? Events + : never + +export type ExportsOf = + A extends AtomTemplateBase + ? G['Exports'] + : A extends GraphNode + ? G extends { Exports: infer Exports } + ? Exports + : never + : A extends AtomApi + ? G['Exports'] : never - : A extends AtomApi - ? G['Exports'] - : never -export type AtomInstanceType = +export type NodeOf = A extends AtomTemplateBase - ? G extends { Node: infer Node } + ? // this allows the Node generic to be extracted from functions that don't + // even accept it but were passed one: + G extends { Node: infer Node } ? Node : GraphNode + : A extends AtomSelectorOrConfig + ? SelectorInstance<{ + Params: Params + State: State + Template: AtomSelectorOrConfig + }> : never -export type AtomParamsType< +export type ParamsOf< A extends AnyAtomTemplate | GraphNode | AtomSelectorOrConfig > = A extends AtomTemplateBase ? G['Params'] @@ -89,66 +116,44 @@ export type AtomParamsType< ? Params : never -export type AtomPromiseType< - A extends AnyAtomApi | AnyAtomTemplate | GraphNode -> = A extends AtomTemplateBase - ? G['Promise'] - : A extends GraphNode - ? G extends { Promise: infer Promise } - ? Promise +export type PromiseOf = + A extends AtomTemplateBase + ? G['Promise'] + : A extends GraphNode + ? G extends { Promise: infer Promise } + ? Promise + : never + : A extends AtomApi + ? G['Promise'] : never - : A extends AtomApi - ? G['Promise'] - : never -export type AtomStateType< +export type SelectorGenerics = Pick & { + Params: any[] + Template: AtomSelectorOrConfig +} + +export type StateOf< A extends AnyAtomApi | AnyAtomTemplate | AtomSelectorOrConfig | GraphNode > = A extends AtomTemplateBase ? G['State'] : A extends GraphNode ? G['State'] + : A extends Signal + ? G['State'] + : A extends MappedSignal + ? G['State'] : A extends AtomApi ? G['State'] : A extends AtomSelectorOrConfig ? State : never -export type AtomStoreType = - A extends AtomTemplateBase - ? G['Store'] - : A extends GraphNode - ? G extends { Store: infer Store } - ? Store - : never - : A extends AtomApi - ? G['Store'] - : never - // TODO: Now that GraphNode has the Template generic, this G extends { Template // ... } check shouldn't be necessary. Double check and remove. -export type AtomTemplateType = A extends GraphNode +export type TemplateOf = A extends GraphNode ? G extends { Template: infer Template } ? Template : G extends AtomGenerics ? AtomTemplateBase : never : never - -export type NodeOf = - A extends AtomTemplateBase - ? // this allows the Node generic to be extracted from functions that don't - // even accept it but were passed one: - G extends { Node: infer Node } - ? Node - : GraphNode - : A extends AtomSelectorOrConfig - ? SelectorInstance<{ - Params: Params - State: State - Template: AtomSelectorOrConfig - }> - : never - -export type SelectorGenerics = Pick & { - Template: AtomSelectorOrConfig -} diff --git a/packages/atoms/src/types/events.ts b/packages/atoms/src/types/events.ts new file mode 100644 index 00000000..5f713230 --- /dev/null +++ b/packages/atoms/src/types/events.ts @@ -0,0 +1,106 @@ +import { RecursivePartial } from '@zedux/core' +import { AtomGenerics, Cleanup, GraphEdgeConfig, Prettify } from './index' + +/** + * 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. + */ +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 + * 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. + * + * ```ts + * mySignal.set(state => ({ ...state, a: 1 }), { mutate: [{ k: 'a', v: 1 }] }) + * ``` + */ + mutate: Transaction[] +} + +export type CatchAllListener> = + (eventMap: Partial>) => void + +export interface EventEmitter< + G extends Pick = { Events: any; State: any } +> { + // TODO: add a `passive` option for listeners that don't prevent destruction + on( + eventName: E, + callback: SingleEventListener, + edgeDetails?: GraphEdgeConfig + ): Cleanup + + on(callback: CatchAllListener, edgeDetails?: GraphEdgeConfig): Cleanup +} + +export interface ImplicitEvents { + change: { newState: State; oldState: State } +} + +export type ListenableEvents> = + Prettify> + +export type Mutatable = + | RecursivePartial + | ((state: State) => void) + +export type MutatableTypes = any[] | Record | Set + +export type SendableEvents> = Prettify< + G['Events'] & ExplicitEvents +> + +export type SingleEventListener< + G extends Pick, + E extends keyof G['Events'] +> = ( + payload: ListenableEvents[E], + eventMap: Partial> +) => void + +/** + * A transaction is a serializable representation of a mutation operation on one + * of the supported data types (native JS object, array, and Set). + */ +export interface Transaction { + /** + * `k`ey - either a top-level object key or an indefinitely-nested array of + * keys detailing the "path" through a nested state object to the field that + * updated. + */ + k: PropertyKey | PropertyKey[] + + /** + * If `t`ype isn't specified, an "add"/"update" is assumed ("update" if key + * exists already, "add" if not). + * + * The "d"elete type means different things for each data type: + * + * - For objects, the `delete` operator was used + * - For arrays, a method like `.splice` or `.pop` was used + * - For sets, `.delete` was used + * + * The "i"nsert type is array-specific. It means this `val` should be inserted + * at `k`ey index, pushing back the item already at that index (and all items + * thereafter) rather than replacing it. + */ + t?: 'd' | 'i' // `d`elete | `i`nsert + + /** + * `v`alue - the new value of the `k`ey + */ + v?: any +} + +export type UndefinedEvents> = { + [K in keyof Events]: Events[K] extends undefined ? K : never +}[keyof Events] diff --git a/packages/atoms/src/types/index.ts b/packages/atoms/src/types/index.ts index a0cc94a4..77c99e7f 100644 --- a/packages/atoms/src/types/index.ts +++ b/packages/atoms/src/types/index.ts @@ -1,20 +1,25 @@ import { ActionChain, Observable, Settable } from '@zedux/core' import { AtomApi } from '../classes/AtomApi' import { Ecosystem } from '../classes/Ecosystem' +import { GraphNode } from '../classes/GraphNode' +import { SelectorInstance } from '../classes/SelectorInstance' +import { AtomTemplateBase } from '../classes/templates/AtomTemplateBase' +import { Signal } from '../classes/Signal' +import { InternalEvaluationType } from '../utils/general' import { + AnyAtomGenerics, AnyAtomInstance, AnyAtomTemplate, AtomGenerics, AtomGenericsToAtomApiGenerics, - AtomInstanceType, - AtomParamsType, - AtomStateType, + NodeOf, + ParamsOf, + StateOf, } from './atoms' -import { SelectorInstance } from '../classes/SelectorInstance' -import { GraphNode } from '../classes/GraphNode' -import { InternalEvaluationType } from '../utils/general' +import { ExplicitEvents, ImplicitEvents } from './events' export * from './atoms' +export * from './events' // eslint-disable-next-line @typescript-eslint/ban-types export type AnyNonNullishValue = {} @@ -37,44 +42,103 @@ export interface AtomGettersBase { * synchronously during atom or AtomSelector evaluation. When called * asynchronously, is just an alias for `ecosystem.get` */ - get( - template: A, - params: AtomParamsType - ): AtomStateType + get(template: A, params: ParamsOf): StateOf - get>(template: A): AtomStateType + get>(template: A): StateOf - get( - template: ParamlessTemplate - ): AtomStateType + get(template: ParamlessTemplate): StateOf - get(instance: I): AtomStateType + get(node: N): StateOf /** * Registers a static graph edge on the resolved atom instance when called * synchronously during atom or AtomSelector evaluation. When called * asynchronously, is just an alias for `ecosystem.getInstance` + * + * @deprecated in favor of `getNode` */ getInstance( template: A, - params: AtomParamsType, - edgeInfo?: GraphEdgeDetails - ): AtomInstanceType + params: ParamsOf, + edgeInfo?: GraphEdgeConfig + ): NodeOf - getInstance>( - template: A - ): AtomInstanceType + getInstance>(template: A): NodeOf getInstance( template: ParamlessTemplate - ): AtomInstanceType + ): NodeOf getInstance( instance: I, params?: [], - edgeInfo?: GraphEdgeDetails + edgeInfo?: GraphEdgeConfig ): I + // TODO: Dedupe these overloads + // atoms + getNode( + templateOrNode: AtomTemplateBase | GraphNode, + params: G['Params'], + edgeConfig?: GraphEdgeConfig + ): G['Node'] + + getNode>( + templateOrNode: AtomTemplateBase | GraphNode + ): G['Node'] + + getNode( + templateOrInstance: ParamlessTemplate | GraphNode> + ): G['Node'] + + getNode(instance: I, params?: []): I + + // selectors + getNode( + selectable: S, + params: ParamsOf, + edgeConfig?: GraphEdgeConfig + ): S extends AtomSelectorOrConfig + ? SelectorInstance<{ + Params: ParamsOf + State: StateOf + Template: S + }> + : S + + getNode>( + selectable: S + ): S extends AtomSelectorOrConfig + ? SelectorInstance<{ + Params: ParamsOf + State: StateOf + Template: S + }> + : S + + getNode( + selectable: ParamlessTemplate + ): S extends AtomSelectorOrConfig + ? SelectorInstance<{ + Params: ParamsOf + State: StateOf + Template: S + }> + : S + + getNode( + node: N, + params?: [], + edgeConfig?: GraphEdgeConfig // only here for AtomGetters type compatibility + ): N + + // catch-all + getNode( + template: AtomTemplateBase | GraphNode | AtomSelectorOrConfig, + params?: G['Params'], + edgeConfig?: GraphEdgeConfig + ): G['Node'] + /** * Runs an AtomSelector which receives its own AtomGetters object and can use * those to register its own dynamic and/or static graph edges (when called @@ -96,8 +160,8 @@ export interface AtomGettersBase { */ select( selectorOrConfigOrInstance: S, - ...args: AtomParamsType - ): AtomStateType + ...args: ParamsOf + ): StateOf } /** @@ -156,22 +220,23 @@ export type AtomSelectorOrConfig = | AtomSelectorConfig export type AtomStateFactory< - G extends Pick< - AtomGenerics, - 'Exports' | 'Params' | 'Promise' | 'State' | 'Store' - > + G extends Pick & { + Signal: Signal | undefined + } > = ( ...params: G['Params'] -) => AtomApi> | G['Store'] | G['State'] +) => + | AtomApi & { Signal: G['Signal'] }> + | G['Signal'] + | G['State'] -export type AtomTuple = [A, AtomParamsType] +export type AtomTuple = [A, ParamsOf] export type AtomValueOrFactory< - G extends Pick< - AtomGenerics, - 'Exports' | 'Params' | 'Promise' | 'State' | 'Store' - > -> = AtomStateFactory | G['Store'] | G['State'] + G extends Pick & { + Signal: Signal | undefined + } +> = AtomStateFactory | G['State'] export type Cleanup = () => void @@ -181,12 +246,6 @@ export interface DehydrationOptions extends NodeFilterOptions { export type DehydrationFilter = string | AnyAtomTemplate | DehydrationOptions -export type DependentCallback = ( - signal: GraphEdgeSignal, - val?: any, - reason?: InternalEvaluationReason -) => any - export interface EcosystemConfig< Context extends Record | undefined = any > { @@ -231,11 +290,22 @@ export type EvaluationType = | 'promise changed' | 'state changed' +/** + * A user-defined object mapping custom event names to unused placeholder + * functions whose return types are used to infer expected event payloads. + * + * We map all Zedux built-in events to `never` here to prevent users from + * specifying those events + */ +export type EventMap = { + [K in keyof ExplicitEvents & ImplicitEvents]?: never +} & Record any> + export type ExportsInfusedSetter = Exports & { (settable: Settable, meta?: any): State } -export type GraphEdgeDetails = { +export interface GraphEdgeConfig { /** * `f`lags - the binary EdgeFlags of this edge */ @@ -262,14 +332,6 @@ export interface GraphEdge { p?: number } -/** - * A low-level detail that tells dependents what sort of event is causing the - * current update. Promise changes and state updates are lumped together as - * 'Update' signals. If you need to distinguish between them, look at the - * EvaluationType (the `type` field) in the full reasons list. - */ -export type GraphEdgeSignal = 'Destroyed' | 'Updated' - export interface GraphViewRecursive { [key: string]: GraphViewRecursive } @@ -291,12 +353,25 @@ export interface InjectPromiseConfig { runOnInvalidate?: boolean } +export interface InjectSignalConfig { + events?: MappedEvents + reactive?: boolean +} + export interface InjectStoreConfig { hydrate?: boolean subscribe?: boolean } 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 + */ + e?: Record + /** * `p`revState - the old state of the source node */ @@ -317,6 +392,9 @@ export interface InternalEvaluationReason { * `t`ype - an obfuscated number representing the type of update (e.g. whether * the source node was force destroyed or its promise updated). Zedux's `why` * utils translate this into a user-friendly string. + * + * If not specified, it's assumed to be a "normal" update (usually a state + * change). */ t?: InternalEvaluationType } @@ -325,10 +403,14 @@ export type IonStateFactory> = ( getters: AtomGetters, ...params: G['Params'] - ) => AtomApi> | G['Store'] | G['State'] + ) => AtomApi> | Signal | G['State'] export type LifecycleStatus = 'Active' | 'Destroyed' | 'Initializing' | 'Stale' +export type MapEvents = Prettify<{ + [K in keyof T]: ReturnType +}> + export type MaybeCleanup = Cleanup | void export interface MutableRefObject { @@ -348,17 +430,18 @@ export type NodeFilter = | AtomSelectorOrConfig | NodeFilterOptions +/** + * Reads better than `Record` in atom generics + */ +export type None = Prettify> + /** * Many Zedux APIs make the `params` parameter optional if the atom doesn't take * params or has only optional params. */ export type ParamlessTemplate< - A extends - | AnyAtomTemplate - | AnyAtomInstance - | AtomSelectorOrConfig - | SelectorInstance -> = AtomParamsType extends [AnyNonNullishValue | undefined | null, ...any[]] + A extends AnyAtomTemplate | AtomSelectorOrConfig | GraphNode +> = ParamsOf extends [AnyNonNullishValue | undefined | null, ...any[]] ? never : A diff --git a/packages/atoms/src/utils/evaluationContext.ts b/packages/atoms/src/utils/evaluationContext.ts index e4e9414b..90290140 100644 --- a/packages/atoms/src/utils/evaluationContext.ts +++ b/packages/atoms/src/utils/evaluationContext.ts @@ -1,4 +1,4 @@ -import { ActionFactoryPayloadType, Store, is } from '@zedux/core' +import { ActionFactoryPayloadType, is } from '@zedux/core' import { AnyAtomInstance } from '../types/index' import { ExplicitExternal, OutOfRange } from '../utils/general' import { pluginActions } from '../utils/plugin-actions' @@ -88,10 +88,6 @@ export const destroyBuffer = ( const finishBuffer = (previousNode?: GraphNode, previousStartTime?: number) => { const { _idGenerator, _mods, modBus } = evaluationContext.n!.e - // if we just popped the last thing off the stack, restore the default - // scheduler - if (!previousNode) Store._scheduler = undefined - if (_mods.evaluationFinished) { const time = evaluationContext.s ? _idGenerator.now(true) - evaluationContext.s @@ -172,14 +168,12 @@ export const setEvaluationContext = (newContext: EvaluationContext) => * ``` */ export const startBuffer = (node: GraphNode) => { + // TODO: when `evaluationFinished` is replaced with `runStart`/`runEnd`, make + // this function return the previous `evaluationContext.n` value so all + // callers don't have to `getEvaluationContext()` first evaluationContext.n = node if (node.e._mods.evaluationFinished) { evaluationContext.s = node.e._idGenerator.now(true) } - - // all stores created during evaluation automatically belong to the ecosystem - // TODO: remove this brittle requirement. It's the only piece of Zedux that - // isn't cross-window compatible. The core package needs its own scheduler. - Store._scheduler = node.e._scheduler } diff --git a/packages/atoms/src/utils/general.ts b/packages/atoms/src/utils/general.ts index e45af08d..370921ad 100644 --- a/packages/atoms/src/utils/general.ts +++ b/packages/atoms/src/utils/general.ts @@ -23,15 +23,19 @@ export const OutOfRange = 8 // not a flag; use a value bigger than any flag /** * The InternalEvaluationTypes. These get translated to user-friendly * EvaluationTypes by `ecosytem.why`. + * + * IMPORTANT! Keep these in sync with `@zedux/stores/atoms-port.ts` */ export const Invalidate = 1 export const Destroy = 2 export const PromiseChange = 3 +export const EventSent = 4 export type InternalEvaluationType = | typeof Destroy | typeof Invalidate | typeof PromiseChange + | typeof EventSent export const isZeduxNode = 'isZeduxNode' diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 83faf1cf..7495c348 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -132,15 +132,11 @@ export type HierarchyDescriptor = export interface Job { /** * `W`eight - the weight of the node (for EvaluateGraphNode jobs). + * UpdateExternalDependent jobs also use this to track the order they were + * added as dependents, since that's the order they should evaluate in. */ W?: number - /** - * `F`lags - the EdgeFlags of the edge between the scheduled node and the node - * that caused it to schedule an update (for UpdateExternalDependent jobs). - */ - F?: number - /** * `j`ob - the actual task to run. */ diff --git a/packages/machines/package.json b/packages/machines/package.json index c239c9cd..7d8e3067 100644 --- a/packages/machines/package.json +++ b/packages/machines/package.json @@ -10,7 +10,8 @@ "url": "https://github.com/Omnistac/zedux/issues" }, "devDependencies": { - "@zedux/atoms": "^2.0.0-alpha.1" + "@zedux/atoms": "^2.0.0-alpha.1", + "@zedux/core": "^2.0.0-alpha.1" }, "exports": { ".": { @@ -35,7 +36,8 @@ ], "license": "MIT", "peerDependencies": { - "@zedux/atoms": "^2.0.0-alpha.1" + "@zedux/atoms": "^2.0.0-alpha.1", + "@zedux/core": "^2.0.0-alpha.1" }, "repository": { "directory": "packages/machines", diff --git a/packages/machines/src/MachineStore.ts b/packages/machines/src/MachineStore.ts index c12b84be..d2a7ce60 100644 --- a/packages/machines/src/MachineStore.ts +++ b/packages/machines/src/MachineStore.ts @@ -1,4 +1,4 @@ -import { RecursivePartial, Settable, Store } from '@zedux/atoms' +import { RecursivePartial, Settable, Store } from '@zedux/core' import { MachineStateShape } from './types' /** diff --git a/packages/machines/test/integrations/state-machines.test.tsx b/packages/machines/test/integrations/state-machines.test.tsx index 3d775d54..27e96b41 100644 --- a/packages/machines/test/integrations/state-machines.test.tsx +++ b/packages/machines/test/integrations/state-machines.test.tsx @@ -3,7 +3,7 @@ import { InjectMachineStoreParams, MachineState, } from '@zedux/machines' -import { api, atom } from '@zedux/atoms' +import { api, atom } from '@zedux/stores' import { ecosystem } from '../../../react/test/utils/ecosystem' const injectMachine = < diff --git a/packages/machines/test/snippets/api.tsx b/packages/machines/test/snippets/api.tsx index 5c5fd603..5240323b 100644 --- a/packages/machines/test/snippets/api.tsx +++ b/packages/machines/test/snippets/api.tsx @@ -1,12 +1,6 @@ -import { - api, - atom, - injectStore, - ion, - useAtomSelector, - useAtomValue, -} from '../../../react/src' +import { useAtomSelector, useAtomValue } from '../../../react/src' import { injectMachineStore } from '@zedux/machines' +import { api, atom, injectStore, ion } from '@zedux/stores' import React, { Suspense, useState } from 'react' const a = atom('a', () => injectStore('a')) diff --git a/packages/react/src/hooks/useAtomContext.ts b/packages/react/src/hooks/useAtomContext.ts index ed478051..a1f711aa 100644 --- a/packages/react/src/hooks/useAtomContext.ts +++ b/packages/react/src/hooks/useAtomContext.ts @@ -2,9 +2,9 @@ import { AnyAtomGenerics, AnyAtomTemplate, AtomInstance, - AtomInstanceType, - AtomParamsType, AtomTemplateBase, + NodeOf, + ParamsOf, } from '@zedux/atoms' import { is } from '@zedux/core' import { useContext } from 'react' @@ -36,17 +36,17 @@ import { getReactContext } from '../utils' * in the providing component. */ export const useAtomContext: { - (template: A): AtomInstanceType | undefined + (template: A): NodeOf | undefined ( template: A, - defaultParams: AtomParamsType - ): AtomInstanceType + defaultParams: ParamsOf + ): NodeOf ( template: A, throwIfNotProvided: boolean - ): AtomInstanceType + ): NodeOf } = >( template: AtomTemplateBase, defaultParams?: G['Params'] | boolean diff --git a/packages/react/src/hooks/useAtomInstance.ts b/packages/react/src/hooks/useAtomInstance.ts index 4dfdc4fd..2f1a19f0 100644 --- a/packages/react/src/hooks/useAtomInstance.ts +++ b/packages/react/src/hooks/useAtomInstance.ts @@ -2,10 +2,10 @@ import { AnyAtomInstance, AnyAtomTemplate, AtomInstance, - AtomInstanceType, - AtomParamsType, ExternalNode, + NodeOf, ParamlessTemplate, + ParamsOf, } from '@zedux/atoms' import { Dispatch, SetStateAction, useEffect, useState } from 'react' import { ZeduxHookConfig } from '../types' @@ -49,15 +49,13 @@ const OPERATION = 'useAtomInstance' export const useAtomInstance: { ( template: A, - params: AtomParamsType, + params: ParamsOf, config?: ZeduxHookConfig - ): AtomInstanceType + ): NodeOf - >(template: A): AtomInstanceType + >(template: A): NodeOf - ( - template: ParamlessTemplate - ): AtomInstanceType + (template: ParamlessTemplate): NodeOf ( instance: I, @@ -66,7 +64,7 @@ export const useAtomInstance: { ): I } = ( atom: A | AnyAtomInstance, - params?: AtomParamsType, + params?: ParamsOf, { operation = OPERATION, subscribe, suspend }: ZeduxHookConfig = { operation: OPERATION, } diff --git a/packages/react/src/hooks/useAtomSelector.ts b/packages/react/src/hooks/useAtomSelector.ts index 68a959bf..dac0970e 100644 --- a/packages/react/src/hooks/useAtomSelector.ts +++ b/packages/react/src/hooks/useAtomSelector.ts @@ -1,9 +1,9 @@ import { - AtomParamsType, - AtomStateType, ExternalNode, + ParamsOf, Selectable, SelectorInstance, + StateOf, } from '@zedux/atoms' import { Dispatch, SetStateAction, useEffect, useState } from 'react' import { External } from '../utils' @@ -24,8 +24,8 @@ const OPERATION = 'useAtomSelector' */ export const useAtomSelector = ( template: S, - ...args: AtomParamsType -): AtomStateType => { + ...args: ParamsOf +): StateOf => { const ecosystem = useEcosystem() const observerId = useReactComponentId() // use this referentially stable setState function as a ref. We lazily add diff --git a/packages/react/src/hooks/useAtomState.ts b/packages/react/src/hooks/useAtomState.ts index 3146e587..99f294fc 100644 --- a/packages/react/src/hooks/useAtomState.ts +++ b/packages/react/src/hooks/useAtomState.ts @@ -1,13 +1,13 @@ import { AnyAtomGenerics, AnyAtomTemplate, - AtomExportsType, AtomInstance, - AtomParamsType, - AtomStateType, AtomTemplateBase, + ExportsOf, ParamlessTemplate, + ParamsOf, StateHookTuple, + StateOf, } from '@zedux/atoms' import { useAtomInstance } from './useAtomInstance' @@ -50,20 +50,20 @@ import { useAtomInstance } from './useAtomInstance' export const useAtomState: { >( template: A, - params: AtomParamsType - ): StateHookTuple, AtomExportsType> + params: ParamsOf + ): StateHookTuple, ExportsOf> >( template: A - ): StateHookTuple, AtomExportsType> + ): StateHookTuple, ExportsOf> >( template: ParamlessTemplate - ): StateHookTuple, AtomExportsType> + ): StateHookTuple, ExportsOf> (instance: I): StateHookTuple< - AtomStateType, - AtomExportsType + StateOf, + ExportsOf > } = >( atom: AtomTemplateBase, diff --git a/packages/react/src/hooks/useAtomValue.ts b/packages/react/src/hooks/useAtomValue.ts index 00845e38..7f94a7cf 100644 --- a/packages/react/src/hooks/useAtomValue.ts +++ b/packages/react/src/hooks/useAtomValue.ts @@ -2,10 +2,10 @@ import { AnyAtomGenerics, AnyAtomInstance, AnyAtomTemplate, - AtomParamsType, - AtomStateType, AtomTemplateBase, ParamlessTemplate, + ParamsOf, + StateOf, } from '@zedux/atoms' import { ZeduxHookConfig } from '../types' import { useAtomInstance } from './useAtomInstance' @@ -40,19 +40,19 @@ import { useAtomInstance } from './useAtomInstance' export const useAtomValue: { ( template: A, - params: AtomParamsType, + params: ParamsOf, config?: Omit - ): AtomStateType + ): StateOf - >(template: A): AtomStateType + >(template: A): StateOf - (template: ParamlessTemplate): AtomStateType + (template: ParamlessTemplate): StateOf ( instance: I, params?: [], config?: Omit - ): AtomStateType + ): StateOf } = ( atom: AtomTemplateBase, params?: G['Params'], diff --git a/packages/react/test/__snapshots__/types.test.tsx.snap b/packages/react/test/__snapshots__/types.test.tsx.snap new file mode 100644 index 00000000..2078e160 --- /dev/null +++ b/packages/react/test/__snapshots__/types.test.tsx.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`react types signals 1`] = ` +{ + "@signal-0": { + "className": "Signal", + "observers": { + "no-1": { + "createdAt": 123456789, + "flags": 3, + "operation": "on", + }, + "no-2": { + "createdAt": 123456789, + "flags": 3, + "operation": "on", + }, + "no-3": { + "createdAt": 123456789, + "flags": 3, + "operation": "on", + }, + }, + "sources": {}, + "state": 1, + "status": "Initializing", + "weight": 1, + }, + "no-1": { + "className": "ExternalNode", + "observers": {}, + "sources": { + "@signal-0": { + "createdAt": 123456789, + "flags": 3, + "operation": "on", + }, + }, + "state": undefined, + "status": "Active", + "weight": 3, + }, + "no-2": { + "className": "ExternalNode", + "observers": {}, + "sources": { + "@signal-0": { + "createdAt": 123456789, + "flags": 3, + "operation": "on", + }, + }, + "state": undefined, + "status": "Active", + "weight": 4, + }, + "no-3": { + "className": "ExternalNode", + "observers": {}, + "sources": { + "@signal-0": { + "createdAt": 123456789, + "flags": 3, + "operation": "on", + }, + }, + "state": undefined, + "status": "Active", + "weight": 5, + }, +} +`; diff --git a/packages/react/test/integrations/__snapshots__/ecosystem.test.tsx.snap b/packages/react/test/integrations/__snapshots__/ecosystem.test.tsx.snap index 0d7b007b..3e414992 100644 --- a/packages/react/test/integrations/__snapshots__/ecosystem.test.tsx.snap +++ b/packages/react/test/integrations/__snapshots__/ecosystem.test.tsx.snap @@ -2,6 +2,16 @@ exports[`ecosystem big graph 1`] = ` { + "@signal(atom1)-0": { + "dependencies": [], + "dependents": [ + { + "key": "atom1", + "operation": "injectSignal", + }, + ], + "weight": 1, + }, "Child-:r0:": { "dependencies": [ { @@ -10,7 +20,7 @@ exports[`ecosystem big graph 1`] = ` }, ], "dependents": [], - "weight": 11, + "weight": 16, }, "Child-:r1:": { "dependencies": [ @@ -20,7 +30,7 @@ exports[`ecosystem big graph 1`] = ` }, ], "dependents": [], - "weight": 7, + "weight": 10, }, "Child-:r2:": { "dependencies": [ @@ -30,7 +40,7 @@ exports[`ecosystem big graph 1`] = ` }, ], "dependents": [], - "weight": 5, + "weight": 7, }, "Child-:r3:": { "dependencies": [ @@ -40,7 +50,7 @@ exports[`ecosystem big graph 1`] = ` }, ], "dependents": [], - "weight": 3, + "weight": 4, }, "Child-:r4:": { "dependencies": [ @@ -50,10 +60,15 @@ exports[`ecosystem big graph 1`] = ` }, ], "dependents": [], - "weight": 2, + "weight": 3, }, "atom1": { - "dependencies": [], + "dependencies": [ + { + "key": "@signal(atom1)-0", + "operation": "injectSignal", + }, + ], "dependents": [ { "key": "atom2", @@ -76,7 +91,7 @@ exports[`ecosystem big graph 1`] = ` "operation": "useAtomValue", }, ], - "weight": 1, + "weight": 2, }, "atom2": { "dependencies": [ @@ -99,7 +114,7 @@ exports[`ecosystem big graph 1`] = ` "operation": "useAtomValue", }, ], - "weight": 2, + "weight": 3, }, "atom3-["1"]": { "dependencies": [ @@ -122,7 +137,7 @@ exports[`ecosystem big graph 1`] = ` "operation": "useAtomValue", }, ], - "weight": 4, + "weight": 6, }, "atom4": { "dependencies": [ @@ -145,7 +160,7 @@ exports[`ecosystem big graph 1`] = ` "operation": "useAtomState", }, ], - "weight": 6, + "weight": 9, }, "atom5": { "dependencies": [ @@ -168,7 +183,7 @@ exports[`ecosystem big graph 1`] = ` "operation": "useAtomValue", }, ], - "weight": 10, + "weight": 15, }, } `; @@ -177,16 +192,26 @@ exports[`ecosystem big graph 2`] = ` { "Child-:r0:": { "atom5": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom2": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, }, "atom4": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom3-["1"]": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom2": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, }, }, }, @@ -194,42 +219,66 @@ exports[`ecosystem big graph 2`] = ` }, "Child-:r1:": { "atom4": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom3-["1"]": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom2": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, }, }, }, }, "Child-:r2:": { "atom3-["1"]": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom2": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, }, }, }, "Child-:r3:": { "atom2": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, }, }, "Child-:r4:": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, }, "atom5": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom2": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, }, "atom4": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom3-["1"]": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, "atom2": { - "atom1": {}, + "atom1": { + "@signal(atom1)-0": {}, + }, }, }, }, @@ -239,10 +288,24 @@ exports[`ecosystem big graph 2`] = ` exports[`ecosystem big graph 3`] = ` { - "atom1": { - "Child-:r4:": {}, - "atom2": { - "Child-:r3:": {}, + "@signal(atom1)-0": { + "atom1": { + "Child-:r4:": {}, + "atom2": { + "Child-:r3:": {}, + "atom3-["1"]": { + "Child-:r2:": {}, + "atom4": { + "Child-:r1:": {}, + "atom5": { + "Child-:r0:": {}, + }, + }, + }, + "atom5": { + "Child-:r0:": {}, + }, + }, "atom3-["1"]": { "Child-:r2:": {}, "atom4": { @@ -252,28 +315,16 @@ exports[`ecosystem big graph 3`] = ` }, }, }, - "atom5": { - "Child-:r0:": {}, - }, - }, - "atom3-["1"]": { - "Child-:r2:": {}, "atom4": { "Child-:r1:": {}, "atom5": { "Child-:r0:": {}, }, }, - }, - "atom4": { - "Child-:r1:": {}, "atom5": { "Child-:r0:": {}, }, }, - "atom5": { - "Child-:r0:": {}, - }, }, } `; diff --git a/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap b/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap index 7ee5d022..ae366163 100644 --- a/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap +++ b/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap @@ -57,7 +57,7 @@ exports[`selection same-name selectors share the namespace when destroyed and re }, "state": undefined, "status": "Active", - "weight": 4, + "weight": 5, }, "root": { "className": "AtomInstance", @@ -134,7 +134,7 @@ exports[`selection same-name selectors share the namespace when destroyed and re }, "state": undefined, "status": "Active", - "weight": 4, + "weight": 7, }, "root": { "className": "AtomInstance", @@ -254,7 +254,7 @@ exports[`selection same-name selectors share the namespace when destroyed and re }, "state": undefined, "status": "Active", - "weight": 4, + "weight": 7, }, "no-4": { "className": "ExternalNode", @@ -268,7 +268,7 @@ exports[`selection same-name selectors share the namespace when destroyed and re }, "state": undefined, "status": "Active", - "weight": 4, + "weight": 8, }, "root": { "className": "AtomInstance", @@ -351,7 +351,7 @@ exports[`selection same-name selectors share the namespace when destroyed and re }, "state": undefined, "status": "Active", - "weight": 4, + "weight": 8, }, "root": { "className": "AtomInstance", diff --git a/packages/react/test/integrations/ecosystem.test.tsx b/packages/react/test/integrations/ecosystem.test.tsx index 4c941d4a..dbe6ff24 100644 --- a/packages/react/test/integrations/ecosystem.test.tsx +++ b/packages/react/test/integrations/ecosystem.test.tsx @@ -3,7 +3,7 @@ import { Ecosystem, createEcosystem, injectAtomValue, - injectStore, + injectSignal, injectWhy, useAtomState, useAtomValue, @@ -30,7 +30,7 @@ describe('ecosystem', () => { const atom1 = atom('atom1', () => { evaluate1() - const store = injectStore('1') + const store = injectSignal('1') return store }) @@ -95,6 +95,7 @@ describe('ecosystem', () => { 'atom5', 'atom2', 'atom1', + '@signal(atom1)-0', 'atom4', 'atom3-["1"]', 'Child-:r0:', @@ -114,7 +115,7 @@ describe('ecosystem', () => { expect(evaluations).toEqual([5, 2, 1, 4, 3]) act(() => { - ecosystem.getInstance(atom1).setState('0') + ecosystem.getInstance(atom1).set('0') }) await findByText('1 0 0 2 0 0 2 0') diff --git a/packages/react/test/integrations/injectors.test.tsx b/packages/react/test/integrations/injectors.test.tsx index 5c9d88f1..9d50126f 100644 --- a/packages/react/test/integrations/injectors.test.tsx +++ b/packages/react/test/integrations/injectors.test.tsx @@ -1,5 +1,6 @@ import { EvaluationReason, + StateOf, api, atom, injectAtomGetters, @@ -14,7 +15,7 @@ import { injectPromise, injectRef, injectSelf, - injectStore, + injectSignal, injectWhy, } from '@zedux/react' import { ecosystem } from '../utils/ecosystem' @@ -34,7 +35,7 @@ describe('injectors', () => { injectPromise, injectRef, injectSelf, - injectStore, + injectSignal, injectWhy, ].forEach(injector => { expect(injector).toThrowError( @@ -54,34 +55,34 @@ describe('injectors', () => { const cbB = () => 'bb' const atom1 = atom('1', () => { - const store = injectStore('a') - const val1 = injectMemo(() => store.getState()) - const val2 = injectMemo(() => store.getState(), []) - const val3 = injectMemo(() => store.getState(), [store.getState()]) + const signal = injectSignal('a') + const val1 = injectMemo(() => signal.get()) + const val2 = injectMemo(() => signal.get(), []) + const val3 = injectMemo(() => signal.get(), [signal.get()]) vals.push(val1, val2, val3) const injectedRef = injectRef(ref) refs.push(injectedRef.current) - const cb1 = injectCallback(store.getState() === 'a' ? cbA : cbB) - const cb2 = injectCallback(store.getState() === 'a' ? cbA : cbB, []) - const cb3 = injectCallback(store.getState() === 'a' ? cbA : cbB, [ - store.getState(), + const cb1 = injectCallback(signal.get() === 'a' ? cbA : cbB) + const cb2 = injectCallback(signal.get() === 'a' ? cbA : cbB, []) + const cb3 = injectCallback(signal.get() === 'a' ? cbA : cbB, [ + signal.get(), ]) cbs.push(cb1(), cb2(), cb3()) injectEffect(() => { - effects.push(store.getState()) + effects.push(signal.get()) - return () => cleanups.push(store.getState()) - }, [store.getState()]) + return () => cleanups.push(signal.get()) + }, [signal.get()]) - return store + return signal }) const instance = ecosystem.getInstance(atom1) - instance.setState('b') + instance.set('b') expect(vals).toEqual(['a', 'a', 'a', 'b', 'a', 'b']) expect(cbs).toEqual(['aa', 'aa', 'aa', 'bb', 'aa', 'bb']) @@ -89,7 +90,7 @@ describe('injectors', () => { expect(cleanups).toEqual([]) expect(refs).toEqual([ref, ref]) - instance.setState('c') + 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']) @@ -103,30 +104,30 @@ describe('injectors', () => { const atom1 = atom('1', () => 1) const atom2 = atom('2', () => { - const store = injectStore(2) + const signal = injectSignal(2) - return api(store).setExports({ - set2: (val: number) => store.setState(val), + return api(signal).setExports({ + set2: (val: number) => signal.set(val), }) }) const atom3 = atom('3', () => { const invalidate = injectInvalidate() - const store = injectStore('a') + const signal = injectSignal('a') const one = injectAtomValue(atom1) const [two, setTwo] = injectAtomState(atom2) const { set2 } = setTwo - vals.push([store.getState(), one, two]) + vals.push([signal.get(), one, two]) - return api(store).setExports({ invalidate, set2, setTwo }) + return api(signal).setExports({ invalidate, set2, setTwo }) }) const instance = ecosystem.getInstance(atom3) expect(vals).toEqual([['a', 1, 2]]) - instance.setState('b') + instance.set('b') expect(vals).toEqual([ ['a', 1, 2], @@ -160,16 +161,16 @@ describe('injectors', () => { const atom3 = atom('3', () => { const invalidate = injectInvalidate() const instance1 = injectAtomInstance(atom1) - const [subscribe, setSubscribe] = injectAtomState(instance1) - const store = injectStore('a', { subscribe }) + const [isReactive, setIsReactive] = injectAtomState(instance1) + const signal = injectSignal('a', { reactive: isReactive }) const instance2 = injectAtomInstance(atom2) - vals.push([store.getState(), subscribe, instance2.getState()]) + vals.push([signal.get(), isReactive, instance2.get()]) - return api(store).setExports({ + return api(signal).setExports({ invalidate, - setSubscribe, - setTwo: instance2.setState, + setIsReactive, + setTwo: (val: StateOf) => instance2.set(val), }) }) @@ -177,7 +178,7 @@ describe('injectors', () => { expect(vals).toEqual([['a', true, 2]]) - instance.exports.setSubscribe(false) + instance.exports.setIsReactive(false) expect(vals).toEqual([ ['a', true, 2], @@ -191,7 +192,7 @@ describe('injectors', () => { ['a', false, 2], ]) - instance.exports.setSubscribe(true) + instance.exports.setIsReactive(true) expect(vals).toEqual([ ['a', true, 2], @@ -220,7 +221,7 @@ describe('injectors', () => { const instance = ecosystem.getInstance(atom1) const getValue = instance.exports.get(atom1) - const getInstanceValue = instance.exports.getInstance(atom1).getState() + const getInstanceValue = instance.exports.getInstance(atom1).get() const selectValue = instance.exports.select(selector1) expect(getValue).toBe('a') @@ -239,41 +240,41 @@ describe('injectors', () => { const whys: (EvaluationReason[] | undefined)[] = [] const atom1 = atom('1', () => { - const store = injectStore('a') + const signal = injectSignal('a') const { ecosystem } = injectAtomGetters() whys.push(injectWhy()) whys.push(ecosystem.why()) - return store + return signal }) const instance1 = ecosystem.getInstance(atom1) expect(whys).toEqual([[], []]) - instance1.setState('b') + instance1.set('b') expect(whys).toEqual([ [], [], [ { - newState: undefined, // TODO: this will be defined again when atoms use signals + newState: 'b', oldState: 'a', - operation: undefined, - reasons: undefined, - source: undefined, + operation: 'injectSignal', + reasons: [], + source: ecosystem.find('@signal(1)'), type: 'state changed', }, ], [ { - newState: undefined, // TODO: this will be defined again when atoms use signals + newState: 'b', oldState: 'a', - operation: undefined, - reasons: undefined, - source: undefined, + operation: 'injectSignal', + reasons: [], + source: ecosystem.find('@signal(1)'), type: 'state changed', }, ], diff --git a/packages/react/test/integrations/lifecycle.test.tsx b/packages/react/test/integrations/lifecycle.test.tsx index 2e6822d1..4fa5db2b 100644 --- a/packages/react/test/integrations/lifecycle.test.tsx +++ b/packages/react/test/integrations/lifecycle.test.tsx @@ -5,7 +5,7 @@ import { createEcosystem, injectAtomValue, injectEffect, - injectStore, + injectSignal, useAtomValue, } from '@zedux/react' import React, { useState } from 'react' @@ -21,7 +21,7 @@ describe('ttl', () => { const atom1 = atom('atom1', () => { evaluations.push(1) - const store = injectStore('1') + const store = injectSignal('1') return store }) diff --git a/packages/react/test/integrations/plugins.test.tsx b/packages/react/test/integrations/plugins.test.tsx index 4c9fc402..b8bb0661 100644 --- a/packages/react/test/integrations/plugins.test.tsx +++ b/packages/react/test/integrations/plugins.test.tsx @@ -29,7 +29,7 @@ describe('plugins', () => { testEcosystem.registerPlugin(plugin) const instance1 = testEcosystem.getInstance(atom1) - instance1.setState('b') + instance1.set('b') expect(actionList).toEqual([ [ @@ -230,7 +230,7 @@ describe('plugins', () => { testEcosystem.getNode(selector1) const instance = testEcosystem.getInstance(atom1) - instance.setState('aa') + instance.set('aa') testEcosystem.destroy() diff --git a/packages/react/test/integrations/promises.test.tsx b/packages/react/test/integrations/promises.test.tsx index 2e666853..167190f3 100644 --- a/packages/react/test/integrations/promises.test.tsx +++ b/packages/react/test/integrations/promises.test.tsx @@ -15,7 +15,7 @@ const promiseAtom = atom('promise', (runOnInvalidate?: boolean) => { const atomApi = injectPromise( () => - new Promise(resolve => { + new Promise(resolve => { setTimeout(() => resolve(reloadCounter + countRef.current++), 1) }), [reloadCounter], @@ -42,7 +42,7 @@ describe('promises', () => { const promiseInstance = ecosystem.getInstance(promiseAtom) const reloadInstance = ecosystem.getInstance(reloadAtom) - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: undefined, isError: false, isLoading: true, @@ -53,7 +53,7 @@ describe('promises', () => { jest.runAllTimers() await Promise.resolve() // wait for injectPromise's `.then` to run - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 0, isError: false, isLoading: false, @@ -61,9 +61,9 @@ describe('promises', () => { status: 'success', }) - reloadInstance.setState(1) + reloadInstance.set(1) - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 0, isError: false, isLoading: true, @@ -74,7 +74,7 @@ describe('promises', () => { jest.runAllTimers() await Promise.resolve() // wait for injectPromise's `.then` to run - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 2, isError: false, isLoading: false, @@ -89,7 +89,7 @@ describe('promises', () => { const queryInstance = ecosystem.getInstance(queryAtom) const reloadInstance = ecosystem.getInstance(reloadAtom) - expect(queryInstance.getState()).toEqual({ + expect(queryInstance.get()).toEqual({ data: undefined, isError: false, isLoading: true, @@ -100,7 +100,7 @@ describe('promises', () => { jest.runAllTimers() await Promise.resolve() // wait for AtomInstance's `.then` to run - expect(queryInstance.getState()).toEqual({ + expect(queryInstance.get()).toEqual({ data: 0, isError: false, isLoading: false, @@ -108,9 +108,9 @@ describe('promises', () => { status: 'success', }) - reloadInstance.setState(1) + reloadInstance.set(1) - expect(queryInstance.getState()).toEqual({ + expect(queryInstance.get()).toEqual({ data: 0, isError: false, isLoading: true, @@ -121,7 +121,7 @@ describe('promises', () => { jest.runAllTimers() await Promise.resolve() // wait for AtomInstance's `.then` to run - expect(queryInstance.getState()).toEqual({ + expect(queryInstance.get()).toEqual({ data: 1, isError: false, isLoading: false, @@ -133,9 +133,9 @@ describe('promises', () => { test('injectPromise runOnInvalidate reruns promise factory on atom invalidation', async () => { jest.useFakeTimers() - const promiseInstance = ecosystem.getInstance(promiseAtom, [true]) + const promiseInstance = ecosystem.getNode(promiseAtom, [true]) - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: undefined, isError: false, isLoading: true, @@ -146,7 +146,7 @@ describe('promises', () => { jest.runAllTimers() await Promise.resolve() // wait for injectPromise's `.then` to run - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 0, isError: false, isLoading: false, @@ -156,7 +156,7 @@ describe('promises', () => { promiseInstance.invalidate() - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 0, isError: false, isLoading: true, @@ -167,7 +167,7 @@ describe('promises', () => { jest.runAllTimers() await Promise.resolve() // wait for injectPromise's `.then` to run - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 1, isError: false, isLoading: false, @@ -181,7 +181,7 @@ describe('promises', () => { const promiseInstance = ecosystem.getInstance(promiseAtom, [false]) - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: undefined, isError: false, isLoading: true, @@ -192,7 +192,7 @@ describe('promises', () => { jest.runAllTimers() await Promise.resolve() // wait for injectPromise's `.then` to run - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 0, isError: false, isLoading: false, @@ -202,7 +202,7 @@ describe('promises', () => { promiseInstance.invalidate() - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 0, isError: false, isLoading: false, @@ -213,7 +213,7 @@ describe('promises', () => { jest.runAllTimers() await Promise.resolve() // wait for injectPromise's `.then` to run - expect(promiseInstance.getState()).toEqual({ + expect(promiseInstance.get()).toEqual({ data: 0, isError: false, isLoading: false, diff --git a/packages/react/test/integrations/react-context.test.tsx b/packages/react/test/integrations/react-context.test.tsx index a20de820..4bb7b775 100644 --- a/packages/react/test/integrations/react-context.test.tsx +++ b/packages/react/test/integrations/react-context.test.tsx @@ -158,7 +158,7 @@ describe('React context', () => { expect(div.innerHTML).toBe('a') act(() => { - ecosystem.getInstance(atom1, ['a']).setState('aa') + ecosystem.getNode(atom1, ['a']).set('aa') jest.runAllTimers() }) @@ -179,7 +179,7 @@ describe('React context', () => { function Parent() { // useAtomInstance will naturally update the reference on force-destroy - const instance = ecosystem.getInstance(atom1, ['a']) + const instance = ecosystem.getNode(atom1, ['a']) return ( @@ -196,7 +196,7 @@ describe('React context', () => { expect(mock).not.toHaveBeenCalled() act(() => { - ecosystem.getInstance(atom1, ['a']).destroy(true) + ecosystem.getNode(atom1, ['a']).destroy(true) jest.runAllTimers() }) diff --git a/packages/react/test/integrations/selection.test.tsx b/packages/react/test/integrations/selection.test.tsx index 3a91ff21..5ae4b642 100644 --- a/packages/react/test/integrations/selection.test.tsx +++ b/packages/react/test/integrations/selection.test.tsx @@ -55,7 +55,7 @@ describe('selection', () => { expect(selector2).toHaveBeenCalledTimes(1) act(() => { - ecosystem.find(testAtom, ['a'])?.setState('b') + ecosystem.find(testAtom, ['a'])?.set('b') jest.runAllTimers() }) @@ -75,7 +75,7 @@ describe('selection', () => { const val = useAtomSelector(selector) useEffect(() => () => { - instance.setState('b') + instance.set('b') }) return
{val}
@@ -226,7 +226,7 @@ describe('selection', () => { expect(div.innerHTML).toBe('object') act(() => { - instance.setState(undefined) + instance.set(undefined) }) jest.runAllTimers() @@ -234,7 +234,7 @@ describe('selection', () => { expect(div.innerHTML).toBe('undefined') act(() => { - instance.setState(1) + instance.set(1) }) jest.runAllTimers() @@ -242,7 +242,7 @@ describe('selection', () => { expect(div.innerHTML).toBe('number') act(() => { - instance.setState(undefined) + instance.set(undefined) }) jest.runAllTimers() diff --git a/packages/react/test/integrations/signals.test.tsx b/packages/react/test/integrations/signals.test.tsx new file mode 100644 index 00000000..7dd600fe --- /dev/null +++ b/packages/react/test/integrations/signals.test.tsx @@ -0,0 +1,194 @@ +import { + api, + As, + atom, + injectMappedSignal, + injectSignal, + StateOf, + Transaction, +} from '@zedux/atoms' +import { ecosystem } from '../utils/ecosystem' +import { expectTypeOf } from 'expect-type' + +const atom1 = atom('1', () => { + const signal = injectSignal( + { a: 1, b: [{ c: 2 }] }, + { + events: { + eventA: As<{ test: boolean }>, + }, + } + ) + + return api(signal).setExports({ + mutateSignal: >( + key: K, + value: StateOf[K] + ) => + signal.mutate(state => { + state[key] = value + }), + setSignal: (state: StateOf) => signal.set(state), + signal, + }) +}) + +describe('signals', () => { + test('setting a signal triggers state changes in the injecting atom', () => { + const instance1 = ecosystem.getNode(atom1) + + expect(instance1.get()).toEqual({ a: 1, b: [{ c: 2 }] }) + + instance1.exports.setSignal({ a: 2, b: [{ c: 3 }] }) + + expect(instance1.get()).toEqual({ a: 2, b: [{ c: 3 }] }) + }) + + test('mutating a signal triggers state changes in the injecting atom', () => { + const instance1 = ecosystem.getNode(atom1) + + instance1.exports.mutateSignal('a', 2) + + expect(instance1.get()).toEqual({ a: 2, b: [{ c: 2 }] }) + }) + + test('mutating a signal generates a transaction', () => { + const instance1 = ecosystem.getNode(atom1) + + const [newState, transactions] = instance1.exports.mutateSignal('a', 2) + + expect(newState).toEqual({ a: 2, b: [{ c: 2 }] }) + expect(transactions).toEqual([{ k: 'a', v: 2 }]) + }) + + test('deeply mutating a signal generates a transaction with a nested key', () => { + const instance1 = ecosystem.getNode(atom1) + + const [newState, transactions] = instance1.exports.signal.mutate(state => { + state.b[0].c = 3 + }) + + expect(newState).toEqual({ a: 1, b: [{ c: 3 }] }) + expect(transactions).toEqual([{ k: ['b', '0', 'c'], v: 3 }]) + }) + + test('mutate listeners are notified of a transaction', () => { + const instance1 = ecosystem.getNode(atom1) + const transactionsList: Transaction[][] = [] + + instance1.exports.signal.on('mutate', transactions => { + expectTypeOf(transactions).toEqualTypeOf() + transactionsList.push(transactions) + }) + + instance1.exports.signal.mutate(state => { + state.b[0].c = 3 + }) + + expect(transactionsList).toEqual([[{ k: ['b', '0', 'c'], v: 3 }]]) + }) + + test('mixed mutations and sets all notify subscribers', () => { + const instance1 = ecosystem.getNode(atom1) + const calls: any[][] = [] + + instance1.exports.signal.on('mutate', (transactions, eventMap) => { + expectTypeOf(transactions).toEqualTypeOf() + + calls.push(['mutate', transactions, eventMap]) + }) + + instance1.exports.signal.on('change', (change, eventMap) => { + expectTypeOf(change).toEqualTypeOf<{ + newState: StateOf + oldState: StateOf + }>() + + calls.push(['change', change, eventMap]) + }) + + instance1.exports.signal.mutate(state => { + state.b[0].c = 3 + state.b = [] + }) + + const expectedEvents = { + change: { newState: { a: 1, b: [] }, oldState: { a: 1, b: [{ c: 2 }] } }, + mutate: [ + { k: ['b', '0', 'c'], v: 3 }, + { k: 'b', v: [] }, + ], + } + + expect(calls).toEqual([ + ['mutate', expectedEvents.mutate, expectedEvents], + ['change', expectedEvents.change, expectedEvents], + ]) + calls.splice(0, 2) + + instance1.exports.signal.set(state => ({ ...state, a: state.a + 1 })) + + const expectedEvents2 = { + change: { newState: { a: 2, b: [] }, oldState: { a: 1, b: [] } }, + } + + expect(calls).toEqual([['change', expectedEvents2.change, expectedEvents2]]) + }) +}) + +describe('mapped signals', () => { + test('mapped signals forward state updates to inner signals', () => { + const values: string[] = [] + + const atom1 = atom('1', () => { + const a = injectSignal('a') + const b = injectSignal('b') + + values.push(a.get(), b.get()) + + const mappedSignal = injectMappedSignal({ a, b }) + + return mappedSignal + }) + + expectTypeOf>().toEqualTypeOf<{ + a: string + b: string + }>() + + const instance1 = ecosystem.getNode(atom1) + + expect(instance1.get()).toEqual({ a: 'a', b: 'b' }) + expect(values).toEqual(['a', 'b']) + + instance1.set(state => ({ ...state, a: 'aa' })) + + expect(instance1.get()).toEqual({ a: 'aa', b: 'b' }) + expect(values).toEqual(['a', 'b', 'aa', 'b']) + + instance1.mutate(state => { + state.b = 'bb' + }) + + expect(instance1.get()).toEqual({ a: 'aa', b: 'bb' }) + expect(values).toEqual(['a', 'b', 'aa', 'b', 'aa', 'bb']) + }) +}) + +// const signalA = injectSignal('a', { +// events: { eventA: As, eventC: As }, +// }) +// const signalB = injectSignal('b', { +// events: { eventA: As, eventB: As<1 | 2> }, +// }) + +// const result = injectMappedSignal({ +// a: signalA, +// b: signalB, +// }) + +// type TEvents = EventsOf +// type Tuple = UnionToTuple> +// type TEvents4 = TupleToEvents + +// result.on('eventB', (test, map) => 2) diff --git a/packages/react/test/integrations/suspense.test.tsx b/packages/react/test/integrations/suspense.test.tsx index 4db0de69..3998af05 100644 --- a/packages/react/test/integrations/suspense.test.tsx +++ b/packages/react/test/integrations/suspense.test.tsx @@ -189,7 +189,7 @@ describe('suspense', () => { expect(div2.innerHTML).toBe('b') act(() => { - ecosystem.getInstance(atom1).setStateDeep({ data: 'c' }) + ecosystem.getInstance(atom1).mutate({ data: 'c' }) }) await Promise.resolve() diff --git a/packages/react/test/legacy-types.test.tsx b/packages/react/test/legacy-types.test.tsx new file mode 100644 index 00000000..fbc63e69 --- /dev/null +++ b/packages/react/test/legacy-types.test.tsx @@ -0,0 +1,806 @@ +import { Store, StoreStateType, createStore } from '@zedux/core' +import { + AnyAtomTemplate, + AtomGetters, + AtomTuple, + createEcosystem, + injectAtomInstance, + injectAtomState, + injectAtomValue, + injectCallback, + injectMemo, + injectSelf, + ParamlessTemplate, + PromiseState, +} from '@zedux/react' +import { + AnyStoreAtomInstance, + AnyStoreAtomTemplate, + AnyAtomGenerics, + api, + atom, + AtomApi, + AtomExportsType, + AtomInstance, + AtomInstanceRecursive, + AtomInstanceType, + AtomParamsType, + AtomPromiseType, + AtomStateType, + AtomStoreType, + AtomTemplateRecursive, + AtomTemplateType, + injectPromise, + injectStore, + ion, + IonTemplateRecursive, +} from '@zedux/stores' +import { expectTypeOf } from 'expect-type' + +const exampleAtom = atom('example', (p: string) => { + const store = injectStore(p) + + const partialInstance = injectSelf() + + if ((partialInstance as AtomInstanceType).store) { + ;(partialInstance as AtomInstanceType).store.getState() + } + + return api(store) + .setExports({ + getBool: () => Boolean(store.getState()), + getNum: () => Number(store.getState()), + }) + .setPromise(Promise.resolve(2)) +}) + +const ecosystem = createEcosystem({ id: 'root' }) + +afterEach(() => { + ecosystem.reset() +}) + +describe('react types', () => { + test('atom generic getters', () => { + const instance = ecosystem.getInstance(exampleAtom, ['a']) + + type AtomState = AtomStateType + type AtomParams = AtomParamsType + type AtomExports = AtomExportsType + type AtomPromise = AtomPromiseType + type AtomStore = AtomStoreType + + type AtomInstanceState = AtomStateType + type AtomInstanceParams = AtomParamsType + type AtomInstanceExports = AtomExportsType + type AtomInstancePromise = AtomPromiseType + type AtomInstanceStore = AtomStoreType + + type TAtomInstance = AtomInstanceType + type TAtomTemplate = AtomTemplateType + + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toHaveProperty('getNum').toBeFunction() + expectTypeOf() + .toHaveProperty('getNum') + .parameters.toEqualTypeOf<[]>() + expectTypeOf().toHaveProperty('getNum').returns.toBeNumber() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().resolves.toBeNumber() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + + expectTypeOf>().toBeString() + }) + + test('non-atom-api inference in atoms', () => { + const storeAtom = atom('store', (p: string) => injectStore(p)) + const valueAtom = atom('value', (p: string) => p) + + const storeInstance = ecosystem.getInstance(storeAtom, ['a']) + const valueInstance = ecosystem.getInstance(valueAtom, ['a']) + + type StoreAtomState = AtomStateType + type StoreAtomParams = AtomParamsType + type StoreAtomExports = AtomExportsType + type StoreAtomPromise = AtomPromiseType + type StoreAtomStore = AtomStoreType + type ValueAtomState = AtomStateType + type ValueAtomParams = AtomParamsType + type ValueAtomExports = AtomExportsType + type ValueAtomPromise = AtomPromiseType + type ValueAtomStore = AtomStoreType + + type StoreAtomInstanceState = AtomStateType + type StoreAtomInstanceParams = AtomParamsType + type StoreAtomInstanceExports = AtomExportsType + type StoreAtomInstancePromise = AtomPromiseType + type StoreAtomInstanceStore = AtomStoreType + type ValueAtomInstanceState = AtomStateType + type ValueAtomInstanceParams = AtomParamsType + type ValueAtomInstanceExports = AtomExportsType + type ValueAtomInstancePromise = AtomPromiseType + type ValueAtomInstanceStore = AtomStoreType + + type TStoreAtomInstance = AtomInstanceType + type TStoreAtomTemplate = AtomTemplateType + type TValueAtomInstance = AtomInstanceType + type TValueAtomTemplate = AtomTemplateType + + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + Record + >() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toBeUndefined() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeUndefined() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + AtomTemplateRecursive<{ + State: ValueAtomState + Params: ValueAtomParams + Events: Record + Exports: ValueAtomExports + Store: ValueAtomStore + Promise: ValueAtomPromise + }> + >() + }) + + test('atom api inference in atoms', () => { + const storeAtom = atom('store', (p: string) => { + const store = injectStore(p) + + return api(store) + .setExports({ toNum: (str: string) => Number(str) }) + .setPromise(Promise.resolve('b')) + }) + + const valueAtom = atom('value', (p: string) => { + return api(p) + .setExports({ toNum: (str: string) => Number(str) }) + .setPromise(Promise.resolve('b')) + }) + + const queryAtom = atom('query', (p: string) => { + return api(Promise.resolve(p)).setExports({ + toNum: (str: string) => Number(str), + }) + }) + + const queryWithPromiseAtom = atom('queryWithPromise', (p: string) => { + return api(Promise.resolve(p)) + .setExports({ toNum: (str: string) => Number(str) }) + .setPromise(Promise.resolve(1)) + }) + + const noExportsAtom = atom('noExports', () => api('a')) + + type ExpectedExports = { + toNum: (str: string) => number + } + + expectTypeOf>().toBeString() + expectTypeOf>().toBeString() + expectTypeOf>().toEqualTypeOf< + PromiseState + >() + expectTypeOf>().toEqualTypeOf< + PromiseState + >() + expectTypeOf>().toBeString() + + expectTypeOf>().toEqualTypeOf< + [p: string] + >() + expectTypeOf>().toEqualTypeOf< + [p: string] + >() + expectTypeOf>().toEqualTypeOf< + [p: string] + >() + expectTypeOf>().toEqualTypeOf< + [p: string] + >() + expectTypeOf>().toEqualTypeOf<[]>() + + expectTypeOf< + AtomExportsType + >().toEqualTypeOf() + expectTypeOf< + AtomExportsType + >().toEqualTypeOf() + expectTypeOf< + AtomExportsType + >().toEqualTypeOf() + expectTypeOf< + AtomExportsType + >().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf< + Record + >() + + expectTypeOf>().toEqualTypeOf< + Store + >() + expectTypeOf>().toEqualTypeOf< + Store + >() + expectTypeOf>().toEqualTypeOf< + Store> + >() + expectTypeOf>().toEqualTypeOf< + Store> + >() + expectTypeOf>().toEqualTypeOf< + Store + >() + + expectTypeOf>().toEqualTypeOf< + Promise + >() + expectTypeOf>().toEqualTypeOf< + Promise + >() + expectTypeOf>().toEqualTypeOf< + Promise + >() + expectTypeOf>().toEqualTypeOf< + Promise + >() + expectTypeOf< + AtomPromiseType + >().toEqualTypeOf() + }) + + test('non-atom-api inference in ions', () => { + const storeIon = ion('store', (_, p: string) => injectStore(p)) + const valueIon = ion('value', (_, p: string) => p) + + const storeInstance = ecosystem.getNode(storeIon, ['a']) + const valueInstance = ecosystem.getNode(valueIon, ['a']) + + type StoreIonState = AtomStateType + type StoreIonParams = AtomParamsType + type StoreIonExports = AtomExportsType + type StoreIonPromise = AtomPromiseType + type StoreIonStore = AtomStoreType + type ValueIonState = AtomStateType + type ValueIonParams = AtomParamsType + type ValueIonExports = AtomExportsType + type ValueIonPromise = AtomPromiseType + type ValueIonStore = AtomStoreType + + type StoreIonInstanceState = AtomStateType + type StoreIonInstanceParams = AtomParamsType + type StoreIonInstanceExports = AtomExportsType + type StoreIonInstancePromise = AtomPromiseType + type StoreIonInstanceStore = AtomStoreType + type ValueIonInstanceState = AtomStateType + type ValueIonInstanceParams = AtomParamsType + type ValueIonInstanceExports = AtomExportsType + type ValueIonInstancePromise = AtomPromiseType + type ValueIonInstanceStore = AtomStoreType + + type TStoreIonInstance = AtomInstanceType + type TStoreIonTemplate = AtomTemplateType + type TValueIonInstance = AtomInstanceType + type TValueIonTemplate = AtomTemplateType + + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + Record + >() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toBeUndefined() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeUndefined() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + IonTemplateRecursive<{ + State: StoreIonState + Params: StoreIonParams + Events: Record + Exports: StoreIonExports + Store: StoreIonStore + Promise: StoreIonPromise + }> + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + IonTemplateRecursive<{ + State: StoreIonState + Params: StoreIonParams + Events: Record + Exports: StoreIonExports + Store: StoreIonStore + Promise: StoreIonPromise + }> + >() + }) + + test('atom api inference in ions', () => { + const storeIon = ion('store', (_, p: string) => { + const store = injectStore(p) + + return api(store) + .setExports({ toNum: (str: string) => Number(str) }) + .setPromise(Promise.resolve('b')) + }) + + const valueIon = ion('value', (_, p: string) => { + return api(p) + .setExports({ toNum: (str: string) => Number(str) }) + .setPromise(Promise.resolve('b')) + }) + + const queryIon = ion('query', (_, p: string) => { + return api(Promise.resolve(p)).setExports({ + toNum: (str: string) => Number(str), + }) + }) + + const queryWithPromiseIon = ion('queryWithPromise', (_, p: string) => { + return api(Promise.resolve(p)) + .setExports({ toNum: (str: string) => Number(str) }) + .setPromise(Promise.resolve(1)) + }) + + type ExpectedExports = { + toNum: (str: string) => number + } + + expectTypeOf>().toBeString() + expectTypeOf>().toBeString() + expectTypeOf>().toEqualTypeOf< + PromiseState + >() + expectTypeOf>().toEqualTypeOf< + PromiseState + >() + + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf< + [p: string] + >() + + expectTypeOf< + AtomExportsType + >().toEqualTypeOf() + expectTypeOf< + AtomExportsType + >().toEqualTypeOf() + expectTypeOf< + AtomExportsType + >().toEqualTypeOf() + expectTypeOf< + AtomExportsType + >().toEqualTypeOf() + + expectTypeOf>().toEqualTypeOf< + Store + >() + expectTypeOf>().toEqualTypeOf< + Store + >() + expectTypeOf>().toEqualTypeOf< + Store> + >() + expectTypeOf>().toEqualTypeOf< + Store> + >() + + expectTypeOf>().toEqualTypeOf< + Promise + >() + expectTypeOf>().toEqualTypeOf< + Promise + >() + expectTypeOf>().toEqualTypeOf< + Promise + >() + expectTypeOf>().toEqualTypeOf< + Promise + >() + }) + + test('AnyAtomGenerics - instances as params', () => { + const innerAtom = atom('inner', 'a') + + 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() + } + ) + + const innerInstance = ecosystem.getInstance(innerAtom) + const outerInstance = ecosystem.getNode(outerAtom, [innerInstance]) + const val = outerInstance.get() + + expect(val).toBe('A') + expectTypeOf().toMatchTypeOf< + AtomInstanceRecursive<{ + Events: Record + Exports: Record + Params: [] + State: string + Store: Store + Promise: undefined + }> + >() + + expectTypeOf().toMatchTypeOf< + AtomInstanceRecursive<{ + Events: Record + Exports: Record + Params: [ + instance: AtomInstance< + AnyAtomGenerics<{ + State: string + }> + > + ] + State: string + Store: Store + Promise: undefined + }> + >() + }) + + test('accepting templates', () => { + const allOptionalParamsAtom = atom( + 'allOptionalParams', + (a?: boolean, b?: string[]) => { + const store = injectStore(a ? b : 2) + + return store + } + ) + + const allRequiredParamsAtom = atom( + 'allRequiredParams', + (a: string, b: number, c: boolean) => (c ? a : b) + ) + + const someOptionalParamsAtom = atom( + 'someOptionalParams', + (a: string, b?: number) => a + b + ) + + const getKey =
(template: A) => template.key + + let idCounter = 0 + const instantiateWithId = < + A extends AnyAtomTemplate<{ Params: [id: string] }> + >( + template: A + ) => + ecosystem.getInstance(template, [ + (idCounter++).toString(), + ] as AtomParamsType) + + const key = getKey(exampleAtom) + const instance = instantiateWithId(exampleAtom) + const instance2 = ecosystem.getInstance(allOptionalParamsAtom) + + // @ts-expect-error exampleAtom's param is required + ecosystem.getInstance(exampleAtom) + // @ts-expect-error exampleAtom's param should be a string + ecosystem.getInstance(exampleAtom, [2]) + // @ts-expect-error exampleAtom only needs 1 param + ecosystem.getInstance(exampleAtom, ['a', 2]) + ecosystem.getInstance(allOptionalParamsAtom, [undefined, undefined]) + ecosystem.getInstance(allOptionalParamsAtom, [undefined, ['1']]) + // @ts-expect-error allOptionalParamsAtom's 2nd param is type `string[]` + ecosystem.getInstance(allOptionalParamsAtom, [undefined, [1]]) + + expectTypeOf().toBeString() + expectTypeOf().toMatchTypeOf< + AtomInstanceRecursive<{ + State: string + Params: [p: string] + Events: Record + Exports: { + getBool: () => boolean + getNum: () => number + } + Store: Store + Promise: Promise + }> + >() + expectTypeOf().toMatchTypeOf< + AtomInstanceRecursive<{ + State: number | string[] | undefined + Params: [a?: boolean | undefined, b?: string[] | undefined] + Events: Record + Exports: Record + Store: Store + Promise: undefined + }> + >() + + // @ts-expect-error has a required param, so params must be passed + ecosystem.getInstance(someOptionalParamsAtom) + + // @ts-expect-error all params required, so params must be passed + ecosystem.get(allRequiredParamsAtom) + + function noParams>(atom: A) { + return ecosystem.get(atom) + } + + // @ts-expect-error optional params not allowed + noParams(someOptionalParamsAtom) + + expectTypeOf( + ecosystem.get(someOptionalParamsAtom, ['a']) + ).toMatchTypeOf() + + expectTypeOf(noParams(atom('no-params-test', null))).toMatchTypeOf() + }) + + test('accepting instances', () => { + const getExampleVal = >( + i: I + ) => i.getState() + + const getKey = (instance: I) => + instance.t.key + + const getUppercase = >( + instance: I + ) => instance.getState().toUpperCase() + + const getNum = < + I extends Omit, 'exports'> & { + exports: { getNum: () => number } + } + >( + i: I + ) => i.exports.getNum() + + const getNum2 = number } }>(i: I) => + i.exports.getNum() + + const getValue: { + // params ("family"): + ( + template: A, + params: AtomParamsType + ): AtomStateType + + // no params ("singleton"): + ( + template: ParamlessTemplate + ): AtomStateType + + // also accept instances: + (instance: I): AtomStateType + } = ( + template: A, + params?: AtomParamsType + ) => ecosystem.get(template as AnyStoreAtomTemplate, params) + + const instance = ecosystem.getInstance(exampleAtom, ['a']) + const exampleVal = getExampleVal(instance) + const key = getKey(instance) + const uppercase = getUppercase(instance) + const num = getNum(instance) + const num2 = getNum2(instance) + const val = getValue(exampleAtom, ['a']) + const val2 = getValue(instance) + + expectTypeOf().toBeString() + expectTypeOf().toBeString() + expectTypeOf().toBeString() + expectTypeOf().toBeNumber() + expectTypeOf().toBeNumber() + expectTypeOf().toBeString() + expectTypeOf().toBeString() + }) + + test('injectors', () => { + const injectingAtom = atom('injecting', () => { + // @ts-expect-error missing param + injectAtomInstance(exampleAtom) + const instance = injectAtomInstance(exampleAtom, ['a']) + const val1 = injectAtomValue(exampleAtom, ['a']) + const val2 = injectAtomValue(instance) + const [val3] = injectAtomState(exampleAtom, ['a']) + const [val4] = injectAtomState(instance) + const val5 = injectMemo(() => true, []) + const val6 = injectCallback(() => true, []) + const val7 = injectPromise(() => Promise.resolve(1), []) + + return api(injectStore(instance.getState())).setExports({ + val1, + val2, + val3, + val4, + val5, + val6, + val7, + }) + }) + + const instance = ecosystem.getInstance(injectingAtom) + + expectTypeOf>().toBeString() + expectTypeOf>().toMatchTypeOf<{ + val1: string + val2: string + val3: string + val4: string + val5: boolean + val6: () => boolean + val7: AtomApi<{ + Exports: Record + Promise: Promise + State: PromiseState + Store: Store> + }> + }>() + }) + + test('AtomTuple', () => { + const getPromise = }>>( + ...[template, params]: AtomTuple + ) => ecosystem.getInstance(template, params).promise as AtomPromiseType + + const promise = getPromise(exampleAtom, ['a']) + + expectTypeOf().resolves.toBeNumber() + }) + + test('AtomApi types helpers', () => { + const store = createStore(null, 'a') + const withEverything = api(store) + .addExports({ a: 1 }) + .setPromise(Promise.resolve(true)) + + expectTypeOf>().toEqualTypeOf<{ + a: number + }>() + expectTypeOf>().toEqualTypeOf< + Promise + >() + expectTypeOf>().toBeString() + expectTypeOf>().toEqualTypeOf< + Store + >() + }) + + test('promises', () => { + const atom1 = atom('1', () => api().setPromise()) + const atom2 = atom('1', () => api().setPromise(undefined)) + const atom3 = atom('1', () => + api().setPromise().setPromise(Promise.resolve(2)) + ) + + expectTypeOf>().toBeUndefined() + expectTypeOf>().toBeUndefined() + expectTypeOf>().resolves.toBeNumber() + }) + + test('recursive templates and nodes', () => { + const instanceA = exampleAtom._createInstance(ecosystem, 'a', ['b']) + const instanceB = instanceA.t._createInstance(ecosystem, '', ['b']) + const instanceC = instanceB.t._createInstance(ecosystem, '', ['b']) + const instanceD = instanceC.t._createInstance(ecosystem, '', ['b']) + + expectTypeOf>().toEqualTypeOf< + [p: string] + >() + + const instanceE = ecosystem.getInstance( + ecosystem.live.getInstance( + ecosystem.getInstance(ecosystem.live.getInstance(exampleAtom, ['a'])) + ) + ) + + expectTypeOf>().toEqualTypeOf< + [p: string] + >() + + const selectorInstance = ecosystem.getNode( + ecosystem.getNode( + ecosystem.getNode((_: AtomGetters, a?: string) => a, ['a']) + ) + ) + + expectTypeOf>().toEqualTypeOf< + string | undefined + >() + expectTypeOf>().toEqualTypeOf< + [a?: string] + >() + expectTypeOf>().toEqualTypeOf< + (_: AtomGetters, a?: string) => string | undefined + >() + + const selectorInstance2 = ecosystem.getNode( + ecosystem.getNode( + ecosystem.getNode( + { + resultsComparator: () => true, + selector: (_: AtomGetters, a?: string) => a, + argsComparator: () => true, + }, + ['a'] + ) + ) + ) + + expectTypeOf>().toEqualTypeOf< + string | undefined + >() + expectTypeOf>().toEqualTypeOf< + [a?: string] + >() + expectTypeOf>().toEqualTypeOf<{ + resultsComparator: () => boolean + selector: (_: AtomGetters, a?: string) => string | undefined + argsComparator: () => boolean + }>() + }) +}) diff --git a/packages/react/test/snippets/big-graph.tsx b/packages/react/test/snippets/big-graph.tsx index d3e9a06b..fcfb1539 100644 --- a/packages/react/test/snippets/big-graph.tsx +++ b/packages/react/test/snippets/big-graph.tsx @@ -1,10 +1,5 @@ -import { - atom, - injectAtomInstance, - injectAtomValue, - injectStore, - useAtomValue, -} from '@zedux/react' +import { injectAtomInstance, injectAtomValue, useAtomValue } from '@zedux/react' +import { atom, injectStore } from '@zedux/stores' import React from 'react' const atom1 = atom('atom1', () => { diff --git a/packages/react/test/snippets/context.tsx b/packages/react/test/snippets/context.tsx index 0f043ad2..b0d6794f 100644 --- a/packages/react/test/snippets/context.tsx +++ b/packages/react/test/snippets/context.tsx @@ -1,15 +1,14 @@ import { - atom, AtomProvider, createEcosystem, EcosystemProvider, injectAtomValue, injectEffect, - injectStore, useAtomContext, useAtomInstance, useAtomValue, } from '@zedux/react' +import { atom, injectStore } from '@zedux/stores' import React, { useState } from 'react' const testEcosystem = createEcosystem({ id: 'test' }) diff --git a/packages/react/test/snippets/contextual-atoms.tsx b/packages/react/test/snippets/contextual-atoms.tsx new file mode 100644 index 00000000..4742625a --- /dev/null +++ b/packages/react/test/snippets/contextual-atoms.tsx @@ -0,0 +1,138 @@ +import { + atom, + api, + ion, + AtomGetters, + injectMemo, + injectAtomGetters, + injectSelf, +} from '@zedux/atoms' +import { + useAtomValue, + useAtomContext, + useAtomInstance, + AtomProvider, +} from '@zedux/react' +import React, { + createContext, + use as reactUse, + useContext, + Suspense, + Context, +} from 'react' + +const use = reactUse as (context: Context) => T + +interface User { + name: string +} + +const fetchUser = () => + new Promise(resolve => + setTimeout(() => resolve({ name: 'Test User' }), 2000) + ) + +const userAtom = atom('user', () => api(fetchUser())) + +const userNameCapsAtom = atom('userNameCaps', () => { + const { ecosystem } = injectAtomGetters() + + const name = injectMemo(() => use(userContext)?.name, []) + + return name?.toUpperCase() +}) + +function injectReactContext(context: Context) { + const { id } = injectSelf() + + return injectMemo(() => { + const value = use(context) + + if (value == null) { + throw new Error( + `context was not provided during initial evaluation of atom "${id}"` + ) + } + + return value + }, []) +} + +const userNameAtom = atom( + 'userName', + () => injectReactContext(userContext).name +) + +const loadedUserDataAtom = ion('loadedUserData', ({ get }) => { + const userData = get(userAtom) + + if (!userData) { + throw new Error('tried accessing loaded user data before it was loaded') + } + + return userData +}) + +const userContext = createContext(undefined as undefined | User) +const getUserName = ({ get }: AtomGetters) => get(loadedUserDataAtom).data?.name + +// interface AuthContext { +// userInstance: AtomInstanceType +// } + +// interface RouteContext { +// path +// } + +const useUserFromZedux = () => { + const userData = useAtomValue(useAtomContext(userAtom, true)).data + + if (!userData) throw new Error('user not provided') + + return userData +} + +const useUserFromReact = () => { + const userData = useContext(userContext) + + if (!userData) throw new Error('user not provided') + + return userData +} + +function UserName() { + const userName = useAtomValue(userNameAtom) + + return userName == null ? null : {userName} +} + +function AuthedApp() { + const userFromZedux = useUserFromZedux() + const userFromReact = useUserFromReact() + + return ( +
+ The Authed App! {userFromZedux.name} {userFromReact.name} +
+ ) +} + +function AuthGate() { + const userInstance = useAtomInstance(userAtom) + + return ( + + + + + + ) +} + +export function App() { + return ( + Loading User Data}> + + + ) +} diff --git a/packages/react/test/snippets/ecosystem.tsx b/packages/react/test/snippets/ecosystem.tsx index 28da02a1..40c0ce8e 100644 --- a/packages/react/test/snippets/ecosystem.tsx +++ b/packages/react/test/snippets/ecosystem.tsx @@ -1,13 +1,12 @@ import { - atom, createEcosystem, EcosystemProvider, injectAtomInstance, injectAtomValue, - injectStore, injectWhy, useAtomValue, } from '@zedux/react' +import { atom, injectStore } from '@zedux/stores' import React from 'react' const testEcosystem = createEcosystem({ id: 'test' }) diff --git a/packages/react/test/snippets/inject-promise.tsx b/packages/react/test/snippets/inject-promise.tsx index ceb6df88..01a54c14 100644 --- a/packages/react/test/snippets/inject-promise.tsx +++ b/packages/react/test/snippets/inject-promise.tsx @@ -1,11 +1,11 @@ +import { useAtomValue } from '@zedux/react' import { api, atom, createStore, injectPromise, injectStore, - useAtomValue, -} from '@zedux/react' +} from '@zedux/stores' import React, { Suspense } from 'react' const asyncAtom = atom('async', () => { diff --git a/packages/react/test/snippets/ions.tsx b/packages/react/test/snippets/ions.tsx index 657ca5f8..1847118e 100644 --- a/packages/react/test/snippets/ions.tsx +++ b/packages/react/test/snippets/ions.tsx @@ -1,13 +1,10 @@ import { - api, - atom, AtomProvider, - AtomInstanceType, - ion, useAtomContext, useAtomInstance, useAtomValue, } from '@zedux/react' +import { api, atom, AtomInstanceType, ion } from '@zedux/stores' import React, { useState } from 'react' const otherAtom = atom('other', () => 'hello') diff --git a/packages/react/test/snippets/persistence.tsx b/packages/react/test/snippets/persistence.tsx index f069c5f6..ce987450 100644 --- a/packages/react/test/snippets/persistence.tsx +++ b/packages/react/test/snippets/persistence.tsx @@ -1,12 +1,5 @@ -import { - api, - atom, - createStore, - injectAtomInstance, - injectAtomValue, - injectStore, - useAtomState, -} from '@zedux/react' +import { injectAtomInstance, injectAtomValue, useAtomState } from '@zedux/react' +import { api, atom, createStore, injectStore } from '@zedux/stores' import React from 'react' const localStorageAtom = atom('localStorage', (key: string) => { diff --git a/packages/react/test/snippets/selectors.tsx b/packages/react/test/snippets/selectors.tsx index d4a0e1de..a5343bd9 100644 --- a/packages/react/test/snippets/selectors.tsx +++ b/packages/react/test/snippets/selectors.tsx @@ -1,17 +1,13 @@ import { - api, - atom, AtomProvider, - AtomInstanceType, injectAtomSelector, injectEffect, - injectStore, - ion, useAtomContext, useAtomInstance, useAtomSelector, useAtomValue, } from '@zedux/react' +import { api, atom, AtomInstanceType, injectStore, ion } from '@zedux/stores' import React, { useState } from 'react' const otherAtom = atom('other', () => 'hello') diff --git a/packages/react/test/snippets/stress-testing.tsx b/packages/react/test/snippets/stress-testing.tsx index d591c206..5169ee4f 100644 --- a/packages/react/test/snippets/stress-testing.tsx +++ b/packages/react/test/snippets/stress-testing.tsx @@ -24,7 +24,7 @@ const instance2 = ecosystem.getInstance(circularAtom, [200]) const start2 = performance.now() for (let i = 0; i < 50; i++) { const instance = ecosystem.getNode(circularAtom, [(i + 1) * 1000 + 99]) - instance.setState(state => state + 1) + instance.set(state => state + 1) } const end2 = performance.now() diff --git a/packages/react/test/snippets/suspense.tsx b/packages/react/test/snippets/suspense.tsx index 850d28b7..8da909dd 100644 --- a/packages/react/test/snippets/suspense.tsx +++ b/packages/react/test/snippets/suspense.tsx @@ -35,7 +35,7 @@ const forwardingAtom = atom('forwarding', (param?: string) => { function Grandchild() { const instance = useAtomInstance(forwardingAtom, ['param']) - return
The Second Value! {instance.store.getState()}
+ return
The Second Value! {instance.get()}
} function Child() { diff --git a/packages/react/test/snippets/ttl.tsx b/packages/react/test/snippets/ttl.tsx index bcc50864..7683a90d 100644 --- a/packages/react/test/snippets/ttl.tsx +++ b/packages/react/test/snippets/ttl.tsx @@ -1,14 +1,13 @@ import { - atom, createEcosystem, EcosystemProvider, injectAtomGetters, injectAtomValue, injectEffect, injectInvalidate, - injectStore, useAtomValue, } from '@zedux/react' +import { atom, injectStore } from '@zedux/stores' import React, { useState } from 'react' const testEcosystem = createEcosystem({ id: 'test' }) diff --git a/packages/react/test/units/AtomInstance.test.tsx b/packages/react/test/stores/AtomInstance.test.tsx similarity index 96% rename from packages/react/test/units/AtomInstance.test.tsx rename to packages/react/test/stores/AtomInstance.test.tsx index eeac8cff..74ea3457 100644 --- a/packages/react/test/units/AtomInstance.test.tsx +++ b/packages/react/test/stores/AtomInstance.test.tsx @@ -1,4 +1,5 @@ -import { atom, createStore, injectEffect, injectStore } from '@zedux/react' +import { createStore, injectEffect } from '@zedux/atoms' +import { atom, injectStore } from '@zedux/stores' import { ecosystem } from '../utils/ecosystem' import { mockConsole } from '../utils/console' diff --git a/packages/react/test/integrations/__snapshots__/graph.test.tsx.snap b/packages/react/test/stores/__snapshots__/graph.test.tsx.snap similarity index 96% rename from packages/react/test/integrations/__snapshots__/graph.test.tsx.snap rename to packages/react/test/stores/__snapshots__/graph.test.tsx.snap index d11ed468..de6e6c73 100644 --- a/packages/react/test/integrations/__snapshots__/graph.test.tsx.snap +++ b/packages/react/test/stores/__snapshots__/graph.test.tsx.snap @@ -8,7 +8,7 @@ exports[`graph getInstance(atom) returns the instance 1`] = ` "ion1": { "createdAt": 123456789, "flags": 4, - "operation": "getInstance", + "operation": "getNode", "p": undefined, }, }, @@ -23,7 +23,7 @@ exports[`graph getInstance(atom) returns the instance 1`] = ` "ion1": { "createdAt": 123456789, "flags": 4, - "operation": "getInstance", + "operation": "getNode", "p": undefined, }, }, @@ -39,13 +39,13 @@ exports[`graph getInstance(atom) returns the instance 1`] = ` "atom1": { "createdAt": 123456789, "flags": 4, - "operation": "getInstance", + "operation": "getNode", "p": undefined, }, "atom2": { "createdAt": 123456789, "flags": 4, - "operation": "getInstance", + "operation": "getNode", "p": undefined, }, }, @@ -66,7 +66,7 @@ exports[`graph injectAtomGetters 1`] = ` }, ], "dependents": [], - "weight": 4, + "weight": 3, }, "Test-:r1:": { "dependencies": [ @@ -76,7 +76,7 @@ exports[`graph injectAtomGetters 1`] = ` }, ], "dependents": [], - "weight": 1, + "weight": 0, }, "atom1": { "dependencies": [], diff --git a/packages/react/test/integrations/atom-stores.test.tsx b/packages/react/test/stores/atom-stores.test.tsx similarity index 96% rename from packages/react/test/integrations/atom-stores.test.tsx rename to packages/react/test/stores/atom-stores.test.tsx index f884c1c1..e994fd71 100644 --- a/packages/react/test/integrations/atom-stores.test.tsx +++ b/packages/react/test/stores/atom-stores.test.tsx @@ -1,10 +1,6 @@ import { createStore } from '@zedux/core' -import { - atom, - injectAtomInstance, - injectEffect, - injectStore, -} from '@zedux/react' +import { injectAtomInstance, injectEffect } from '@zedux/react' +import { atom, injectStore } from '@zedux/stores' import { ecosystem } from '../utils/ecosystem' describe('stores in atoms', () => { diff --git a/packages/react/test/integrations/batching.test.tsx b/packages/react/test/stores/batching.test.tsx similarity index 97% rename from packages/react/test/integrations/batching.test.tsx rename to packages/react/test/stores/batching.test.tsx index 946fdcb6..d4518d08 100644 --- a/packages/react/test/integrations/batching.test.tsx +++ b/packages/react/test/stores/batching.test.tsx @@ -1,12 +1,10 @@ import { - api, - atom, injectAtomGetters, injectCallback, injectRef, - injectStore, zeduxTypes, -} from '@zedux/react' +} from '@zedux/atoms' +import { api, atom, injectStore } from '@zedux/stores' import { ecosystem } from '../utils/ecosystem' describe('batching', () => { diff --git a/packages/react/test/integrations/dependency-injection.test.tsx b/packages/react/test/stores/dependency-injection.test.tsx similarity index 98% rename from packages/react/test/integrations/dependency-injection.test.tsx rename to packages/react/test/stores/dependency-injection.test.tsx index d3fdaa6f..cfebe7e3 100644 --- a/packages/react/test/integrations/dependency-injection.test.tsx +++ b/packages/react/test/stores/dependency-injection.test.tsx @@ -2,15 +2,12 @@ import { fireEvent } from '@testing-library/dom' import '@testing-library/jest-dom/extend-expect' import { act } from '@testing-library/react' import { - api, - atom, createStore, injectEffect, - injectStore, - ion, useAtomInstance, useAtomValue, } from '@zedux/react' +import { api, atom, injectStore, ion } from '@zedux/stores' import React, { FC } from 'react' import { renderInEcosystem } from '../utils/renderInEcosystem' import { ecosystem } from '../utils/ecosystem' diff --git a/packages/react/test/integrations/graph.test.tsx b/packages/react/test/stores/graph.test.tsx similarity index 98% rename from packages/react/test/integrations/graph.test.tsx rename to packages/react/test/stores/graph.test.tsx index b54e0071..dfa434f6 100644 --- a/packages/react/test/integrations/graph.test.tsx +++ b/packages/react/test/stores/graph.test.tsx @@ -1,21 +1,17 @@ import { fireEvent } from '@testing-library/dom' import { act } from '@testing-library/react' import { - api, - atom, createStore, injectAtomGetters, injectAtomValue, - injectStore, - ion, useAtomInstance, useAtomValue, - AtomInstanceType, injectAtomInstance, injectRef, AtomGetters, EvaluationReason, } from '@zedux/react' +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' diff --git a/packages/react/test/units/injectStore.test.tsx b/packages/react/test/stores/injectStore.test.tsx similarity index 95% rename from packages/react/test/units/injectStore.test.tsx rename to packages/react/test/stores/injectStore.test.tsx index f7bda10d..e91fa20b 100644 --- a/packages/react/test/units/injectStore.test.tsx +++ b/packages/react/test/stores/injectStore.test.tsx @@ -1,4 +1,4 @@ -import { atom, injectStore } from '@zedux/react' +import { atom, injectStore } from '@zedux/stores' import { ecosystem } from '../utils/ecosystem' describe('injectStore()', () => { diff --git a/packages/react/test/stores/injectors.test.tsx b/packages/react/test/stores/injectors.test.tsx new file mode 100644 index 00000000..a2b40514 --- /dev/null +++ b/packages/react/test/stores/injectors.test.tsx @@ -0,0 +1,281 @@ +import { + EvaluationReason, + injectAtomGetters, + injectAtomInstance, + injectAtomSelector, + injectAtomState, + injectAtomValue, + injectCallback, + injectEffect, + injectInvalidate, + injectMemo, + injectPromise, + injectRef, + injectSelf, + injectWhy, +} from '@zedux/react' +import { api, atom, injectStore } from '@zedux/stores' +import { ecosystem } from '../utils/ecosystem' + +describe('injectors', () => { + test('injectors can only be called during atom evaluation', () => { + ;[ + injectAtomGetters, + injectAtomInstance, + injectAtomSelector, + injectAtomState, + injectAtomValue, + injectCallback, + injectEffect, + injectInvalidate, + injectMemo, + injectPromise, + injectRef, + injectSelf, + injectStore, + injectWhy, + ].forEach(injector => { + expect(injector).toThrowError( + /injectors can only be used in atom state factories/i + ) + }) + }) + + test('React-esque injectors mimic React hook functionality', () => { + const ref = {} + const cbs: string[] = [] + const cleanups: string[] = [] + const effects: string[] = [] + const refs: (typeof ref)[] = [] + const vals: string[] = [] + const cbA = () => 'aa' + const cbB = () => 'bb' + + const atom1 = atom('1', () => { + const store = injectStore('a') + const val1 = injectMemo(() => store.getState()) + const val2 = injectMemo(() => store.getState(), []) + const val3 = injectMemo(() => store.getState(), [store.getState()]) + vals.push(val1, val2, val3) + + const injectedRef = injectRef(ref) + refs.push(injectedRef.current) + + const cb1 = injectCallback(store.getState() === 'a' ? cbA : cbB) + const cb2 = injectCallback(store.getState() === 'a' ? cbA : cbB, []) + const cb3 = injectCallback(store.getState() === 'a' ? cbA : cbB, [ + store.getState(), + ]) + cbs.push(cb1(), cb2(), cb3()) + + injectEffect(() => { + effects.push(store.getState()) + + return () => cleanups.push(store.getState()) + }, [store.getState()]) + + return store + }) + + const instance = ecosystem.getInstance(atom1) + + instance.setState('b') + + 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(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(refs).toEqual([ref, ref, ref]) + }) + + test('dynamic injectors subscribe to updates', () => { + const vals: [string, number, number][] = [] + + const atom1 = atom('1', () => 1) + const atom2 = atom('2', () => { + const store = injectStore(2) + + return api(store).setExports({ + set2: (val: number) => store.setState(val), + }) + }) + + const atom3 = atom('3', () => { + const invalidate = injectInvalidate() + const store = injectStore('a') + const one = injectAtomValue(atom1) + const [two, setTwo] = injectAtomState(atom2) + const { set2 } = setTwo + + vals.push([store.getState(), one, two]) + + return api(store).setExports({ invalidate, set2, setTwo }) + }) + + const instance = ecosystem.getInstance(atom3) + + expect(vals).toEqual([['a', 1, 2]]) + + instance.setState('b') + + expect(vals).toEqual([ + ['a', 1, 2], + ['b', 1, 2], + ]) + + instance.exports.set2(22) + + expect(vals).toEqual([ + ['a', 1, 2], + ['b', 1, 2], + ['b', 1, 22], + ]) + + instance.exports.setTwo(222) + + expect(vals).toEqual([ + ['a', 1, 2], + ['b', 1, 2], + ['b', 1, 22], + ['b', 1, 222], + ]) + }) + + test("static injectors don't subscribe to updates", () => { + const vals: [string, boolean, number][] = [] + + const atom1 = atom('1', () => true) + const atom2 = atom('2', () => 2) + + const atom3 = atom('3', () => { + const invalidate = injectInvalidate() + const instance1 = injectAtomInstance(atom1) + const [subscribe, setSubscribe] = injectAtomState(instance1) + const store = injectStore('a', { subscribe }) + const instance2 = injectAtomInstance(atom2) + + vals.push([store.getState(), subscribe, instance2.getState()]) + + return api(store).setExports({ + invalidate, + setSubscribe, + setTwo: instance2.setState, + }) + }) + + const instance = ecosystem.getInstance(atom3) + + expect(vals).toEqual([['a', true, 2]]) + + instance.exports.setSubscribe(false) + + expect(vals).toEqual([ + ['a', true, 2], + ['a', false, 2], + ]) + + instance.exports.setTwo(22) + + expect(vals).toEqual([ + ['a', true, 2], + ['a', false, 2], + ]) + + instance.exports.setSubscribe(true) + + expect(vals).toEqual([ + ['a', true, 2], + ['a', false, 2], + ['a', true, 22], + ]) + + instance.exports.invalidate() + + expect(vals).toEqual([ + ['a', true, 2], + ['a', false, 2], + ['a', true, 22], + ['a', true, 22], + ]) + }) + + test('injected AtomGetters do nothing after evaluation is over', () => { + const atom1 = atom('1', () => { + const { get, getInstance, select } = injectAtomGetters() + + return api('a').setExports({ get, getInstance, select }) + }) + + const selector1 = () => 1 + + const instance = ecosystem.getInstance(atom1) + const getValue = instance.exports.get(atom1) + const getInstanceValue = instance.exports.getInstance(atom1).getState() + const selectValue = instance.exports.select(selector1) + + expect(getValue).toBe('a') + expect(getInstanceValue).toBe('a') + expect(selectValue).toBe(1) + expect(ecosystem.viewGraph()).toEqual({ + 1: { + dependencies: [], + dependents: [], + weight: 1, + }, + }) + }) + + test('injectWhy() is an alias of ecosystem.why() during atom evaluation', () => { + const whys: (EvaluationReason[] | undefined)[] = [] + + const atom1 = atom('1', () => { + const store = injectStore('a') + const { ecosystem } = injectAtomGetters() + + whys.push(injectWhy()) + whys.push(ecosystem.why()) + + return store + }) + + const instance1 = ecosystem.getInstance(atom1) + + expect(whys).toEqual([[], []]) + + instance1.setState('b') + + expect(whys).toEqual([ + [], + [], + [ + { + newState: undefined, // TODO: this will be defined again when atoms use signals + oldState: 'a', + operation: undefined, + reasons: undefined, + source: undefined, + type: 'state changed', + }, + ], + [ + { + newState: undefined, // TODO: this will be defined again when atoms use signals + oldState: 'a', + operation: undefined, + reasons: undefined, + source: undefined, + type: 'state changed', + }, + ], + ]) + expect(whys[2]).toEqual(whys[3]) + }) +}) diff --git a/packages/react/test/integrations/ssr.test.tsx b/packages/react/test/stores/ssr.test.tsx similarity index 98% rename from packages/react/test/integrations/ssr.test.tsx rename to packages/react/test/stores/ssr.test.tsx index 217b79a2..41a6382b 100644 --- a/packages/react/test/integrations/ssr.test.tsx +++ b/packages/react/test/stores/ssr.test.tsx @@ -1,4 +1,5 @@ -import { atom, createStore, injectStore, ion } from '@zedux/react' +import { createStore } from '@zedux/core' +import { atom, injectStore, ion } from '@zedux/stores' import { ecosystem } from '../utils/ecosystem' describe('ssr', () => { diff --git a/packages/react/test/types.test.tsx b/packages/react/test/types.test.tsx index 69814877..58374861 100644 --- a/packages/react/test/types.test.tsx +++ b/packages/react/test/types.test.tsx @@ -1,4 +1,4 @@ -import { Store, StoreStateType, createStore } from '@zedux/core' +import { Signal } from '@zedux/atoms/classes/Signal' import { AnyAtomGenerics, AnyAtomInstance, @@ -6,53 +6,58 @@ import { api, atom, AtomApi, - AtomExportsType, + ExportsOf, AtomGetters, AtomInstance, AtomInstanceRecursive, - AtomInstanceType, - AtomParamsType, - AtomPromiseType, - AtomStateType, - AtomStoreType, + NodeOf, + ParamsOf, + PromiseOf, + StateOf, AtomTemplateRecursive, - AtomTemplateType, + TemplateOf, AtomTuple, - createEcosystem, injectAtomInstance, injectAtomState, injectAtomValue, injectCallback, injectMemo, injectPromise, - injectSelf, - injectStore, ion, IonTemplateRecursive, ParamlessTemplate, PromiseState, + injectSignal, + EventsOf, + As, + None, + Transaction, } from '@zedux/react' import { expectTypeOf } from 'expect-type' +import { ecosystem, snapshotNodes } from './utils/ecosystem' -const exampleAtom = atom('example', (p: string) => { - const store = injectStore(p) +const exampleEvents = { + numEvent: As, + strEvent: As, +} - const partialInstance = injectSelf() +type ExampleEvents = { + [K in keyof typeof exampleEvents]: ReturnType<(typeof exampleEvents)[K]> +} - if ((partialInstance as AtomInstanceType).store) { - ;(partialInstance as AtomInstanceType).store.getState() - } +const exampleAtom = atom('example', (p: string) => { + const signal = injectSignal(p, { + events: exampleEvents, + }) - return api(store) + return api(signal) .setExports({ - getBool: () => Boolean(store.getState()), - getNum: () => Number(store.getState()), + getBool: () => Boolean(signal.get()), + getNum: () => Number(signal.get()), }) .setPromise(Promise.resolve(2)) }) -const ecosystem = createEcosystem({ id: 'root' }) - afterEach(() => { ecosystem.reset() }) @@ -61,20 +66,20 @@ describe('react types', () => { test('atom generic getters', () => { const instance = ecosystem.getInstance(exampleAtom, ['a']) - type AtomState = AtomStateType - type AtomParams = AtomParamsType - type AtomExports = AtomExportsType - type AtomPromise = AtomPromiseType - type AtomStore = AtomStoreType + type AtomState = StateOf + type AtomParams = ParamsOf + type AtomExports = ExportsOf + type AtomPromise = PromiseOf + type AtomEvents = EventsOf - type AtomInstanceState = AtomStateType - type AtomInstanceParams = AtomParamsType - type AtomInstanceExports = AtomExportsType - type AtomInstancePromise = AtomPromiseType - type AtomInstanceStore = AtomStoreType + type AtomInstanceState = StateOf + type AtomInstanceParams = ParamsOf + type AtomInstanceExports = ExportsOf + type AtomInstancePromise = PromiseOf + type AtomInstanceStore = EventsOf - type TAtomInstance = AtomInstanceType - type TAtomTemplate = AtomTemplateType + type TAtomInstance = NodeOf + type TAtomTemplate = TemplateOf expectTypeOf().toBeString() expectTypeOf().toEqualTypeOf() @@ -92,95 +97,95 @@ describe('react types', () => { expectTypeOf().resolves.toBeNumber() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() - - expectTypeOf>().toBeString() }) test('non-atom-api inference in atoms', () => { - const storeAtom = atom('store', (p: string) => injectStore(p)) + const signalAtom = atom('signal', (p: string) => injectSignal(p)) const valueAtom = atom('value', (p: string) => p) - const storeInstance = ecosystem.getInstance(storeAtom, ['a']) + const signalInstance = ecosystem.getInstance(signalAtom, ['a']) const valueInstance = ecosystem.getInstance(valueAtom, ['a']) - type StoreAtomState = AtomStateType - type StoreAtomParams = AtomParamsType - type StoreAtomExports = AtomExportsType - type StoreAtomPromise = AtomPromiseType - type StoreAtomStore = AtomStoreType - type ValueAtomState = AtomStateType - type ValueAtomParams = AtomParamsType - type ValueAtomExports = AtomExportsType - type ValueAtomPromise = AtomPromiseType - type ValueAtomStore = AtomStoreType - - type StoreAtomInstanceState = AtomStateType - type StoreAtomInstanceParams = AtomParamsType - type StoreAtomInstanceExports = AtomExportsType - type StoreAtomInstancePromise = AtomPromiseType - type StoreAtomInstanceStore = AtomStoreType - type ValueAtomInstanceState = AtomStateType - type ValueAtomInstanceParams = AtomParamsType - type ValueAtomInstanceExports = AtomExportsType - type ValueAtomInstancePromise = AtomPromiseType - type ValueAtomInstanceStore = AtomStoreType - - type TStoreAtomInstance = AtomInstanceType - type TStoreAtomTemplate = AtomTemplateType - type TValueAtomInstance = AtomInstanceType - type TValueAtomTemplate = AtomTemplateType - - expectTypeOf().toBeString() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toBeString() - expectTypeOf().toEqualTypeOf() - - expectTypeOf().toEqualTypeOf<[p: string]>() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf<[p: string]>() - expectTypeOf().toEqualTypeOf() - - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf< + type SignalAtomState = StateOf + type SignalAtomParams = ParamsOf + type SignalAtomExports = ExportsOf + type SignalAtomPromise = PromiseOf + type SignalAtomEvents = EventsOf + type ValueAtomState = StateOf + type ValueAtomParams = ParamsOf + type ValueAtomExports = ExportsOf + type ValueAtomPromise = PromiseOf + type ValueAtomEvents = EventsOf + + type SignalAtomInstanceState = StateOf + type SignalAtomInstanceParams = ParamsOf + type SignalAtomInstanceExports = ExportsOf + type SignalAtomInstancePromise = PromiseOf + type SignalAtomInstanceEvents = EventsOf + type ValueAtomInstanceState = StateOf + type ValueAtomInstanceParams = ParamsOf + type ValueAtomInstanceExports = ExportsOf + type ValueAtomInstancePromise = PromiseOf + type ValueAtomInstanceEvents = EventsOf + + type TSignalAtomInstance = NodeOf + type TSignalAtomTemplate = TemplateOf + type TValueAtomInstance = NodeOf + type TValueAtomTemplate = TemplateOf + + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< Record >() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() - expectTypeOf().toBeUndefined() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toBeUndefined() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeUndefined() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeUndefined() + expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + Record + >() + expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf< AtomTemplateRecursive<{ State: ValueAtomState Params: ValueAtomParams Exports: ValueAtomExports - Store: ValueAtomStore + Events: Record Promise: ValueAtomPromise }> >() }) test('atom api inference in atoms', () => { - const storeAtom = atom('store', (p: string) => { - const store = injectStore(p) + const signalAtom = atom('signal', (p: string) => { + const signal = injectSignal(p) - return api(store) + return api(signal) .setExports({ toNum: (str: string) => Number(str) }) .setPromise(Promise.resolve('b')) }) @@ -209,167 +214,147 @@ describe('react types', () => { toNum: (str: string) => number } - expectTypeOf>().toBeString() - expectTypeOf>().toBeString() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toBeString() + expectTypeOf>().toBeString() + expectTypeOf>().toEqualTypeOf< PromiseState >() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< PromiseState >() - expectTypeOf>().toBeString() + expectTypeOf>().toBeString() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf< [p: string] >() - expectTypeOf>().toEqualTypeOf< - [p: string] - >() - expectTypeOf>().toEqualTypeOf< - [p: string] - >() - expectTypeOf>().toEqualTypeOf< - [p: string] - >() - expectTypeOf>().toEqualTypeOf<[]>() + expectTypeOf>().toEqualTypeOf<[]>() expectTypeOf< - AtomExportsType - >().toEqualTypeOf() - expectTypeOf< - AtomExportsType - >().toEqualTypeOf() - expectTypeOf< - AtomExportsType + ExportsOf >().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() expectTypeOf< - AtomExportsType + ExportsOf >().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< Record >() - expectTypeOf>().toEqualTypeOf< - Store + expectTypeOf>().toEqualTypeOf< + Record >() - expectTypeOf>().toEqualTypeOf< - Store - >() - expectTypeOf>().toEqualTypeOf< - Store> - >() - expectTypeOf>().toEqualTypeOf< - Store> - >() - expectTypeOf>().toEqualTypeOf< - Store + expectTypeOf>().toEqualTypeOf< + Record >() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf< - Promise - >() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< Promise >() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf< Promise >() - expectTypeOf>().toEqualTypeOf< - Promise - >() - expectTypeOf< - AtomPromiseType - >().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() }) test('non-atom-api inference in ions', () => { - const storeIon = ion('store', (_, p: string) => injectStore(p)) + const signalIon = ion('signal', (_, p: string) => injectSignal(p)) const valueIon = ion('value', (_, p: string) => p) - const storeInstance = ecosystem.getNode(storeIon, ['a']) + const signalInstance = ecosystem.getNode(signalIon, ['a']) const valueInstance = ecosystem.getNode(valueIon, ['a']) - type StoreIonState = AtomStateType - type StoreIonParams = AtomParamsType - type StoreIonExports = AtomExportsType - type StoreIonPromise = AtomPromiseType - type StoreIonStore = AtomStoreType - type ValueIonState = AtomStateType - type ValueIonParams = AtomParamsType - type ValueIonExports = AtomExportsType - type ValueIonPromise = AtomPromiseType - type ValueIonStore = AtomStoreType - - type StoreIonInstanceState = AtomStateType - type StoreIonInstanceParams = AtomParamsType - type StoreIonInstanceExports = AtomExportsType - type StoreIonInstancePromise = AtomPromiseType - type StoreIonInstanceStore = AtomStoreType - type ValueIonInstanceState = AtomStateType - type ValueIonInstanceParams = AtomParamsType - type ValueIonInstanceExports = AtomExportsType - type ValueIonInstancePromise = AtomPromiseType - type ValueIonInstanceStore = AtomStoreType - - type TStoreIonInstance = AtomInstanceType - type TStoreIonTemplate = AtomTemplateType - type TValueIonInstance = AtomInstanceType - type TValueIonTemplate = AtomTemplateType - - expectTypeOf().toBeString() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toBeString() - expectTypeOf().toEqualTypeOf() - - expectTypeOf().toEqualTypeOf<[p: string]>() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf<[p: string]>() - expectTypeOf().toEqualTypeOf() - - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf< - Record - >() - expectTypeOf().toEqualTypeOf() - - expectTypeOf().toBeUndefined() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toBeUndefined() - expectTypeOf().toEqualTypeOf() - - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf>() - expectTypeOf().toEqualTypeOf() - - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf< + type SignalIonState = StateOf + type SignalIonParams = ParamsOf + type SignalIonExports = ExportsOf + type SignalIonPromise = PromiseOf + type SignalIonEvents = EventsOf + type ValueIonState = StateOf + type ValueIonParams = ParamsOf + type ValueIonExports = ExportsOf + type ValueIonPromise = PromiseOf + type ValueIonEvents = EventsOf + + type SignalIonInstanceState = StateOf + type SignalIonInstanceParams = ParamsOf + type SignalIonInstanceExports = ExportsOf + type SignalIonInstancePromise = PromiseOf + type SignalIonInstanceEvents = EventsOf + type ValueIonInstanceState = StateOf + type ValueIonInstanceParams = ParamsOf + type ValueIonInstanceExports = ExportsOf + type ValueIonInstancePromise = PromiseOf + type ValueIonInstanceEvents = EventsOf + + type TSignalIonInstance = NodeOf + type TSignalIonTemplate = TemplateOf + type TValueIonInstance = NodeOf + type TValueIonTemplate = TemplateOf + + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeString() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<[p: string]>() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + Record + >() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toBeUndefined() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeUndefined() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< IonTemplateRecursive<{ - State: StoreIonState - Params: StoreIonParams - Exports: StoreIonExports - Store: StoreIonStore - Promise: StoreIonPromise + State: SignalIonState + Params: SignalIonParams + Exports: SignalIonExports + Events: SignalIonEvents + Promise: SignalIonPromise }> >() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf< IonTemplateRecursive<{ - State: StoreIonState - Params: StoreIonParams - Exports: StoreIonExports - Store: StoreIonStore - Promise: StoreIonPromise + State: SignalIonState + Params: SignalIonParams + Exports: SignalIonExports + Events: SignalIonEvents + Promise: SignalIonPromise }> >() }) test('atom api inference in ions', () => { - const storeIon = ion('store', (_, p: string) => { - const store = injectStore(p) + const signalIon = ion('signal', (_, p: string) => { + const signal = injectSignal(p, { + events: exampleEvents, + }) - return api(store) + return api(signal) .setExports({ toNum: (str: string) => Number(str) }) .setPromise(Promise.resolve('b')) }) @@ -396,58 +381,38 @@ describe('react types', () => { toNum: (str: string) => number } - expectTypeOf>().toBeString() - expectTypeOf>().toBeString() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toBeString() + expectTypeOf>().toBeString() + expectTypeOf>().toEqualTypeOf< PromiseState >() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< PromiseState >() - expectTypeOf>().toEqualTypeOf<[p: string]>() - expectTypeOf>().toEqualTypeOf<[p: string]>() - expectTypeOf>().toEqualTypeOf<[p: string]>() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf<[p: string]>() + expectTypeOf>().toEqualTypeOf< [p: string] >() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() expectTypeOf< - AtomExportsType - >().toEqualTypeOf() - expectTypeOf< - AtomExportsType - >().toEqualTypeOf() - expectTypeOf< - AtomExportsType - >().toEqualTypeOf() - expectTypeOf< - AtomExportsType + ExportsOf >().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf< - Store - >() - expectTypeOf>().toEqualTypeOf< - Store - >() - expectTypeOf>().toEqualTypeOf< - Store> - >() - expectTypeOf>().toEqualTypeOf< - Store> - >() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() - expectTypeOf>().toEqualTypeOf< - Promise - >() - expectTypeOf>().toEqualTypeOf< - Promise - >() - expectTypeOf>().toEqualTypeOf< - Promise - >() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf< Promise >() }) @@ -474,7 +439,7 @@ describe('react types', () => { Exports: Record Params: [] State: string - Store: Store + Events: None Promise: undefined }> >() @@ -490,7 +455,7 @@ describe('react types', () => { > ] State: string - Store: Store + Events: None Promise: undefined }> >() @@ -500,9 +465,9 @@ describe('react types', () => { const allOptionalParamsAtom = atom( 'allOptionalParams', (a?: boolean, b?: string[]) => { - const store = injectStore(a ? b : 2) + const signal = injectSignal(a ? b : 2) - return store + return signal } ) @@ -524,9 +489,7 @@ describe('react types', () => { >( template: A ) => - ecosystem.getInstance(template, [ - (idCounter++).toString(), - ] as AtomParamsType
) + ecosystem.getInstance(template, [(idCounter++).toString()] as ParamsOf) const key = getKey(exampleAtom) const instance = instantiateWithId(exampleAtom) @@ -552,7 +515,7 @@ describe('react types', () => { getBool: () => boolean getNum: () => number } - Store: Store + Events: ExampleEvents Promise: Promise }> >() @@ -561,7 +524,7 @@ describe('react types', () => { State: number | string[] | undefined Params: [a?: boolean | undefined, b?: string[] | undefined] Exports: Record - Store: Store + Events: None Promise: undefined }> >() @@ -579,19 +542,22 @@ describe('react types', () => { // @ts-expect-error optional params not allowed noParams(someOptionalParamsAtom) + expectTypeOf( + ecosystem.get(someOptionalParamsAtom, ['a']) + ).toMatchTypeOf() + expectTypeOf(noParams(atom('no-params-test', null))).toMatchTypeOf() }) test('accepting instances', () => { - const getExampleVal = >( - i: I - ) => i.getState() + const getExampleVal = >(i: I) => + i.get() const getKey = (instance: I) => instance.t.key const getUppercase = >( instance: I - ) => instance.getState().toUpperCase() + ) => instance.get().toUpperCase() const getNum = < I extends Omit, 'exports'> & { @@ -606,21 +572,16 @@ describe('react types', () => { const getValue: { // params ("family"): - ( - template: A, - params: AtomParamsType - ): AtomStateType + (template: A, params: ParamsOf): StateOf // no params ("singleton"): - ( - template: ParamlessTemplate - ): AtomStateType + (template: ParamlessTemplate): StateOf // also accept instances: - (instance: I): AtomStateType + (instance: I): StateOf } = ( template: A, - params?: AtomParamsType + params?: ParamsOf ) => ecosystem.get(template as AnyAtomTemplate, params) const instance = ecosystem.getInstance(exampleAtom, ['a']) @@ -654,7 +615,7 @@ describe('react types', () => { const val6 = injectCallback(() => true, []) const val7 = injectPromise(() => Promise.resolve(1), []) - return api(injectStore(instance.getState())).setExports({ + return api(injectSignal(instance.get())).setExports({ val1, val2, val3, @@ -667,8 +628,8 @@ describe('react types', () => { const instance = ecosystem.getInstance(injectingAtom) - expectTypeOf>().toBeString() - expectTypeOf>().toMatchTypeOf<{ + expectTypeOf>().toBeString() + expectTypeOf>().toMatchTypeOf<{ val1: string val2: string val3: string @@ -679,7 +640,7 @@ describe('react types', () => { Exports: Record Promise: Promise State: PromiseState - Store: Store> + Signal: Signal<{ Events: None; State: PromiseState }> }> }>() }) @@ -687,7 +648,7 @@ describe('react types', () => { test('AtomTuple', () => { const getPromise = }>>( ...[template, params]: AtomTuple - ) => ecosystem.getInstance(template, params).promise as AtomPromiseType + ) => ecosystem.getInstance(template, params).promise as PromiseOf const promise = getPromise(exampleAtom, ['a']) @@ -695,21 +656,28 @@ describe('react types', () => { }) test('AtomApi types helpers', () => { - const store = createStore(null, 'a') - const withEverything = api(store) + const signal = ecosystem.signal('a', { + events: { + eventA: () => 1, + eventB: () => 2, + }, + }) + + const withEverything = api(signal) .addExports({ a: 1 }) .setPromise(Promise.resolve(true)) - expectTypeOf>().toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ a: number }>() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< Promise >() - expectTypeOf>().toBeString() - expectTypeOf>().toEqualTypeOf< - Store - >() + expectTypeOf>().toBeString() + expectTypeOf>().toEqualTypeOf<{ + eventA: 1 + eventB: 2 + }>() }) test('promises', () => { @@ -719,9 +687,9 @@ describe('react types', () => { api().setPromise().setPromise(Promise.resolve(2)) ) - expectTypeOf>().toBeUndefined() - expectTypeOf>().toBeUndefined() - expectTypeOf>().resolves.toBeNumber() + expectTypeOf>().toBeUndefined() + expectTypeOf>().toBeUndefined() + expectTypeOf>().resolves.toBeNumber() }) test('recursive templates and nodes', () => { @@ -730,9 +698,7 @@ describe('react types', () => { const instanceC = instanceB.t._createInstance(ecosystem, '', ['b']) const instanceD = instanceC.t._createInstance(ecosystem, '', ['b']) - expectTypeOf>().toEqualTypeOf< - [p: string] - >() + expectTypeOf>().toEqualTypeOf<[p: string]>() const instanceE = ecosystem.getInstance( ecosystem.live.getInstance( @@ -740,9 +706,7 @@ describe('react types', () => { ) ) - expectTypeOf>().toEqualTypeOf< - [p: string] - >() + expectTypeOf>().toEqualTypeOf<[p: string]>() const selectorInstance = ecosystem.getNode( ecosystem.getNode( @@ -750,13 +714,13 @@ describe('react types', () => { ) ) - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< string | undefined >() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< [a?: string] >() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< (_: AtomGetters, a?: string) => string | undefined >() @@ -773,16 +737,103 @@ describe('react types', () => { ) ) - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< string | undefined >() - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< [a?: string] >() - expectTypeOf>().toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ resultsComparator: () => boolean selector: (_: AtomGetters, a?: string) => string | undefined argsComparator: () => boolean }>() }) + + test('signals', () => { + const signal = ecosystem.signal(1, { + events: { + a: As, + b: As, + }, + }) + + type TestListenableEvents = Partial<{ + a: number + b: undefined + batch: boolean + mutate: Transaction[] + change: { + newState: number + oldState: number + } + }> + + const calls: any[] = [] + + signal.on('a', (payload, eventMap) => { + expectTypeOf(payload).toEqualTypeOf() + expectTypeOf(eventMap).toEqualTypeOf() + calls.push(['a', payload, eventMap]) + }) + + signal.on('change', ({ newState, oldState }, eventMap) => { + expectTypeOf(newState).toEqualTypeOf() + expectTypeOf(oldState).toEqualTypeOf() + expectTypeOf(eventMap).toEqualTypeOf() + calls.push(['change', { newState, oldState }, eventMap]) + }) + + signal.on(eventMap => { + expectTypeOf(eventMap).toEqualTypeOf() + calls.push(['*', eventMap]) + }) + + snapshotNodes() + + signal.set(state => state + 1, { a: 11 }) + + expect(calls).toEqual([ + ['a', 11, { a: 11, change: { newState: 2, oldState: 1 } }], + [ + 'change', + { newState: 2, oldState: 1 }, + { a: 11, change: { newState: 2, oldState: 1 } }, + ], + ['*', { a: 11, change: { newState: 2, oldState: 1 } }], + ]) + calls.splice(0, 3) + + signal.send('a', 11) + + expect(calls).toEqual([ + ['a', 11, { a: 11 }], + ['*', { a: 11 }], + ]) + calls.splice(0, 2) + + signal.send('b') + + expect(calls).toEqual([['*', { b: undefined }]]) + calls.splice(0, 1) + + signal.send({ + a: 12, + b: undefined, + }) + + expect(calls).toEqual([ + ['a', 12, { a: 12, b: undefined }], + ['*', { a: 12, b: undefined }], + ]) + calls.splice(0, 2) + + type SignalEvents = EventsOf + type SignalState = StateOf + + expectTypeOf().toEqualTypeOf<{ a: number; b: undefined }>() + expectTypeOf().toEqualTypeOf() + + expect(ecosystem.get(signal)).toBe(2) + }) }) diff --git a/packages/react/test/units/Ecosystem.test.tsx b/packages/react/test/units/Ecosystem.test.tsx index 9bedcfbe..04ba0a9c 100644 --- a/packages/react/test/units/Ecosystem.test.tsx +++ b/packages/react/test/units/Ecosystem.test.tsx @@ -116,9 +116,9 @@ describe('Ecosystem', () => { ecosystem.getInstance(atom4) expect(ecosystem.find('someLongKey', [])).toBeUndefined() - expect(ecosystem.find(atom1, ['b'])?.getState()).toBe('b') + expect(ecosystem.find(atom1, ['b'])?.get()).toBe('b') expect(ecosystem.find(atom1, ['c'])).toBeUndefined() - expect(ecosystem.find(atom3, [])?.getState()).toBe(1) + expect(ecosystem.find(atom3, [])?.get()).toBe(1) }) test('findAll() with no params returns all atom instances', () => { diff --git a/packages/react/test/units/useAtomInstance.test.tsx b/packages/react/test/units/useAtomInstance.test.tsx index 144d5a0e..6556e444 100644 --- a/packages/react/test/units/useAtomInstance.test.tsx +++ b/packages/react/test/units/useAtomInstance.test.tsx @@ -12,7 +12,7 @@ describe('useAtomInstance', () => { function Test() { const instance = useAtomInstance(atom1, [], { subscribe: true }) - return
{instance.getState()}
+ return
{instance.get()}
} const { findByTestId } = renderInEcosystem() @@ -22,7 +22,7 @@ describe('useAtomInstance', () => { expect(div.innerHTML).toBe('a') act(() => { - ecosystem.getInstance(atom1).setState('b') + ecosystem.getInstance(atom1).set('b') jest.runAllTimers() }) @@ -36,7 +36,7 @@ describe('useAtomInstance', () => { function Test() { const instance = useAtomInstance(atom1, [], { subscribe: true }) - return
{instance.getState()}
+ return
{instance.get()}
} const { findByTestId } = renderInEcosystem() @@ -46,7 +46,7 @@ describe('useAtomInstance', () => { expect(div.innerHTML).toBe('a') act(() => { - ecosystem.getInstance(atom1).setState('b') + ecosystem.getInstance(atom1).set('b') jest.runAllTimers() }) @@ -68,13 +68,13 @@ describe('useAtomInstance', () => { function TestA() { const instance = useAtomInstance(atom1, [], { subscribe: true }) - return
{instance.getState()}
+ return
{instance.get()}
} function TestB() { const instance = useAtomInstance(atom1, [], { subscribe: true }) - return
{instance.getState()}
+ return
{instance.get()}
} function Parent() { @@ -88,14 +88,14 @@ describe('useAtomInstance', () => { expect((await findByTestId('text')).innerHTML).toBe('a') act(() => { - ecosystem.getInstance(toggleAtom).setState(false) + ecosystem.getInstance(toggleAtom).set(false) jest.runAllTimers() }) expect((await findByTestId('text')).innerHTML).toBe('a') act(() => { - ecosystem.getInstance(atom1).setState('b') + ecosystem.getInstance(atom1).set('b') jest.runAllTimers() }) diff --git a/packages/react/test/units/useAtomSelector.test.tsx b/packages/react/test/units/useAtomSelector.test.tsx index 8fc05ece..2224b0a5 100644 --- a/packages/react/test/units/useAtomSelector.test.tsx +++ b/packages/react/test/units/useAtomSelector.test.tsx @@ -482,7 +482,7 @@ describe('useAtomSelector', () => { snapshotNodes() act(() => { - ecosystem.getInstance(atom1).setState({ val: 2 }) + ecosystem.getInstance(atom1).set({ val: 2 }) jest.runAllTimers() }) @@ -518,7 +518,7 @@ describe('useAtomSelector', () => { snapshotNodes() act(() => { - ecosystem.getInstance(atom1).setState({ val: 2 }) + ecosystem.getInstance(atom1).set({ val: 2 }) jest.runAllTimers() }) @@ -554,21 +554,21 @@ describe('useAtomSelector', () => { expect(div.innerHTML).toBe('1') act(() => { - ecosystem.getInstance(atom1).setState({ val: 2 }) + ecosystem.getInstance(atom1).set({ val: 2 }) jest.runAllTimers() }) expect(div.innerHTML).toBe('2') act(() => { - ecosystem.getInstance(atom1).setState({ val: 3 }) + ecosystem.getInstance(atom1).set({ val: 3 }) jest.runAllTimers() }) expect(div.innerHTML).toBe('3') act(() => { - ecosystem.getInstance(atom1).setState({ val: 4 }) + ecosystem.getInstance(atom1).set({ val: 4 }) jest.runAllTimers() }) diff --git a/packages/react/test/utils/ecosystem.ts b/packages/react/test/utils/ecosystem.ts index cba4d3e7..8d6423e3 100644 --- a/packages/react/test/utils/ecosystem.ts +++ b/packages/react/test/utils/ecosystem.ts @@ -8,11 +8,9 @@ import { export const ecosystem = createEcosystem({ id: 'test' }) -let idCounter = 0 - -export const generateIdMock = jest.fn( - (prefix: string) => `${prefix}-${idCounter++}` -) +export const generateIdMock = jest.fn(function generateIdMock(prefix: string) { + return `${prefix}-${this.idCounter++}` +}) export const getEdges = (map: Map) => Object.fromEntries([...map].map(([node, edge]) => [node.id, edge])) @@ -51,7 +49,7 @@ ecosystem._idGenerator.now = nowMock afterAll(() => ecosystem.destroy()) afterEach(() => { - idCounter = 0 + ecosystem._idGenerator.idCounter = 0 act(() => { ecosystem.reset() diff --git a/packages/stores/README.md b/packages/stores/README.md new file mode 100644 index 00000000..1d4dc767 --- /dev/null +++ b/packages/stores/README.md @@ -0,0 +1,74 @@ +# `@zedux/stores` + +The composable store model of Zedux. This is an addon package with a dependency on `@zedux/atoms` and `@zedux/core`. It includes (via re-exporting) the Zedux core store package as well as all APIs related to working with stores in atoms. + +This package is framework-independent and can run in any JS runtime. It's considered a "legacy" package of Zedux, since Zedux's main packages have switched to a signals-based model + +If you're new to Zedux, you're probably looking for [the quick start](https://omnistac.github.io/zedux/docs/walkthrough/quick-start). You may also want to avoid this package, preferring the newer signal-based APIs in the `@zedux/react` or `@zedux/atoms` packages. + +## Installation + +```sh +npm install @zedux/stores # npm +yarn add @zedux/stores # yarn +pnpm add @zedux/stores # pnpm +``` + +If you're using React, you probably want to install the [`@zedux/react` package](https://www.npmjs.com/package/@zedux/react) alongside this package (and very likely want to skip this package altogether. Prefer signals). + +This package has a direct dependency on both the [`@zedux/core` package](https://www.npmjs.com/package/@zedux/core) and [`@zedux/atoms` package](https://www.npmjs.com/package/@zedux/atoms). If you install those directly, ensure their versions exactly match your `@zedux/stores` version to prevent installing duplicate packages. + +## Usage + +See the [top-level README](https://github.com/Omnistac/zedux) for a general overview of Zedux. + +See the [Zedux documentation](https://omnistac.github.io/zedux) for comprehensive usage details. + +Basic example: + +```tsx +import { atom, createEcosystem } from '@zedux/stores' + +const greetingAtom = atom('greeting', 'Hello, World!') +const ecosystem = createEcosystem({ id: 'root' }) + +const instance = ecosystem.getInstance(greetingAtom) + +instance.store.subscribe(newState => console.log('state updated:', newState)) +instance.setState('Goodbye, World!') +instance.destroy() +``` + +## Exports + +This package includes and re-exports everything from the following package: + +- [`@zedux/core`](https://www.npmjs.com/package/@zedux/core) + +On top of this, `@zedux/stores` exports the following APIs and many helper types for working with them in TypeScript. Note that most of these have newer, signals-based versions in the `@zedux/react` (or `@zedux/atoms`) package that you should prefer. + +### Classes + +- [`AtomApi`](https://omnistac.github.io/zedux/docs/api/classes/AtomApi) +- [`AtomInstance`](https://omnistac.github.io/zedux/docs/api/classes/AtomInstance) +- [`AtomTemplate`](https://omnistac.github.io/zedux/docs/api/classes/AtomTemplate) +- [`IonTemplate`](https://omnistac.github.io/zedux/docs/api/classes/IonTemplate) + +### Factories + +- [`api()`](https://omnistac.github.io/zedux/docs/api/factories/api) +- [`atom()`](https://omnistac.github.io/zedux/docs/api/factories/atom) +- [`ion()`](https://omnistac.github.io/zedux/docs/api/factories/ion) + +### Injectors + +- [`injectPromise()`](https://omnistac.github.io/zedux/docs/api/injectors/injectPromise) +- [`injectStore()`](https://omnistac.github.io/zedux/docs/api/injectors/injectStore) + +## For Authors + +If your lib only uses APIs in this package, it's recommended to only import this package, not `@zedux/react`. It's recommended to use a peer dependency + dev dependency on this package. + +## Contributing, License, Etc + +See the [top-level README](https://github.com/Omnistac/zedux) for all the technical stuff. diff --git a/packages/stores/package.json b/packages/stores/package.json new file mode 100644 index 00000000..2646a2e8 --- /dev/null +++ b/packages/stores/package.json @@ -0,0 +1,57 @@ +{ + "name": "@zedux/stores", + "version": "2.0.0-alpha.1", + "description": "The legacy composable store model of Zedux", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "author": "Joshua Claunch", + "bugs": { + "url": "https://github.com/Omnistac/zedux/issues" + }, + "dependencies": { + "@zedux/atoms": "^2.0.0-alpha.1", + "@zedux/core": "^2.0.0-alpha.1" + }, + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/cjs/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "homepage": "https://omnistac.github.io/zedux/", + "keywords": [ + "atom", + "atomic", + "composable", + "DI", + "easy", + "flexible", + "molecular", + "redux", + "simple", + "state", + "store", + "testable", + "zedux" + ], + "license": "MIT", + "repository": { + "directory": "packages/stores", + "type": "git", + "url": "https://github.com/Omnistac/zedux.git" + }, + "scripts": { + "build": "../../scripts/build.js", + "lint": "eslint 'src/**/*.@(tsx|ts)' && tsc --noEmit", + "prepublishOnly": "yarn build", + "test": "cd ../.. && yarn test packages/stores --collectCoverageFrom=packages/stores/src/**/*.ts" + }, + "sideEffects": false, + "type": "module" +} diff --git a/packages/stores/src/AtomApi.ts b/packages/stores/src/AtomApi.ts new file mode 100644 index 00000000..ed6e1b8f --- /dev/null +++ b/packages/stores/src/AtomApi.ts @@ -0,0 +1,82 @@ +import { AtomInstanceTtl, Prettify } from '@zedux/atoms' +import { is, Store } from '@zedux/core' +import { prefix } from './atoms-port' +import { AtomApiGenerics } from './types' + +export class AtomApi { + public static $$typeof = Symbol.for(`${prefix}/AtomApi`) + + public exports?: G['Exports'] + public promise: G['Promise'] + public store: G['Store'] + public signal: undefined + public ttl?: AtomInstanceTtl | (() => AtomInstanceTtl) + public value: G['State'] | G['Store'] + + constructor(value: AtomApi | G['Store'] | G['State']) { + this.promise = undefined as G['Promise'] + this.value = value as G['Store'] | G['State'] + this.store = (is(value, Store) ? value : undefined) as G['Store'] + + if (is(value, AtomApi)) { + Object.assign(this, value as AtomApi) + } + } + + public addExports>( + exports: NewExports + ): AtomApi< + Prettify< + Omit & { + Exports: (G['Exports'] extends Record + ? unknown + : G['Exports']) & + NewExports + } + > + > { + if (!this.exports) this.exports = exports as any + else this.exports = { ...this.exports, ...exports } + + return this as AtomApi< + Omit & { + Exports: (G['Exports'] extends Record + ? unknown + : G['Exports']) & + NewExports + } + > + } + + public setExports>( + exports: NewExports + ): AtomApi & { Exports: NewExports }>> { + ;( + this as unknown as AtomApi & { Exports: NewExports }> + ).exports = exports + + return this as unknown as AtomApi< + Omit & { Exports: NewExports } + > // for chaining + } + + public setPromise(): AtomApi & { Promise: undefined }> + + public setPromise

| undefined>( + promise: P + ): AtomApi & { Promise: P }> + + public setPromise

| undefined>( + promise?: P + ): AtomApi & { Promise: P }>> { + this.promise = promise as unknown as G['Promise'] + + return this as unknown as AtomApi & { Promise: P }> // for chaining + } + + public setTtl(ttl: AtomInstanceTtl | (() => AtomInstanceTtl)) { + this.ttl = ttl + + return this // for chaining + } +} diff --git a/packages/stores/src/AtomInstance.ts b/packages/stores/src/AtomInstance.ts new file mode 100644 index 00000000..77419a4e --- /dev/null +++ b/packages/stores/src/AtomInstance.ts @@ -0,0 +1,456 @@ +import { + ActionChain, + createStore, + Dispatchable, + zeduxTypes, + is, + RecursivePartial, + Settable, + Store, + Subscription, +} from '@zedux/core' +import { + AtomGenerics, + AtomGenericsToAtomApiGenerics, + AnyAtomGenerics, +} from './types' +import { + AtomInstance as NewAtomInstance, + Cleanup, + Ecosystem, + ExportsInfusedSetter, + PromiseState, + PromiseStatus, + InternalEvaluationReason, + ExplicitEvents, + Transaction, + ZeduxPlugin, + zi, +} from '@zedux/atoms' +import { AtomApi } from './AtomApi' +import { + Invalidate, + prefix, + PromiseChange, + getErrorPromiseState, + getInitialPromiseState, + getSuccessPromiseState, + InjectorDescriptor, +} from './atoms-port' +import { AtomTemplate } from './AtomTemplate' + +const StoreState = 1 +const RawState = 2 + +const getStateType = (val: any) => { + if (is(val, Store)) return StoreState + + return RawState +} + +const getStateStore = < + State = any, + StoreType extends Store = Store, + P extends State | StoreType = State | StoreType +>( + factoryResult: P +) => { + const stateType = getStateType(factoryResult) + + const stateStore = + stateType === StoreState + ? (factoryResult as unknown as StoreType) + : (createStore() as StoreType) + + // define how we populate our store (doesn't apply to user-supplied stores) + if (stateType === RawState) { + stateStore.setState( + typeof factoryResult === 'function' + ? () => factoryResult as State + : (factoryResult as unknown as State) + ) + } + + return [stateType, stateStore] as const +} + +export class AtomInstance< + G extends Omit & { + Template: AtomTemplate + } = AnyAtomGenerics<{ + Node: any + }> +> extends NewAtomInstance { + public static $$typeof = Symbol.for(`${prefix}/AtomInstance`) + + // @ts-expect-error same as exports + public store: G['Store'] + + /** + * @see NewAtomInstance.c + */ + public c?: Cleanup + public _createdAt: number + public _injectors?: InjectorDescriptor[] + public _isEvaluating?: boolean + public _nextInjectors?: InjectorDescriptor[] + public _promiseError?: Error + public _promiseStatus?: PromiseStatus + public _stateType?: typeof StoreState | typeof RawState + + private _bufferedUpdate?: { + newState: G['State'] + oldState?: G['State'] + action: ActionChain + } + private _subscription?: Subscription + + constructor( + /** + * @see NewAtomInstance.e + */ + public readonly e: Ecosystem, + /** + * @see NewAtomInstance.t + */ + public readonly t: G['Template'], + /** + * @see NewAtomInstance.id + */ + public readonly id: string, + /** + * @see NewAtomInstance.p + */ + public readonly p: G['Params'] + ) { + super(e, t, id, p) + this._createdAt = e._idGenerator.now() + } + + /** + * @see NewAtomInstance.destroy + */ + public destroy(force?: boolean) { + if (!zi.b(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 + } + injector.cleanup?.() + }) + nonEffectInjectors.forEach(injector => { + injector.cleanup?.() + }) + this._subscription?.unsubscribe() + + zi.e(this) + } + + /** + * An alias for `.store.dispatch()` + */ + public dispatch = (action: Dispatchable) => this.store.dispatch(action) + + /** + * An alias for `instance.store.getState()`. Returns the current state of this + * atom instance's store. + * + * @deprecated - use `.get()` instead @see AtomInstance.get + */ + public getState(): G['State'] { + return this.store.getState() + } + + /** + * @see NewAtomInstance.get + * + * An alias for `instance.store.getState()`. + */ + public get() { + return this.store.getState() + } + + /** + * Force this atom instance to reevaluate. + */ + public invalidate() { + this.r({ t: Invalidate }, false) + + // run the scheduler synchronously after invalidation + this.e._scheduler.flush() + } + + /** + * `.mutate()` is not supported in legacy, store-based atoms. Upgrade to the + * new `atom()` factory. + */ + public mutate(): [G['State'], Transaction[]] { + throw new Error( + '`.mutate()` is not supported in legacy, store-based atoms. Upgrade to the new `atom()` factory' + ) + } + + public set( + settable: Settable, + events?: Partial + ) { + return this.setState(settable, events && Object.keys(events)[0]) + } + + /** + * An alias for `.store.setState()` + */ + public setState = (settable: Settable, meta?: any): G['State'] => + this.store.setState(settable, meta) + + /** + * An alias for `.store.setStateDeep()` + */ + public setStateDeep = ( + settable: Settable, G['State']>, + meta?: any + ): G['State'] => this.store.setStateDeep(settable, meta) + + /** + * @see NewAtomInstance.j + */ + public j() { + const { n, s } = zi.g() + this._nextInjectors = [] + this._isEvaluating = true + + // all stores created during evaluation automatically belong to the + // ecosystem. This is brittle. It's the only piece of Zedux that isn't + // cross-window compatible. The store package would ideally have its own + // scheduler. Unfortunately, we're probably never focusing on that since the + // real ideal is to move off stores completely in favor of signals. + Store._scheduler = this.e._scheduler + + zi.s(this) + + try { + const newFactoryResult = this._eval() + + if (this.l === 'Initializing') { + ;[this._stateType, this.store] = getStateStore(newFactoryResult) + + this._subscription = this.store.subscribe( + (newState, oldState, action) => { + // buffer updates (with cache size of 1) if this instance is currently + // evaluating + if (this._isEvaluating) { + this._bufferedUpdate = { newState, oldState, action } + return + } + + this._handleStateChange(newState, oldState, action) + } + ) + } else { + const newStateType = getStateType(newFactoryResult) + + if (DEV && newStateType !== this._stateType) { + throw new Error( + `Zedux: atom factory for atom "${this.t.key}" returned a different type than the previous evaluation. This can happen if the atom returned a store initially but then returned a non-store value on a later evaluation or vice versa` + ) + } + + if ( + DEV && + newStateType === StoreState && + newFactoryResult !== this.store + ) { + throw new Error( + `Zedux: atom factory for atom "${this.t.key}" returned a different store. Did you mean to use \`injectStore()\`, or \`injectMemo()\`?` + ) + } + + // there is no way to cause an evaluation loop when the StateType is Value + if (newStateType === RawState) { + this.store.setState( + typeof newFactoryResult === 'function' + ? () => newFactoryResult as G['State'] + : (newFactoryResult as G['State']) + ) + } + } + } catch (err) { + this._nextInjectors.forEach(injector => { + injector.cleanup?.() + }) + + zi.d(n, s) + + throw err + } finally { + this._isEvaluating = false + + // if we just popped the last thing off the stack, restore the default + // scheduler + if (!n) Store._scheduler = undefined + + // even if evaluation errored, we need to update dependents if the store's + // state changed + if (this._bufferedUpdate) { + this._handleStateChange( + this._bufferedUpdate.newState, + this._bufferedUpdate.oldState, + this._bufferedUpdate.action + ) + this._bufferedUpdate = undefined + } + + this.w = [] + } + + this._injectors = this._nextInjectors + + if (this.l !== 'Initializing') { + // let this.i flush updates after status is set to Active + zi.f(n, s) + } + } + + /** + * @see NewAtomInstance.r + */ + public r(reason: InternalEvaluationReason, shouldSetTimeout?: boolean) { + // TODO: Any calls in this case probably indicate a memory leak on the + // user's part. Notify them. TODO: Can we pause evaluations while + // status is Stale (and should we just always evaluate once when + // waking up a stale atom)? + if (this.l !== 'Destroyed' && this.w.push(reason) === 1) { + // refCount just hit 1; we haven't scheduled a job for this node yet + this.e._scheduler.schedule(this, shouldSetTimeout) + } + } + + public _set?: ExportsInfusedSetter + public get _infusedSetter() { + if (this._set) return this._set + const setState: any = (settable: any, meta?: any) => + this.setState(settable, meta) + + return (this._set = Object.assign(setState, this.exports)) + } + + /** + * A standard atom's value can be one of: + * + * - A raw value + * - A Zedux store + * - A function that returns a raw value + * - A function that returns a Zedux store + * - A function that returns an AtomApi + */ + private _eval(): G['Store'] | G['State'] { + const { _value } = this.t + + if (typeof _value !== 'function') { + return _value + } + + try { + const val = ( + _value as ( + ...params: G['Params'] + ) => G['Store'] | G['State'] | AtomApi> + )(...this.p) + + if (!is(val, AtomApi)) return val as G['Store'] | G['State'] + + const api = (this.api = val as AtomApi>) + + // Exports can only be set on initial evaluation + if (this.l === 'Initializing' && api.exports) { + this.exports = api.exports + } + + // if api.value is a promise, we ignore api.promise + if (typeof (api.value as unknown as Promise)?.then === 'function') { + return this._setPromise(api.value as unknown as Promise, true) + } else if (api.promise) { + this._setPromise(api.promise) + } + + return api.value as G['Store'] | G['State'] + } catch (err) { + console.error( + `Zedux: Error while evaluating atom "${this.t.key}" with params:`, + this.p, + err + ) + + throw err + } + } + + private _handleStateChange( + newState: G['State'], + oldState: G['State'] | undefined, + action: ActionChain + ) { + zi.u({ p: oldState, r: this.w, s: this }, false) + + if (this.e._mods.stateChanged) { + this.e.modBus.dispatch( + ZeduxPlugin.actions.stateChanged({ + action, + node: this, + newState, + oldState, + reasons: this.w, + }) + ) + } + + // run the scheduler synchronously after any atom instance state update + if (action.meta !== zeduxTypes.batch) { + this.e._scheduler.flush() + } + } + + private _setPromise(promise: Promise, isStateUpdater?: boolean) { + const currentState = this.store?.getState() + if (promise === this.promise) return currentState + + this.promise = promise as G['Promise'] + + // since we're the first to chain off the returned promise, we don't need to + // track the chained promise - it will run first, before React suspense's + // `.then` on the thrown promise, for example + promise + .then(data => { + if (this.promise !== promise) return + + this._promiseStatus = 'success' + if (!isStateUpdater) return + + this.store.setState( + getSuccessPromiseState(data) as unknown as G['State'] + ) + }) + .catch(error => { + if (this.promise !== promise) return + + this._promiseStatus = 'error' + this._promiseError = error + if (!isStateUpdater) return + + this.store.setState( + getErrorPromiseState(error) as unknown as G['State'] + ) + }) + + const state: PromiseState = getInitialPromiseState(currentState?.data) + this._promiseStatus = state.status + + zi.u({ s: this, t: PromiseChange }, true, true) + + return state as unknown as G['State'] + } +} diff --git a/packages/stores/src/AtomTemplate.ts b/packages/stores/src/AtomTemplate.ts new file mode 100644 index 00000000..a5fac977 --- /dev/null +++ b/packages/stores/src/AtomTemplate.ts @@ -0,0 +1,59 @@ +import { AtomTemplateBase, Ecosystem } from '@zedux/atoms' +import { atom } from './atom' +import { AtomInstance } from './AtomInstance' +import { AnyAtomGenerics, AtomGenerics, AtomValueOrFactory } from './types' + +export type AtomInstanceRecursive< + G extends Omit +> = AtomInstance< + G & { + Node: AtomInstanceRecursive + Template: AtomTemplateRecursive + } +> + +export type AtomTemplateRecursive< + G extends Omit +> = AtomTemplate< + G & { + Node: AtomInstanceRecursive + Template: AtomTemplateRecursive + } +> + +export class AtomTemplate< + G extends AtomGenerics & { + Node: AtomInstanceRecursive + Template: AtomTemplateRecursive + } = AnyAtomGenerics +> extends AtomTemplateBase { + /** + * This method should be overridden when creating custom atom classes that + * create a custom atom instance class. Return a new instance of your atom + * instance class. + */ + public _createInstance( + ecosystem: Ecosystem, + id: string, + params: G['Params'] + ): G['Node'] { + return new AtomInstance(ecosystem, this, id, params) + } + + public getInstanceId(ecosystem: Ecosystem, params?: G['Params']) { + const base = this.key + + if (!params?.length) return base + + return `${base}-${ecosystem._idGenerator.hashParams( + params, + ecosystem.complexParams + )}` + } + + public override(newValue: AtomValueOrFactory): AtomTemplate { + const newAtom = atom(this.key, newValue, this._config) + newAtom._isOverride = true + return newAtom as any + } +} diff --git a/packages/stores/src/IonTemplate.ts b/packages/stores/src/IonTemplate.ts new file mode 100644 index 00000000..20a1fe10 --- /dev/null +++ b/packages/stores/src/IonTemplate.ts @@ -0,0 +1,52 @@ +import { AtomConfig, injectAtomGetters } from '@zedux/atoms' +import { AtomInstance } from './AtomInstance' +import { AtomTemplate } from './AtomTemplate' +import { ion } from './ion' +import { AnyAtomGenerics, AtomGenerics, IonStateFactory } from './types' + +export type IonInstanceRecursive< + G extends Omit +> = AtomInstance< + G & { + Node: IonInstanceRecursive + Template: IonTemplateRecursive + } +> + +export type IonTemplateRecursive< + G extends Omit +> = IonTemplate< + G & { + Node: IonInstanceRecursive + Template: IonTemplateRecursive + } +> + +export class IonTemplate< + G extends AtomGenerics & { + Node: IonInstanceRecursive + Template: IonTemplateRecursive + } = AnyAtomGenerics +> extends AtomTemplate { + private _get: IonStateFactory + + constructor( + key: string, + stateFactory: IonStateFactory>, + _config?: AtomConfig + ) { + super( + key, + (...params: G['Params']) => stateFactory(injectAtomGetters(), ...params), + _config + ) + + this._get = stateFactory + } + + public override(newGet?: IonStateFactory): IonTemplate { + const newIon = ion(this.key, newGet || this._get, this._config) + newIon._isOverride = true + return newIon + } +} diff --git a/packages/stores/src/api.ts b/packages/stores/src/api.ts new file mode 100644 index 00000000..50856081 --- /dev/null +++ b/packages/stores/src/api.ts @@ -0,0 +1,141 @@ +import { AtomApiPromise } from '@zedux/atoms' +import { Store, StoreStateType } from '@zedux/core' +import { AtomApi } from './AtomApi' + +/** + * Create an AtomApi + * + * AtomApis are the standard mechanism for passing stores, exports, and promises + * around. + * + * An AtomApi that's returned from an atom state factory becomes _the_ api of + * the atom. + * + * - Any exports on the AtomApi are set as the atom instance's exports on + * initial evaluation and ignored forever after. + * - If promise or state references change on subsequent evaluations, it + * triggers the appropriate updates in all the atom's dynamic dependents. + */ +export const api: { + // Custom Stores (AtomApi cloning) + < + StoreType extends Store = Store, + Exports extends Record = Record, + PromiseType extends AtomApiPromise = undefined + >( + value: AtomApi<{ + Exports: Exports + Promise: PromiseType + State: StoreStateType + Store: StoreType + }> + ): AtomApi<{ + Exports: Exports + Promise: PromiseType + State: StoreStateType + Store: StoreType + }> + + // Custom Stores (normal) + = Store>(value: StoreType): AtomApi<{ + Exports: Record + Promise: undefined + State: StoreStateType + Store: StoreType + }> + + // No Value + (): AtomApi<{ + Exports: Record + Promise: undefined + State: undefined + Store: undefined + }> + + < + State = undefined, + Exports extends Record = Record, + PromiseType extends AtomApiPromise = undefined + >(): AtomApi<{ + Exports: Exports + Promise: PromiseType + State: State + Store: undefined + }> + + // No Store (AtomApi cloning) + < + State = undefined, + Exports extends Record = Record, + PromiseType extends AtomApiPromise = undefined + >( + value: AtomApi<{ + Exports: Exports + Promise: PromiseType + State: State + Store: undefined + }> + ): AtomApi<{ + Exports: Exports + Promise: PromiseType + State: State + Store: undefined + }> + + // No Store (normal) + (value: State): AtomApi<{ + Exports: Record + Promise: undefined + State: State + Store: undefined + }> + + // Catch-all + < + State = undefined, + Exports extends Record = Record, + StoreType extends Store = Store, + PromiseType extends AtomApiPromise = undefined + >( + value: + | State + | StoreType + | AtomApi<{ + Exports: Exports + Promise: PromiseType + State: State + Store: StoreType + }> + ): AtomApi<{ + Exports: Exports + Promise: PromiseType + State: State + Store: StoreType + }> +} = < + State = undefined, + Exports extends Record = Record, + StoreType extends Store | undefined = undefined, + PromiseType extends AtomApiPromise = undefined +>( + value?: + | AtomApi<{ + Exports: Exports + Promise: PromiseType + State: State + Store: StoreType + }> + | StoreType + | State +) => + new AtomApi( + value as + | AtomApi<{ + Exports: Exports + Promise: PromiseType + State: State + Store: StoreType + }> + | StoreType + | State + ) diff --git a/packages/stores/src/atom.ts b/packages/stores/src/atom.ts new file mode 100644 index 00000000..0d50bc8a --- /dev/null +++ b/packages/stores/src/atom.ts @@ -0,0 +1,106 @@ +import { AtomConfig, AtomApiPromise, PromiseState } from '@zedux/atoms' +import { Store, StoreStateType } from '@zedux/core' +import { AtomApi } from './AtomApi' +import { AtomTemplate, AtomTemplateRecursive } from './AtomTemplate' +import { AtomValueOrFactory } from './types' + +export const atom: { + // Query Atoms + < + State = any, + Params extends any[] = [], + Exports extends Record = Record + >( + key: string, + value: (...params: Params) => AtomApi<{ + Exports: Exports + Promise: any + State: Promise + Store: undefined + }>, + config?: AtomConfig + ): AtomTemplateRecursive<{ + State: PromiseState + Params: Params + Events: Record + Exports: Exports + Store: Store> + Promise: Promise + }> + + // Custom Stores + < + StoreType extends Store = Store, + Params extends any[] = [], + Exports extends Record = Record, + PromiseType extends AtomApiPromise = undefined + >( + key: string, + value: (...params: Params) => + | StoreType + | AtomApi<{ + Exports: Exports + Promise: PromiseType + State: StoreStateType + Store: StoreType + }>, + config?: AtomConfig> + ): AtomTemplateRecursive<{ + State: StoreStateType + Params: Params + Events: Record + Exports: Exports + Store: StoreType + Promise: PromiseType + }> + + // Catch-all + < + State = any, + Params extends any[] = [], + Exports extends Record = Record, + StoreType extends Store = Store, + PromiseType extends AtomApiPromise = undefined + >( + key: string, + value: AtomValueOrFactory<{ + Events: Record + Exports: Exports + Params: Params + Promise: PromiseType + State: State + Store: StoreType + }>, + config?: AtomConfig + ): AtomTemplateRecursive<{ + Events: Record + Exports: Exports + Params: Params + Promise: PromiseType + State: State + Store: StoreType + }> +} = < + State = any, + Params extends any[] = [], + Exports extends Record = Record, + StoreType extends Store = Store, + PromiseType extends AtomApiPromise = undefined +>( + key: string, + value: AtomValueOrFactory<{ + Events: Record + Exports: Exports + Params: Params + Promise: PromiseType + State: State + Store: StoreType + }>, + config?: AtomConfig +) => { + if (DEV && !key) { + throw new TypeError('Zedux: All atoms must have a key') + } + + return new AtomTemplate(key, value, config) as any +} diff --git a/packages/stores/src/atoms-port.ts b/packages/stores/src/atoms-port.ts new file mode 100644 index 00000000..2e22c0bb --- /dev/null +++ b/packages/stores/src/atoms-port.ts @@ -0,0 +1,52 @@ +// This file contains lots of code and types duplicated from the `@zedux/atoms` +// package. This is the best way to get them in the `tsc` build without breaking +// type compatibility with the external `@zedux/atoms` package. That breakage +// happens when we give TS a `@zedux/atoms` `paths` alias and let it pull in +// duplicated classes e.g. in `dist/esm/atoms/classes/...` +import { PromiseState } from '@zedux/atoms' + +export type InjectorDescriptor = T extends undefined + ? { + cleanup?: () => void + result?: T + type: string + } + : { + cleanup?: () => void + result: T + type: string + } + +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 PromiseChange = 3 +export const EventSent = 4 + +export const getErrorPromiseState = (error: Error): PromiseState => ({ + error, + isError: true, + isLoading: false, + isSuccess: false, + status: 'error', +}) + +export const getInitialPromiseState = (data?: T): PromiseState => ({ + data, + isError: false, + isLoading: true, + isSuccess: false, + status: 'loading' as const, +}) + +export const getSuccessPromiseState = (data: T): PromiseState => ({ + data, + isError: false, + isLoading: false, + isSuccess: true, + status: 'success', +}) diff --git a/packages/stores/src/index.ts b/packages/stores/src/index.ts new file mode 100644 index 00000000..9b42f070 --- /dev/null +++ b/packages/stores/src/index.ts @@ -0,0 +1,11 @@ +export * from '@zedux/core' +export * from './api' +export * from './atom' +export * from './AtomApi' +export * from './AtomInstance' +export * from './AtomTemplate' +export * from './injectPromise' +export * from './injectStore' +export * from './ion' +export * from './IonTemplate' +export * from './types' diff --git a/packages/stores/src/injectPromise.ts b/packages/stores/src/injectPromise.ts new file mode 100644 index 00000000..04570896 --- /dev/null +++ b/packages/stores/src/injectPromise.ts @@ -0,0 +1,174 @@ +import { + injectEffect, + injectMemo, + InjectorDeps, + InjectPromiseConfig, + injectRef, + InjectStoreConfig, + injectWhy, + PromiseState, +} from '@zedux/atoms' +import { detailedTypeof, RecursivePartial, Store } from '@zedux/core' +import { + getErrorPromiseState, + getInitialPromiseState, + getSuccessPromiseState, +} from './atoms-port' +import { AtomApi } from './AtomApi' +import { api } from './api' +import { injectStore } from './injectStore' + +/** + * Create a memoized promise reference. Kicks off the promise immediately + * (unlike injectEffect which waits a tick). Creates a store to track promise + * state. This store's state shape is based off React Query: + * + * ```ts + * { + * data?: + * error?: Error + * isError: boolean + * isLoading: boolean + * isSuccess: boolean + * status: 'error' | 'loading' | 'success' + * } + * ``` + * + * Returns an Atom API with `.store` and `.promise` set. + * + * The 2nd `deps` param is just like `injectMemo` - these deps determine when + * the promise's reference should change. + * + * The 3rd `config` param can take the following options: + * + * - `dataOnly`: Set this to true to prevent the store from tracking promise + * status and make your promise's `data` the entire state. + * + * - `initialState`: Set the initial state of the store (e.g. a placeholder + * value before the promise resolves) + * + * - store config: Any other config options will be passed directly to + * `injectStore`'s config. For example, pass `subscribe: false` to + * prevent the store from reevaluating the current atom on update. + * + * ```ts + * const promiseApi = injectPromise(async () => { + * const response = await fetch(url) + * return await response.json() + * }, [url], { + * dataOnly: true, + * initialState: '', + * subscribe: false + * }) + * ``` + */ +export const injectPromise: { + ( + promiseFactory: (controller?: AbortController) => Promise, + deps: InjectorDeps, + config: Omit & { + dataOnly: true + } & InjectStoreConfig + ): AtomApi<{ + Exports: Record + Promise: Promise + State: T + Store: Store + }> + + ( + promiseFactory: (controller?: AbortController) => Promise, + deps?: InjectorDeps, + config?: InjectPromiseConfig & InjectStoreConfig + ): AtomApi<{ + Exports: Record + Promise: Promise + State: PromiseState + Store: Store> + }> +} = ( + promiseFactory: (controller?: AbortController) => Promise, + deps?: InjectorDeps, + { + dataOnly, + initialState, + runOnInvalidate, + ...storeConfig + }: InjectPromiseConfig & InjectStoreConfig = {} +) => { + const refs = injectRef({ counter: 0 } as { + controller?: AbortController + counter: number + promise: Promise + }) + + const store = injectStore( + dataOnly ? initialState : getInitialPromiseState(initialState), + storeConfig + ) + + if ( + runOnInvalidate && + // injectWhy is an unrestricted injector - using it conditionally is fine: + injectWhy().some(reason => reason.type === 'cache invalidated') + ) { + refs.current.counter++ + } + + // setting a ref during evaluation is perfectly fine in Zedux + refs.current.promise = injectMemo(() => { + const prevController = refs.current.controller + const nextController = + typeof AbortController !== 'undefined' ? new AbortController() : undefined + + refs.current.controller = nextController + const promise = promiseFactory(refs.current.controller) + + if (DEV && typeof promise?.then !== 'function') { + throw new TypeError( + `Zedux: injectPromise expected callback to return a promise. Received ${detailedTypeof( + promise + )}` + ) + } + + if (promise === refs.current.promise) return refs.current.promise + + if (prevController) (prevController as any).abort('updated') + + if (!dataOnly) { + // preserve previous data and error using setStateDeep: + store.setStateDeep( + state => + getInitialPromiseState( + (state as PromiseState).data + ) as RecursivePartial> + ) + } + + promise + .then(data => { + if (nextController?.signal.aborted) return + + store.setState(dataOnly ? data : getSuccessPromiseState(data)) + }) + .catch(error => { + if (dataOnly || nextController?.signal.aborted) return + + // preserve previous data using setStateDeep: + store.setStateDeep(getErrorPromiseState(error)) + }) + + return promise + }, deps && [...deps, refs.current.counter]) + + injectEffect( + () => () => { + const controller = refs.current.controller + if (controller) (controller as any).abort('destroyed') + }, + [] + ) + + return api(store).setPromise(refs.current.promise) +} diff --git a/packages/atoms/src/injectors/injectStore.ts b/packages/stores/src/injectStore.ts similarity index 94% rename from packages/atoms/src/injectors/injectStore.ts rename to packages/stores/src/injectStore.ts index c6e95c42..d7414634 100644 --- a/packages/atoms/src/injectors/injectStore.ts +++ b/packages/stores/src/injectStore.ts @@ -1,8 +1,6 @@ import { createStore, zeduxTypes, Store } from '@zedux/core' -import { createInjector } from '../factories/createInjector' -import { InjectStoreConfig, PartialAtomInstance } from '../types/index' -import { prefix } from '../utils/general' -import type { InjectorDescriptor } from '../utils/types' +import { InjectStoreConfig, PartialAtomInstance, zi } from '@zedux/atoms' +import { InjectorDescriptor, prefix } from './atoms-port' export const doSubscribe = ( instance: PartialAtomInstance, @@ -88,7 +86,7 @@ export const injectStore: { config?: InjectStoreConfig ): Store (): Store -} = createInjector( +} = zi.c( 'injectStore', ( instance: PartialAtomInstance, diff --git a/packages/stores/src/ion.ts b/packages/stores/src/ion.ts new file mode 100644 index 00000000..1c135fa6 --- /dev/null +++ b/packages/stores/src/ion.ts @@ -0,0 +1,135 @@ +import { AtomConfig, AtomGetters, PromiseState } from '@zedux/atoms' +import { Store, StoreStateType } from '@zedux/core' +import { AtomApi } from './AtomApi' +import { IonTemplate, IonTemplateRecursive } from './IonTemplate' +import { AtomApiPromise, IonStateFactory } from './types' + +export const ion: { + // Query Atoms + < + State = any, + Params extends any[] = [], + Exports extends Record = Record + >( + key: string, + value: ( + getters: AtomGetters, + ...params: Params + ) => AtomApi<{ + Exports: Exports + Promise: any + State: Promise + Store: undefined + }>, + config?: AtomConfig + ): IonTemplateRecursive<{ + State: PromiseState + Params: Params + Events: Record + Exports: Exports + Store: Store> + Promise: Promise + }> + + // Custom Stores + < + StoreType extends Store = Store, + Params extends any[] = [], + Exports extends Record = Record, + PromiseType extends AtomApiPromise = undefined + >( + key: string, + get: ( + getters: AtomGetters, + ...params: Params + ) => + | StoreType + | AtomApi<{ + Exports: Exports + Promise: PromiseType + State: StoreStateType + Store: StoreType + }>, + config?: AtomConfig> + ): IonTemplateRecursive<{ + State: StoreStateType + Params: Params + Events: Record + Exports: Exports + Store: StoreType + Promise: PromiseType + }> + + // No Store + < + State = any, + Params extends any[] = [], + Exports extends Record = Record, + PromiseType extends AtomApiPromise = undefined + >( + key: string, + get: ( + getters: AtomGetters, + ...params: Params + ) => + | AtomApi<{ + Exports: Exports + Promise: PromiseType + State: State + Store: undefined + }> + | State, + config?: AtomConfig + ): IonTemplateRecursive<{ + State: State + Params: Params + Events: Record + Exports: Exports + Store: Store + Promise: PromiseType + }> + + // Catch-all + < + State = any, + Params extends any[] = [], + Exports extends Record = Record, + StoreType extends Store = Store, + PromiseType extends AtomApiPromise = undefined + >( + key: string, + get: IonStateFactory<{ + State: State + Params: Params + Events: Record + Exports: Exports + Store: StoreType + Promise: PromiseType + }>, + config?: AtomConfig + ): IonTemplateRecursive<{ + State: State + Params: Params + Events: Record + Exports: Exports + Store: StoreType + Promise: PromiseType + }> +} = < + State = any, + Params extends any[] = [], + Exports extends Record = Record, + StoreType extends Store = Store, + PromiseType extends AtomApiPromise = undefined +>( + key: string, + get: IonStateFactory<{ + State: State + Params: Params + Events: Record + Exports: Exports + Store: StoreType + Promise: PromiseType + }>, + config?: AtomConfig +) => new IonTemplate(key, get, config) as any diff --git a/packages/stores/src/types.ts b/packages/stores/src/types.ts new file mode 100644 index 00000000..4b815ca3 --- /dev/null +++ b/packages/stores/src/types.ts @@ -0,0 +1,181 @@ +import { + AnyNonNullishValue, + AtomGenerics as NewAtomGenerics, + AtomGetters, + AtomSelectorOrConfig, + AtomTemplateBase, + GraphNode, + Prettify, +} from '@zedux/atoms' +import { Store } from '@zedux/core' +import { AtomInstance } from './AtomInstance' +import { AtomApi } from './AtomApi' + +export type AnyAtomApiGenerics = { [K in keyof AtomGenerics]: any } + +export type AnyAtomGenerics< + G extends Partial = AnyNonNullishValue +> = Prettify & G> + +export type AnyAtomApi | 'any' = 'any'> = + AtomApi ? AtomApiGenericsPartial : any> + +export type AnyStoreAtomInstance< + G extends Partial | 'any' = 'any' +> = AtomInstance< + G extends Partial + ? { Template: AnyStoreAtomTemplate } & AnyAtomGenerics + : any +> + +export type AnyStoreAtomTemplate< + G extends Partial | 'any' = 'any' +> = AtomTemplateBase< + G extends Partial + ? { Node: AnyStoreAtomInstance } & AnyAtomGenerics + : any +> + +export type AtomApiGenerics = Pick< + AtomGenerics, + 'Exports' | 'Promise' | 'State' +> & { + Store: Store | undefined +} + +export type AtomApiGenericsPartial> = Omit< + AnyAtomApiGenerics, + keyof G +> & + G + +export type AtomGenericsToAtomApiGenerics< + G extends Pick< + AtomGenerics, + 'Events' | 'Exports' | 'Promise' | 'State' | 'Store' + > +> = Pick & { + Store: G['Store'] | undefined +} + +export interface AtomGenerics extends NewAtomGenerics { + Store: Store +} + +export type AtomApiPromise = Promise | undefined + +export type AtomEventsType< + A extends AnyAtomApi | AnyStoreAtomTemplate | GraphNode +> = A extends AtomTemplateBase + ? G['Events'] + : A extends GraphNode + ? G extends { Events: infer Events } + ? Events + : never + : never + +export type AtomExportsType< + A extends AnyAtomApi | AnyStoreAtomTemplate | GraphNode +> = A extends AtomTemplateBase + ? G['Exports'] + : A extends GraphNode + ? G extends { Exports: infer Exports } + ? Exports + : never + : A extends AtomApi + ? G['Exports'] + : never + +export type AtomInstanceType = + A extends AtomTemplateBase + ? G extends { Node: infer Node } + ? Node + : GraphNode + : never + +export type AtomParamsType< + A extends AnyStoreAtomTemplate | GraphNode | AtomSelectorOrConfig +> = A extends AtomTemplateBase + ? G['Params'] + : A extends GraphNode + ? G extends { Params: infer Params } + ? Params + : never + : A extends AtomSelectorOrConfig + ? Params + : never + +export type AtomPromiseType< + A extends AnyAtomApi | AnyStoreAtomTemplate | GraphNode +> = A extends AtomTemplateBase + ? G['Promise'] + : A extends GraphNode + ? G extends { Promise: infer Promise } + ? Promise + : never + : A extends AtomApi + ? G['Promise'] + : never + +export type AtomStateFactory< + G extends Pick< + AtomGenerics, + 'Events' | 'Exports' | 'Params' | 'Promise' | 'State' | 'Store' + > +> = ( + ...params: G['Params'] +) => AtomApi> | G['Store'] | G['State'] + +export type AtomValueOrFactory< + G extends Pick< + AtomGenerics, + 'Events' | 'Exports' | 'Params' | 'Promise' | 'State' | 'Store' + > +> = AtomStateFactory | G['Store'] | G['State'] + +export type AtomStateType< + A extends AnyAtomApi | AnyStoreAtomTemplate | AtomSelectorOrConfig | GraphNode +> = A extends AtomTemplateBase + ? G['State'] + : A extends GraphNode + ? G['State'] + : A extends AtomApi + ? G['State'] + : A extends AtomSelectorOrConfig + ? State + : never + +export type AtomStoreType< + A extends AnyAtomApi | AnyStoreAtomTemplate | GraphNode +> = A extends AtomTemplateBase + ? G extends { Store: infer Store } + ? Store + : never + : A extends GraphNode + ? G extends { Store: infer Store } + ? Store + : never + : A extends AtomApi + ? G['Store'] + : never + +// TODO: Now that GraphNode has the Template generic, this G extends { Template +// ... } check shouldn't be necessary. Double check and remove. +export type AtomTemplateType = A extends GraphNode + ? G extends { Template: infer Template } + ? Template + : G extends AtomGenerics + ? AtomTemplateBase + : never + : never + +export type IonStateFactory> = + ( + getters: AtomGetters, + ...params: G['Params'] + ) => AtomApi> | G['Store'] | G['State'] + +export type SelectorGenerics = Pick & { + Params: any[] + Template: AtomSelectorOrConfig +} diff --git a/packages/stores/tsconfig.build.json b/packages/stores/tsconfig.build.json new file mode 100644 index 00000000..0ae61576 --- /dev/null +++ b/packages/stores/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "lib": ["DOM", "ESNext"], + "outDir": "./dist/esm", + "paths": {} + }, + "include": ["./src", "../../global.d.ts"] +} diff --git a/packages/stores/tsconfig.json b/packages/stores/tsconfig.json new file mode 100644 index 00000000..0969f9fe --- /dev/null +++ b/packages/stores/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["DOM", "ESNext"] + }, + "include": ["./src", "./test", "../../global.d.ts"] +} diff --git a/packages/stores/vite.config.ts b/packages/stores/vite.config.ts new file mode 100644 index 00000000..d579060c --- /dev/null +++ b/packages/stores/vite.config.ts @@ -0,0 +1,18 @@ +import { resolve } from 'path' +import { configureVite } from '../vite-base-config' + +export default configureVite({ + getConfig: () => ({ + resolve: { + alias: { + '@zedux/atoms-bundled': resolve(__dirname, '../atoms/dist/esm'), + '@zedux/stores': resolve(__dirname, 'src'), + }, + }, + }), + globals: { + '@zedux/atoms': 'ZeduxAtoms', + // don't add `@zedux/core` to globals - @zedux/stores prod builds bundle it + }, + moduleName: 'ZeduxStores', +}) diff --git a/scripts/release.js b/scripts/release.js index 82f4f4f6..ddb0a5c0 100755 --- a/scripts/release.js +++ b/scripts/release.js @@ -277,7 +277,6 @@ const generateChangelog = async (type, tagName, includeChores) => { fixes.push(item) } else if ((includeChores ? /^chore[(!:]/ : /^chore(\(.+?\))?!:/).test(message)) { // only include breaking chores if !includeChores - item.message = `Chore: ${shortMessage}` chores.push(item) } }) diff --git a/tsconfig.json b/tsconfig.json index 85fadcf0..b7ca1b56 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,9 +20,12 @@ "@zedux/machines": ["./packages/machines/src"], "@zedux/machines/*": ["./packages/machines/src/*"], "@zedux/react": ["./packages/react/src"], - "@zedux/react/*": ["./packages/react/src/*"] + "@zedux/react/*": ["./packages/react/src/*"], + "@zedux/stores": ["./packages/stores/src"], + "@zedux/stores/*": ["./packages/stores/src/*"] }, "resolveJsonModule": true, + "skipLibCheck": true, "strict": true, "target": "ES2015" },