From 9cbee475d0126357dbadf28933727e039919718e Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Mon, 8 Jun 2026 10:31:54 +0700 Subject: [PATCH] Make AppRefresher generic over Option + Source payload --- README.md | 66 +++++++---- .../Refresh/AppRefresher.swift | 105 ++++++++---------- .../Refresh/AppRefresherTests.swift | 85 +++++++------- 3 files changed, 133 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 5973f03..de877d1 100644 --- a/README.md +++ b/README.md @@ -569,32 +569,43 @@ struct EditItemView: View { ## App Refresh Bus -A lightweight, app-wide "refresh signal" bus built on the SwiftUI environment and the `@Observable` macro (no Combine). It lets any screen broadcast *"something changed, reload X"* and lets any other screen react — without the two knowing about each other. This decouples a producer (e.g. you just created an item) from consumers (e.g. a list that needs to reload). +A lightweight, app-wide "refresh signal" bus built on the SwiftUI environment and the `@Observable` macro (no Combine). It lets any screen broadcast *"something changed, reload X"* and lets any other screen react — without the two knowing about each other. It is generic over two types you define: + +- **`Option`** (an `OptionSet`) — *what* to refresh. Consumers filter on it, and you can combine signals (`[.inbox, .settings]`) in one call. +- **`Source`** (any `Sendable`, typically an `enum`) — an optional **payload** delivered to observers. Its associated values carry the fresh object, so a consumer can update without touching a local DB (e.g. hand it a brand-new `Session`). ### How It Works -- **`AppRefresher`** — `@MainActor @Observable` broadcaster holding a single `action`. Call `refresh(_:)` to publish. -- **`AppRefreshAction.RefreshOption`** — an `OptionSet` describing *what* to refresh. ScreenStateKit ships only `.idle`; **your app defines its own options** by extending it. Because it's an `OptionSet`, multiple signals can be combined in one call. -- **`AppRefreshAction`** — wraps the option **plus a fresh `requestId: UUID`** on every `refresh(_:)`. The UUID makes each signal unique, so consumers still react when the *same* option is fired twice in a row (SwiftUI would otherwise dedupe identical values). +- **`AppRefresher`** — `@MainActor @Observable` broadcaster holding the latest `action`. Call `refresh(_:source:)` to publish. +- **`AppRefreshAction`** — wraps the `option`, the optional `source` payload, and a fresh `id: UUID` on every call. The UUID makes each signal unique, so consumers still react when the *same* option fires twice in a row (SwiftUI would otherwise dedupe identical values). -### 1. Define Your Refresh Options +### 1. Define Your Option and Source ```swift import ScreenStateKit -extension AppRefreshAction.RefreshOption { - static let globalInbox = AppRefreshAction.RefreshOption(rawValue: 1 << 1) - static let channelState = AppRefreshAction.RefreshOption(rawValue: 1 << 2) +struct RefreshOption: OptionSet, Sendable { + let rawValue: Int + static let inboxMessage = RefreshOption(rawValue: 1 << 0) + static let teamSettings = RefreshOption(rawValue: 1 << 1) +} + +enum RefreshSource: Sendable { + case newSetting(TeamSetting) // associated value = the payload object + case newSession(Session) } + +typealias Refresher = AppRefresher ``` ### 2. Host the Bus Once, Near the Root ```swift +// Auto-create and inject: RootView() - .appRefresherHost() // creates and injects an AppRefresher + .appRefresherHost(option: RefreshOption.self, source: RefreshSource.self) -// Or inject a shared instance you own (e.g. to send from a store): +// Or inject a shared instance you own (e.g. to also send from a store): RootView() .appRefresherHost(myRefresher) ``` @@ -602,14 +613,14 @@ RootView() ### 3. Broadcast a Signal ```swift -struct CreateItemScreen: View { - @Environment(\.appRefresher) private var refresher +struct EditSettingScreen: View { + @Environment(Refresher.self) private var refresher var body: some View { - Button("Create") { + Button("Save") { Task { - await store.receive(.create) - refresher?.refresh(.globalInbox) // or [.globalInbox, .channelState] + let setting = await store.save() + refresher?.refresh(.teamSettings, source: .newSetting(setting)) } } } @@ -623,17 +634,23 @@ struct InboxScreen: View { var body: some View { List(/* ... */) { /* ... */ } // .onNextAppear (default): defers until the screen is next visible - .onAppRefresh(.globalInbox) { - Task { await store.receive(.reload) } + .onAppRefresh(RefreshOption.teamSettings) { (source: RefreshSource?) in + if case let .newSetting(setting) = source { + store.apply(setting) // use the payload directly + } else { + Task { await store.receive(.reload) } + } } // .immediate: runs right away, even while off-screen - .onAppRefresh(.channelState, behavior: .immediate) { - Task { await store.receive(.recheckState) } + .onAppRefresh(RefreshOption.inboxMessage, behavior: .immediate) { _ in + Task { await store.receive(.reload) } } } } ``` +> Spell the option's type at the call site (`RefreshOption.teamSettings`) — chained modifiers give no contextual type for leading-dot syntax to infer the generic `Option` from. + ### Behavior | Behavior | When the action runs | @@ -641,7 +658,7 @@ struct InboxScreen: View { | `.onNextAppear` (default) | Stores the request; runs on the view's next `onAppear`. Ideal for hidden screens — no point reloading a list the user can't see. | | `.immediate` | Runs the instant the signal fires, even if the view is off-screen. | -> **Using it from a Store:** The bus is read through the SwiftUI environment, so the View is the boundary that listens. Forward into your `ScreenActionStore` from the closure — `.onAppRefresh(.globalInbox) { Task { await store.receive(.reload) } }` — keeping the store free of any SwiftUI/environment coupling. +> **Using it from a Store:** The bus is read through the SwiftUI environment, so the View is the boundary that listens. Forward into your `ScreenActionStore` from the closure — `.onAppRefresh(RefreshOption.inboxMessage) { _ in Task { await store.receive(.reload) } }` — keeping the store free of any SwiftUI/environment coupling. --- @@ -816,7 +833,7 @@ func observe(stream: AnyAsyncStream) async { |-------|---------| | `ScreenState` | `@Observable @MainActor` base class with loading counter, error handling, and parent binding | | `LoadmoreScreenState` | Extends `ScreenState` with pagination state (`canShowLoadmore`, `didLoadAllData`) | -| `AppRefresher` | `@Observable @MainActor` app-wide refresh bus. Call `refresh(_:)` to broadcast a `RefreshOption` | +| `AppRefresher` | `@Observable @MainActor` app-wide refresh bus. Call `refresh(_:source:)` to broadcast an `OptionSet` plus an optional payload | ### Actors @@ -835,7 +852,8 @@ func observe(stream: AnyAsyncStream) async { | `AnyTask` | Public handle to a stored task with `cancel()`, `waitComplete()`, and `isCancelled` | | `AnyAsyncStream` | Type-erased `AsyncSequence` wrapper | | `RMLoadmoreView` | Pre-built `ProgressView` for load-more pagination | -| `AppRefreshAction` | Envelope carrying a `RefreshOption` plus a unique `requestId`; nested `RefreshOption` is a consumer-extensible `OptionSet` | +| `AppRefreshAction` | Envelope carrying the `option`, an optional `source` payload, and a unique `id` per emission | +| `AppRefreshBehavior` | Delivery mode for `onAppRefresh`: `.onNextAppear` (default) or `.immediate` | ### View Modifiers @@ -849,8 +867,8 @@ func observe(stream: AnyAsyncStream) async { | `.onDeleted(_:)` | Environment callback for delete actions | | `.onCreated(_:)` | Environment callback for create actions | | `.onCancelled(_:)` | Environment callback for cancel actions | -| `.appRefresherHost()` | Creates and injects an `AppRefresher` into the environment (overload accepts a shared instance) | -| `.onAppRefresh(_:behavior:perform:)` | Reacts to a `RefreshOption`, with `.onNextAppear` (default) or `.immediate` behavior | +| `.appRefresherHost(option:source:)` | Creates and injects an `AppRefresher` into the environment (overload accepts a shared instance) | +| `.onAppRefresh(_:behavior:perform:)` | Reacts to an option, delivering the `Source?` payload, with `.onNextAppear` (default) or `.immediate` behavior | --- diff --git a/Sources/ScreenStatetKit/Refresh/AppRefresher.swift b/Sources/ScreenStatetKit/Refresh/AppRefresher.swift index c3347af..01f48df 100644 --- a/Sources/ScreenStatetKit/Refresh/AppRefresher.swift +++ b/Sources/ScreenStatetKit/Refresh/AppRefresher.swift @@ -2,101 +2,88 @@ import SwiftUI @MainActor @Observable -public final class AppRefresher { - public private(set) var action: AppRefreshAction +public final class AppRefresher { + public private(set) var action: AppRefreshAction? - public init() { - self.action = AppRefreshAction(option: .idle) - } + public init() {} - public func refresh(_ option: AppRefreshAction.RefreshOption) { - action = AppRefreshAction(option: option) + public func refresh(_ option: Option, source: Source? = nil) { + action = AppRefreshAction(option: option, source: source) } } -public extension AppRefresher { - - enum Behavior: Int8, Sendable { - case immediate - case onNextAppear - } +public enum AppRefreshBehavior: Int8, Sendable { + case immediate + case onNextAppear } -public struct AppRefreshAction: Equatable, Sendable { - - public struct RefreshOption: OptionSet, Sendable { - public let rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } +public struct AppRefreshAction: Sendable, Identifiable { + public let id: UUID + public let option: Option + public let source: Source? - public static let idle = RefreshOption(rawValue: 1 << 0) - } - - public let requestId: UUID - public let option: RefreshOption - - public init(option: RefreshOption) { - self.requestId = UUID() + public init(option: Option, source: Source? = nil) { + self.id = UUID() self.option = option + self.source = source } } -public extension EnvironmentValues { - @Entry var appRefresher: AppRefresher? = nil -} - public extension View { - func appRefresherHost() -> some View { - modifier(AppRefresherHostModifier()) + func appRefresherHost( + option: Option.Type, + source: Source.Type + ) -> some View where Option: OptionSet & Sendable, Source: Sendable { + modifier(AppRefresherHostModifier()) } - func appRefresherHost(_ refresher: AppRefresher) -> some View { - environment(\.appRefresher, refresher) + func appRefresherHost(_ refresher: AppRefresher) -> some View + where Option: OptionSet & Sendable, Source: Sendable { + environment(refresher) } - func onAppRefresh( - _ option: AppRefreshAction.RefreshOption, - behavior: AppRefresher.Behavior = .onNextAppear, - perform action: @escaping () -> Void - ) -> some View { - modifier(AppRefreshActionModifier(option: option, behavior: behavior, action: action)) + func onAppRefresh( + _ option: Option, + behavior: AppRefreshBehavior = .onNextAppear, + perform action: @escaping (Source?) -> Void + ) -> some View where Option: OptionSet & Sendable, Source: Sendable { + modifier(AppRefreshActionModifier(option: option, behavior: behavior, action: action)) } } -private struct AppRefresherHostModifier: ViewModifier { - @State private var refresher = AppRefresher() +private struct AppRefresherHostModifier: ViewModifier { + @State private var refresher = AppRefresher() func body(content: Content) -> some View { - content.environment(\.appRefresher, refresher) + content.environment(refresher) } } -private struct AppRefreshActionModifier: ViewModifier { +private struct AppRefreshActionModifier: ViewModifier { - @Environment(\.appRefresher) private var refresher - @State private var pendingRefreshId: UUID? + @Environment(AppRefresher.self) private var refresher: AppRefresher? + @State private var pending: AppRefreshAction? - let option: AppRefreshAction.RefreshOption - let behavior: AppRefresher.Behavior - let action: () -> Void + let option: Option + let behavior: AppRefreshBehavior + let action: (Source?) -> Void func body(content: Content) -> some View { content .onAppear { - guard pendingRefreshId != nil, behavior == .onNextAppear else { return } - pendingRefreshId = nil - action() + guard behavior == .onNextAppear, let pending else { return } + self.pending = nil + action(pending.source) } - .onChange(of: refresher?.action) { _, newValue in - guard let newValue, newValue.option.contains(option) else { return } + .onChange(of: refresher?.action?.id) { _, newId in + guard newId != nil, let current = refresher?.action else { return } + guard current.option.isSuperset(of: option) else { return } switch behavior { case .immediate: - action() + action(current.source) case .onNextAppear: - pendingRefreshId = newValue.requestId + pending = current } } } diff --git a/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift b/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift index 3b2d186..9e6c15f 100644 --- a/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift +++ b/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift @@ -7,78 +7,83 @@ import Testing import SwiftUI @testable import ScreenStateKit -private extension AppRefreshAction.RefreshOption { - static let inbox = AppRefreshAction.RefreshOption(rawValue: 1 << 1) - static let channelState = AppRefreshAction.RefreshOption(rawValue: 1 << 2) +private struct TestOption: OptionSet, Sendable { + let rawValue: Int + static let inbox = TestOption(rawValue: 1 << 0) + static let settings = TestOption(rawValue: 1 << 1) } +private enum TestSource: Sendable, Equatable { + case newSetting(String) + case newSession(Int) +} + +private typealias SUT = AppRefresher + @Suite("AppRefresher Tests") @MainActor struct AppRefresherTests { - @Test("init starts in idle") - func test_init_startsIdle() { - let sut = AppRefresher() + @Test("init starts with no action") + func test_init_noAction() { + let sut = SUT() - #expect(sut.action.option == .idle) + #expect(sut.action == nil) } - @Test("refresh updates the current option") - func test_refresh_updatesOption() { - let sut = AppRefresher() + @Test("refresh records the option and leaves source nil by default") + func test_refresh_recordsOption_noSource() { + let sut = SUT() sut.refresh(.inbox) - #expect(sut.action.option == .inbox) + #expect(sut.action?.option == .inbox) + #expect(sut.action?.source == nil) } - @Test("refreshing the same option twice produces a new requestId") - func test_refresh_sameOptionTwice_producesUniqueRequestIds() { - let sut = AppRefresher() + @Test("refresh carries the source payload object") + func test_refresh_carriesSourcePayload() { + let sut = SUT() - sut.refresh(.inbox) - let first = sut.action.requestId - sut.refresh(.inbox) - let second = sut.action.requestId + sut.refresh(.settings, source: .newSetting("dark")) - #expect(first != second) + #expect(sut.action?.option == .settings) + #expect(sut.action?.source == .newSetting("dark")) } - @Test("refresh can carry combined options") + @Test("refresh can combine options in one signal") func test_refresh_combinedOptions() { - let sut = AppRefresher() + let sut = SUT() - sut.refresh([.inbox, .channelState]) + sut.refresh([.inbox, .settings], source: .newSession(7)) - #expect(sut.action.option.contains(.inbox)) - #expect(sut.action.option.contains(.channelState)) + #expect(sut.action?.option.contains(.inbox) == true) + #expect(sut.action?.option.contains(.settings) == true) + #expect(sut.action?.source == .newSession(7)) } - @Test("idle option does not contain consumer options") - func test_idle_doesNotContainConsumerOptions() { - let sut = AppRefresher() + @Test("refreshing the same option twice produces a new id") + func test_refresh_sameOptionTwice_producesUniqueIds() { + let sut = SUT() - #expect(sut.action.option.contains(.inbox) == false) - } - - @Test("environment value can be set and retrieved") - func test_environmentValue_setAndRetrieve() { - var env = EnvironmentValues() - #expect(env.appRefresher == nil) + sut.refresh(.inbox) + let first = sut.action?.id + sut.refresh(.inbox) + let second = sut.action?.id - env.appRefresher = AppRefresher() - #expect(env.appRefresher != nil) + #expect(first != nil) + #expect(first != second) } @Test("view modifiers can be applied to views") func test_viewModifiers_canBeApplied() { let _ = Text("Test") - .appRefresherHost() - .onAppRefresh(.inbox) { } + .appRefresherHost(option: TestOption.self, source: TestSource.self) + .onAppRefresh(TestOption.inbox) { (_: TestSource?) in } - let refresher = AppRefresher() + let refresher = SUT() let _ = Text("Test") .appRefresherHost(refresher) - .onAppRefresh(.channelState, behavior: .immediate) { } + .onAppRefresh(TestOption.settings, behavior: .immediate) { (_: TestSource?) in } } }