Omnistac / zedux

:zap: A Molecular State Engine for React
https://Omnistac.github.io/zedux/
MIT License
376 stars 7 forks source link

New Plugin System #119

Open bowheart opened 2 months ago

bowheart commented 2 months ago

The current plan is to completely rework how plugins work in Zedux v2.

The Current Model

Plugins in Zedux v1 are purposefully verbose. You instantiate a class, set up your initial "mods", manually subscribe to the ecosystem's mod store with a verbose effects subscriber, and add ugly checks for mod action types.

const loggingPlugin = new ZeduxPlugin({
  initialMods: ['stateChanged'],

  registerEcosystem: ecosystem => {
    const subscription = ecosystem.modBus.subscribe({
      effects: ({ action }) => {
        if (action.type === ZeduxPlugin.actions.stateChanged.type) {
          console.log('node state updated', action.payload)
        }
      },
    })

    return () => subscription.unsubscribe()
  },
})

myEcosystem.registerPlugin(loggingPlugin)
myEcosystem.unregisterPlugin(loggingPlugin)

Fast forward a couple years. We now know what plugins actually need. We can make this a lot better.

The New Model

Plugins will simply be calls to ecosystem.on.

const cleanup = myEcosystem.on('change', reason => {
  console.log('node state updated', reason)
})

Wow such clean 🤩.

Plugins previously might have maintained internal state, turned on multiple mods, and/or returned a cleanup function. This can all be done in the new model. Conventionally, a "plugin" will now be a simple function that accepts the ecosystem and calls .on. It can instantiate and use atoms in the ecosystem (or its own internal ecosystem), store internal state, and clean it all up on destroy or reset:

const logAtom = atom('log', [])

const loggingPlugin = (ecosystem: Ecosystem) => {
  // other internal state can be tracked here:
  let internalState = []

  const cleanupChange = myEcosystem.on('change', reason => {
    ecosystem.getNode(logAtom).set(state => [...state, reason])
    internalState.push(reason)
  })

  // plugins can listen to `destroy` and/or `reset` to destroy internal state
  const cleanupDestroy = ecosystem.on('destroy', () => {
    cleanupChange()
    cleanupDestroy()
    ecosystem.getNode(logAtom).destroy(true)
    internalState = []
  })
}

loggingPlugin(myEcosystem)

Note that the destroy handler in this example is unnecessary - all four of its cleanup operations would happen automatically if the ecosystem is garbage collected. This is just for demonstration.

Communicating with Plugins

Just like with the new GraphNode#on model described in #115, Ecosystem#on will support custom events. This will allow you to send anything you want to plugins via Ecosystem#send.

myEcosystem.send('myCustomEvent', 'my custom payload type')

Ecosystems will get a new generic for event maps:

const myEcosystem = createEcosystem<undefined, {
  myCustomEvent: string
}>()

We'll also document how to create a typed useEcosystem hook and injectAtomGetters injector (or injectEcosystem if we switch to that) so all interactions with your ecosystem have event types intact.

The Plugin Events

The concept of "mods" shall die, replaced with "plugin events". Some of the mods will have event equivalents. Some will be removed completely:

The full list of built-in events is:

Zedux will no longer track evaluation time internally (previously a feature of the evaluationFinished mod). Instead, use a combination of runStart and runEnd to track it yourself. This gives more control and allows us to remove one of Zedux's only two non-deterministic APIs (ecosystem._idGenerator.now) which will make it easier for users to test Zedux code (especially via snapshot testing).

Full Change List

These APIs will be removed:

These APIs will be added:

Additional Considerations

Instead of the new concept of a plugin being a simple function, we might want to export a new plugin factory for creating Plugins with event maps intact and add a plugins ecosystem config option to auto-type the ecosystem's event map. Besides automatic type inference, this would allow plugins to return a cleanup function again, removing the need for the new destroy event.

Theoretical futuristic code:

import { createEcosystem, type NodeFilter, plugin } from '@zedux/react'

const Placeholder = {} as any

const loggingPlugin = plugin(
  'logging',
  ecosystem => {
    ecosystem.on('change', reason => {
      console.log('node state updated', reason)
    })

    ecosystem.on('logAll', filterOptions => {
      console.log('all atoms:', ecosystem.findAll(filterOptions))
    })

    // can return cleanup function here (unnecessary in this case)
  },
  {
    logAll: Placeholder as NodeFilter,
  }
)

const ecosystem = createEcosystem('root', {
  plugins: [loggingPlugin],
})

ecosystem.send('logAll', { excludeFlags: ['disable-logging'] })