Code Monkey home page Code Monkey logo

actomaton's Introduction

๐ŸŽญ Actomaton

Swift 5.9

๐Ÿง‘โ€๐ŸŽค Actor + ๐Ÿค– Automaton = ๐ŸŽญ Actomaton

Actomaton is Swift async/await & Actor-powered effectful state-management framework inspired by Elm and swift-composable-architecture.

This repository consists of 3 frameworks:

  1. Actomaton: Actor-based effect-handling state-machine at its core. Linux ready.
  2. ActomatonUI: SwiftUI & UIKit & Combine support
  3. ActomatonDebugging: Helper module to print Action and State (with diffing) per Reducer call.

(NOTE: ActomatonStore is deprecated in ver 0.7.0)

These frameworks depend on swift-case-paths as Functional Prism library, which is a powerful tool to construct an App-level Mega-Reducer from each screen's Reducers.

This framework is a successor of the following projects:

Installation

In Package.swift:

let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/Actomaton/Actomaton", .branch("main"))
    ]
)

Note: Specifying by "git version tag" is not currently supported due to usage of unsafe flags. See also: #56

Demo App

1. Actomaton (Core)

Example 1-1. Simple Counter

struct State: Sendable {
    var count: Int = 0
}

enum Action: Sendable {
    case increment
    case decrement
}

typealias Environment = Void

let reducer: Reducer<Action, State, Environment>
reducer = Reducer { action, state, environment in
    switch action {
    case .increment:
        state.count += 1
        return Effect.empty
    case .decrement:
        state.count -= 1
        return Effect.empty
    }
}

let actomaton = Actomaton<Action, State>(
    state: State(),
    reducer: reducer
)

@main
enum Main {
    static func main() async {
        assertEqual(await actomaton.state.count, 0)

        await actomaton.send(.increment)
        assertEqual(await actomaton.state.count, 1)

        await actomaton.send(.increment)
        assertEqual(await actomaton.state.count, 2)

        await actomaton.send(.decrement)
        assertEqual(await actomaton.state.count, 1)

        await actomaton.send(.decrement)
        assertEqual(await actomaton.state.count, 0)
    }
}

If you want to do some logging (side-effect), add Effect in Reducer as follows:

reducer = Reducer { action, state, environment in
    switch action {
    case .increment:
        state.count += 1
        return Effect.fireAndForget {
            print("increment")
        }
    case .decrement:
        state.count -= 1
        return Effect.fireAndForget {
            print("decrement and sleep...")
            try await Task.sleep(...) // NOTE: We can use `await`!
            print("I'm awake!")
        }
    }
}

NOTE: There are 5 ways of creating Effect in Actomaton:

  1. No side-effects, but next action only
    • Effect.nextAction
  2. Single async without next action
    • Effect.fireAndForget(id:run:)
  3. Single async with next action
    • Effect.init(id:run:)
  4. Multiple asyncs (i.e. AsyncSequence) with next actions
    • Effect.init(id:sequence:)
  5. Manual cancellation
    • Effect.cancel(id:) / .cancel(ids:)

Example 1-2. Login-Logout (and ForceLogout)

login-diagram

enum State: Sendable {
    case loggedOut, loggingIn, loggedIn, loggingOut
}

enum Action: Sendable {
    case login, loginOK, logout, logoutOK
    case forceLogout
}

// NOTE:
// Use same `EffectID` so that if previous effect is still running,
// next effect with same `EffectID` will automatically cancel the previous one.
//
// Note that `EffectID` is also useful for manual cancellation via `Effect.cancel`.
struct LoginFlowEffectID: EffectIDProtocol {}

struct Environment: Sendable {
    let loginEffect: (userId: String) -> Effect<Action>
    let logoutEffect: Effect<Action>
}

let environment = Environment(
    loginEffect: { userId in
        Effect(id: LoginFlowEffectID()) {
            let loginRequest = ...
            let data = try? await URLSession.shared.data(for: loginRequest)
            if Task.isCancelled { return nil }
            ...
            return Action.loginOK // next action
        }
    },
    logoutEffect: {
        Effect(id: LoginFlowEffectID()) {
            let logoutRequest = ...
            let data = try? await URLSession.shared.data(for: logoutRequest)
            if Task.isCancelled { return nil }
            ...
            return Action.logoutOK // next action
        }
    }
)

let reducer = Reducer { action, state, environment in
    switch (action, state) {
    case (.login, .loggedOut):
        state = .loggingIn
        return environment.login(state.userId)

    case (.loginOK, .loggingIn):
        state = .loggedIn
        return .empty

    case (.logout, .loggedIn),
        (.forceLogout, .loggingIn),
        (.forceLogout, .loggedIn):
        state = .loggingOut
        return environment.logout()

    case (.logoutOK, .loggingOut):
        state = .loggedOut
        return .empty

    default:
        return Effect.fireAndForget {
            print("State transition failed...")
        }
    }
}

let actomaton = Actomaton<Action, State>(
    state: .loggedOut,
    reducer: reducer,
    environment: environment
)

@main
enum Main {
    static func test_login_logout() async {
        var t: Task<(), Error>?

        assertEqual(await actomaton.state, .loggedOut)

        t = await actomaton.send(.login)
        assertEqual(await actomaton.state, .loggingIn)

        await t?.value // wait for previous effect
        assertEqual(await actomaton.state, .loggedIn)

        t = await actomaton.send(.logout)
        assertEqual(await actomaton.state, .loggingOut)

        await t?.value // wait for previous effect
        assertEqual(await actomaton.state, .loggedOut)

        XCTAssertFalse(isLoginCancelled)
    }

    static func test_login_forceLogout() async throws {
        var t: Task<(), Error>?

        assertEqual(await actomaton.state, .loggedOut)

        await actomaton.send(.login)
        assertEqual(await actomaton.state, .loggingIn)

        // Wait for a while and interrupt by `forceLogout`.
        // Login's effect will be automatically cancelled because of same `EffectID.
        try await Task.sleep(/* 1 ms */)
        t = await actomaton.send(.forceLogout)

        assertEqual(await actomaton.state, .loggingOut)

        await t?.value // wait for previous effect
        assertEqual(await actomaton.state, .loggedOut)

    }
}

Here we see the notions of EffectID, Environment, and let task: Task<(), Error> = actomaton.send(...)

  • EffectID is for both manual & automatic cancellation of previous running effects. In this example, forceLogout will cancel login's networking effect.
  • Environment is useful for injecting effects to be called inside Reducer so that they become replaceable. Environment is known as Dependency Injection (using Reader monad).
  • (Optional) Task<(), Error> returned from actomaton.send(action) is another fancy way of dealing with "all the effects triggered by action". We can call await task.value to wait for all of them to be completed, or task.cancel() to cancel all. Note that Actomaton already manages such tasks for us internally, so we normally don't need to handle them by ourselves (use this as a last resort!).

Example 1-3. Timer (using AsyncSequence) and EffectID cancellation

typealias State = Int

enum Action: Sendable {
    case start, tick, stop
}

struct TimerID: EffectIDProtocol {}

struct Environment {
    let timerEffect: Effect<Action>
}

let environment = Environment(
    timerEffect: { userId in
        Effect(id: TimerID(), sequence: {
            AsyncStream<()> { continuation in
                let task = Task {
                    while true {
                        try await Task.sleep(/* 1 sec */)
                        continuation.yield(())
                    }
                }

                continuation.onTermination = { @Sendable _ in
                    task.cancel()
                }
            }
        })
    }
)

let reducer = Reducer { action, state, environment in
    switch action {
    case .start:
        return environment.timerEffect
    case .tick:
        state += 1
        return .empty
    case .stop:
        return Effect.cancel(id: TimerID())
    }
}

let actomaton = Actomaton<Action, State>(
    state: 0,
    reducer: reducer,
    environment: environment
)

@main
enum Main {
    static func test_timer() async {
        assertEqual(await actomaton.state, 0)

        await actomaton.send(.start)

        assertEqual(await actomaton.state, 0)

        try await Task.sleep(/* 1 sec */)
        assertEqual(await actomaton.state, 1)

        try await Task.sleep(/* 1 sec */)
        assertEqual(await actomaton.state, 2)

        try await Task.sleep(/* 1 sec */)
        assertEqual(await actomaton.state, 3)

        await actomaton.send(.stop)

        try await Task.sleep(/* long enough */)
        assertEqual(await actomaton.state, 3,
                    "Should not increment because timer is stopped.")
    }
}

In this example, Effect(id:sequence:) is used for timer effect, which yields Action.tick multiple times.

Example 1-4. EffectQueue

enum Action: Sendable {
    case fetch(id: String)
    case _didFetch(Data)
}

struct State: Sendable {} // no state

struct Environment: Sendable {
    let fetch: @Sendable (_ id: String) async throws -> Data
}

struct DelayedEffectQueue: EffectQueueProtocol {
    // First 3 effects will run concurrently, and other sent effects will be suspended.
    var effectQueuePolicy: EffectQueuePolicy {
        .runOldest(maxCount: 3, .suspendNew)
    }

    // Adds delay between effect start. (This is useful for throttling / deboucing)
    var effectQueueDelay: EffectQueueDelay {
        .random(0.1 ... 0.3)
    }
}

let reducer = Reducer<Action, State, Environment> { action, state, environment in
    switch action {
    case let .fetch(id):
        return Effect(queue: DelayedEffectQueue()) {
            let data = try await environment.fetch(id)
            return ._didFetch(data)
        }
    case let ._didFetch(data):
        // Do something with `data`.
        return .empty
    }
}

let actomaton = Actomaton<Action, State>(
    state: State(),
    reducer: reducer,
        environment: Environment(fetch: { /* ... */ })
)

await actomaton.send(.fetch(id: "item1"))
await actomaton.send(.fetch(id: "item2")) // min delay of 0.1
await actomaton.send(.fetch(id: "item3")) // min delay of 0.1 (after item2 actually starts)
await actomaton.send(.fetch(id: "item4")) // starts when item1 or 2 or 3 finishes

Above code uses a custom DelayedEffectQueue that conforms to EffectQueueProtocol with suspendable EffectQueuePolicy and delays between each effect by EffectQueueDelay.

See EffectQueuePolicy for how each policy takes different queueing strategy for effects.

/// `EffectQueueProtocol`'s buffering policy.
public enum EffectQueuePolicy: Hashable, Sendable
{
    /// Runs `maxCount` newest effects, cancelling old running effects.
    case runNewest(maxCount: Int)

    /// Runs `maxCount` old effects with either suspending or discarding new effects.
    case runOldest(maxCount: Int, OverflowPolicy)

    public enum OverflowPolicy: Sendable
    {
        /// Suspends new effects when `.runOldest` `maxCount` of old effects is reached until one of them is completed.
        case suspendNew

        /// Discards new effects when `.runOldest` `maxCount` of old effects is reached until one of them is completed.
        case discardNew
    }
}

For convenient EffectQueueProtocol protocol conformance, there are built-in sub-protocols:

/// A helper protocol where `effectQueuePolicy` is set to `.runNewest(maxCount: 1)`.
public protocol Newest1EffectQueueProtocol: EffectQueueProtocol {}

/// A helper protocol where `effectQueuePolicy` is set to `.runOldest(maxCount: 1, .discardNew)`.
public protocol Oldest1DiscardNewEffectQueueProtocol: EffectQueueProtocol {}

/// A helper protocol where `effectQueuePolicy` is set to `.runOldest(maxCount: 1, .suspendNew)`.
public protocol Oldest1SuspendNewEffectQueueProtocol: EffectQueueProtocol {}

so that we can write in one-liner: struct MyEffectQueue: Newest1EffectQueueProtocol {}

Example 1-5. Reducer composition

Actomaton-Gallery provides a good example of how Reducers can be combined together into one big Reducer using Reducer.combine.

In this example, swift-case-paths is used as a counterpart of WritableKeyPath, so if we use both, we can easily construct Mega-Reducer without a hassle.

(NOTE: CasePath is useful when dealing with enums, e.g. enum Action and enum Current in this example)

enum Root {} // just a namespace

extension Root {
    enum Action: Sendable {
        case changeCurrent(State.Current?)

        case counter(Counter.Action)
        case stopwatch(Stopwatch.Action)
        case stateDiagram(StateDiagram.Action)
        case todo(Todo.Action)
        case github(GitHub.Action)
    }

    struct State: Equatable, Sendable {
        var current: Current?

        // Current screen (NOTE: enum, so only 1 screen will appear)
        enum Current: Equatable {
            case counter(Counter.State)
            case stopwatch(Stopwatch.State)
            case stateDiagram(StateDiagram.State)
            case todo(Todo.State)
            case github(GitHub.State)
        }
    }

    // NOTE: `contramap` is also called `pullback` in swift-composable-architecture.
    static var reducer: Reducer<Action, State, Environment> {
        Reducer.combine(
            Counter.reducer
                .contramap(action: /Action.counter)
                .contramap(state: /State.Current.counter)
                .contramap(state: \State.current)
                .contramap(environment: { _ in () }),

            Todo.reducer
                .contramap(action: /Action.todo)
                .contramap(state: /State.Current.todo)
                .contramap(state: \State.current)
                .contramap(environment: { _ in () }),

            StateDiagram.reducer
                .contramap(action: /Action.stateDiagram)
                .contramap(state: /State.Current.stateDiagram)
                .contramap(state: \State.current)
                .contramap(environment: { _ in () }),

            Stopwatch.reducer
                .contramap(action: /Action.stopwatch)
                .contramap(state: /State.Current.stopwatch)
                .contramap(state: \State.current)
                .contramap(environment: { $0.stopwatch }),

            GitHub.reducer
                .contramap(action: /Action.github)
                .contramap(state: /State.Current.github)
                .contramap(state: \State.current)
                .contramap(environment: { $0.github })
        )
    }
}

To learn more about CasePath, visit the official site and tutorials:

2. ActomatonUI (SwiftUI & UIKit)

Store (from ActomatonUI.framework) provides a thin wrapper of Actomaton to work seamlessly in SwiftUI and UIKit world.

To find out more, check the following resources:

References

License

MIT

actomaton's People

Contributors

erjanmx avatar inamiy avatar redryerye avatar ryogabarbie avatar yimajo avatar yochidros avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

actomaton's Issues

7guis showcase

I wonder whether this could be used as the underlying "data framework" for implementing 7guis using any GUI toolking of your choice (probably one of those used in Apple ecosystems)?

Breaking type-check when using effect binary operation multi times

Description

iโ€™m facing problem about some project which is using actomaton did breaking type-check.

no problem using binary operation + 1~3 times.
but sometimes use over 3 times, it's problem occurred.

i hope some advice about it ๐Ÿ™

Sample code

It happens type-check error.
Compiler error message:

The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions

ใ‚นใ‚ฏใƒชใƒผใƒณใ‚ทใƒงใƒƒใƒˆ 2022-09-06 14 44 29

Workaround: I do separated some effects as local variables. it's work fine.

ex.

 let effect1 = Effect { 
      // do some side effect
     Action.a
 } + Effect {
    // do some side effect
    return nil
 }
 
 let effect2 = Effect { 
      // do some side effect
     Action.b
  } + Effect {
    // do some side effect
    return nil
 }
 
// work fine
 return effect1 + effect2

Drag & drop - Race condition warning

Hi, I'm trying to dispatch an action to the store, using the new drag and drop api for SwiftUI (MacOS Ventura).

Here's the modifier:
.dropDestination(for: I.self) { items, location in store.send(.interaction(.droppedListItems((items, itemId)))) return false }isTargeted: { inDropArea in self.dropHovered = inDropArea }

I get the warning "Data race detected: @mainactor function at ActomatonUI/ViewStore.swift:40 was not called on the main thread"

What am I doing wrong ? Any solution to this would be highly appreciated. Thanks!

Unsafe flags were added and after that I cannot use Actomaton as a dependency

Hi.

I played with Actomaton version 0.3.1 and I was happy with it.
Now I tried to upgrade to 0.6.0 and I get the following compilation error:
The package product 'ActomatonStore' cannot be used as a dependency of this target because it uses unsafe build flags.

The commit which introduced the change is this:
Add Sendable conformances, -warn-concurrency & -enable-actor-data-race-checks compiler options

To me this unsafe flags error seems confusing, like for the others.
If that unsafe flags are banned from the dependencies, what should we do? Is that absolutely necessary to add or there is a workaround setting into the project which depend on unsafe flagged packages?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.