Skip to content

feat!: implement proxies, signals, and mapped signals #147

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jan 6, 2025
Merged

Conversation

bowheart
Copy link
Collaborator

@bowheart bowheart commented Jan 3, 2025

@affects atoms, core, machines, react, stores

Description

Introduce injectSignal, injectMappedSignal, ecosystem.signal, the Signal class, and the ProxyWrapper class. Signals now replace stores as the primary state container of atoms.

More particularly, atoms now are signals - class AtomInstance extends Signal. When an atom returns a signal from the state factory, the atom becomes a thin wrapper around that signal. Otherwise, the atom itself is the signal. Whether an atom is or has a signal is an internal implementation detail of the atom. Consumers never have to know which it is - there is no .signal property on atoms, unlike .store.

Expand ExternalNode and GraphNode#on functionality to be a full event emission system. Export an As type helper for specifying custom signal events. Basic usage:

import { As, atom, injectSignal } from '@zedux/react'

const exampleAtom = atom('example', () => {
  const signal = injectSignal(state, {
    events: {
      myCustomEvent: As<MyPayloadType>
    }
  })

  return signal
}

Signal-wrapping atoms forward all events sent to them (instance.send('myEvent')) to the wrapped signal and all events sent to the wrapped signal (signal.send('myEvent')) to its own listeners (instance.on('myEvent', callback)).

Implement signal.mutate, which recursively creates ProxyWrappers and currently supports native JS arrays, objects, and sets. The mutation is translated into a list of "transactions" and sent to any mutate listeners:

const todoListAtom = atom('todoList', [])

const node = ecosystem.getNode(todoListAtom)

node.on('mutate', transactions => {
  // receives a compact object like [{ k: '0', v: { text: 'Publish Zedux v2!' } }]
})

node.mutate(state => {
  state.push({ text: 'Publish Zedux v2!' })
})

signal.mutate also accepts an object overload and a function-returning-object overload. Zedux recursively iterates over the object entries, applying each mutation for you (skipping undefined values as per #95).

Mapped signals forward all state updates (via .set and .mutate) and events (via .send) up to relevant wrapped signals and propagates all changes and events in wrapped signals down to itself and thence to its own observers.

Update all tests/example code snippets. Move the store-heavy ones to a packages/react/test/stores folder and migrate the rest to use signals. This gets us decent coverage of these changes. There's still a lot more to test, but I'm confident enough that the new APIs and core signal-based functionality are at least basically working.

TODO

Besides testing and docs, there are quite a few code-level things still to do - too many to list here. I added TODO comments all throughout the code that I believe cover everything. While there are lots of individual items, every one is tiny in scope compared to the monumental changes in #114 and this PR.

In short, despite the few PRs, we're maybe 90% of the way to Zedux v2. I expect to have Zedux v2 release candidates within the next few weeks 🎉

Deprecations

The getInstance atom getter is deprecated in favor of getNode, which is more appropriately named, more robustly-typed to handle all nodes - not just atom instances - and shorter 😄

I also deprecated AtomInstance#getState in favor of the new get method (which the old AtomInstance class inherits from the new one, specifically overriding to work with the store), which will help marginally with migrating.

Breaking Changes

Every @zedux/atoms API and type that worked with stores is now moved into a new @zedux/stores package which depends on @zedux/atoms. Every single API should continue to function as before. To migrate, simply change all usages of the following imports from @zedux/react (or @zedux/atoms which the react package re-exporrts) to @zedux/stores:

import {
  // APIs:
  api,
  atom,
  AtomApi,
  AtomInstance,
  AtomInstanceRecursive,
  AtomTemplate,
  AtomTemplateRecursive,
  injectPromise,
  injectStore,
  ion,
  IonTemplate,

  // types:
  AnyAtomApiGenerics,
  AnyAtomGenerics,
  AnyAtomApi,
-  AnyAtomInstance,
+  AnyStoreAtomInstance,
-  AnyAtomTemplate,
+  AnyStoreAtomTemplate,
  AtomApiGenerics,
  AtomApiGenericsPartial,
  AtomApiPromise,
  AtomEventsType,
  AtomExportsType,
  AtomGenerics,
  AtomGenericsToAtomApiGenerics,
  AtomInstanceType,
  AtomParamsType,
  AtomPromiseType,
  AtomStateFactory,
  AtomStateType,
  AtomStoreType,
  AtomValueOrFactory,
  AtomTemplateType,
  IonInstanceRecursive,
  IonStateFactory,
  IonTemplateRecursive,
  SelectorGenerics,
- } from '@zedux/react' // or '@zedux/atoms'
+ } from '@zedux/stores'

Notably, injectStore is no longer exported from @zedux/react (i.e. @zedux/atoms) at all. When working with stores, be sure to import the old atom, api, and ion factories from @zedux/stores as well, not the new ones from @zedux/react.

Many old atom type helpers are also only found in @zedux/stores now. The new helpers that are in @zedux/atoms now have a *Of format instead of Atom*Type. Simple incremental migration example:

// step 1:
- import { AtomStateType } from '@zedux/react'
+ import { AtomStateType } from '@zedux/stores'

type MyAtomState = AtomStateType<typeof myAtom>

// step 2:
- import { AtomStateType } from '@zedux/stores'
+ import { StateOf } from '@zedux/react'

- type MyAtomState = AtomStateType<typeof myAtom>
+ type MyAtomState = StateOf<typeof myAtom>

Since the new and old types/APIs work fine together, you can migrate in whatever increments you want.

No Codemod

We will probably not be making a codemod to migrate codebases to signals - for most usages, migration is as simple as:

- const store = injectStore(myState)
+ const signal = injectSignal(myState)

and changing the atom, ion, and api imports to @zedux/react or @zedux/atoms. While these basic usages are a simple find-and-replace, any composed-store usages, especially involving effects subscribers, are much less simple. Atoms and stores are different enough that a codemod could easily introduce subtle bugs. There still may be value in handling only the simple cases with a code mod, but since the @zedux/stores package maintains full backwards-compatibility, I'm recommending to switch to that and and manually migrate your codebase incrementally to signals.

Issues

#115 (does not fully resolve, but implements almost everything so we can get started using and testing the signals implementation)

Resolves #95. We may also change store.setStateDeep to ignore undefined, but it's probably better to leave that alone and recommend switching to signals instead to get signal.mutate's improved functionality.

@bowheart bowheart merged commit 6785d58 into master Jan 6, 2025
2 checks passed
@bowheart bowheart deleted the josh/signals branch January 6, 2025 16:43
@bowheart bowheart added this to the Zedux v2 milestone Jan 6, 2025
@bowheart bowheart mentioned this pull request Feb 3, 2025
53 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

TypeScript: Store values should probably all infer as optional
2 participants