From 7549ad4026572d6ab59af82d0460eec784f1eb08 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:56:10 +0200 Subject: [PATCH 1/3] fix: Idempotent start --- Sources/GoodReactor/Core/Reactor.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/GoodReactor/Core/Reactor.swift b/Sources/GoodReactor/Core/Reactor.swift index 4727043..b794c76 100644 --- a/Sources/GoodReactor/Core/Reactor.swift +++ b/Sources/GoodReactor/Core/Reactor.swift @@ -155,6 +155,9 @@ import SwiftUI /// of the reactor. /// /// You can use multiple subscriptions to multiple external events. + /// + /// - important: Keep this function free of side effects, except calls to + /// ``subscribe(to:map:)``. This function may be invoked multiple times. func transform() #if canImport(Combine) @@ -329,6 +332,11 @@ public extension Reactor { /// uses Combine, subscribes the event stream and publishes the /// initial state. func start() { + let hasSubscriptions = MapTables.subscriptions.value(forKey: self)?.isEmpty == false + guard !hasSubscriptions else { + return + } + self.transform() #if canImport(Combine) From 487551d4fd2f3e16545a362de46500c61bfd42ea Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:12:50 +0200 Subject: [PATCH 2/3] fix: Disabled auto-start of AnyReactor --- README.md | 7 ++++++- Sources/GoodReactor/Core/Erased/AnyReactor.swift | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a2e2cc0..f6a475a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,12 @@ You add the ViewModel as a property wrapper to your view: @ViewModel var model: AnyReactor = MyViewModel().eraseToAnyReactor() ``` +`AnyReactor` does not start the wrapped reactor automatically. Start it explicitly: + +```swift +.task { model.start() } +``` + To access the current `State` you use: ```swift @@ -200,4 +206,3 @@ You can easily mock state for Xcode Previews by using `Stub` reactor implementat # License GoodReactor repository is released under the MIT license. See [LICENSE](LICENSE.md) for details. - diff --git a/Sources/GoodReactor/Core/Erased/AnyReactor.swift b/Sources/GoodReactor/Core/Erased/AnyReactor.swift index 5b7535c..7b13609 100644 --- a/Sources/GoodReactor/Core/Erased/AnyReactor.swift +++ b/Sources/GoodReactor/Core/Erased/AnyReactor.swift @@ -25,7 +25,8 @@ import SwiftUI /// /// Behavior: /// - State is accessed dynamically and mutated by reducing events on a concrete underlying reactor. -/// - Lifecycle: external subscriptions start automatically when `AnyReactor` is initialized (by `start()`-ing the base reactor). +/// - Lifecycle: `AnyReactor` does not automatically start the wrapped reactor. +/// Call `start()` explicitly to initialize external subscriptions. /// - Events from `send(action:)`, `send(action:) async`, and `send(destination:)` are forwarded to the base reactor. /// /// Example: @@ -58,9 +59,11 @@ import SwiftUI // MARK: - Initialization + /// Creates a type-erased wrapper around `base`. + /// + /// - note: This initializer does not call `start()` on `base`. public init(_ base: R) where R.Action == Action, R.Destination == Destination, R.State == State { self._box = AnyReactorBox(base) - base.start() } } @@ -151,4 +154,3 @@ public extension Reactor { } } - From 7b3fd5467534ddbc2766555ab0b4951d58f07c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20S=CC=8Cas=CC=8Cala?= <31418257+plajdo@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:14:13 +0200 Subject: [PATCH 3/3] task: Added tests --- Tests/GoodReactorTests/AnyReactorTests.swift | 24 +++++++++++++++++++ Tests/GoodReactorTests/GoodReactorTests.swift | 24 +++++++++++++++++++ .../Samples/ManualEventPublisher.swift | 16 +++++++++++++ .../Samples/ObservableModel.swift | 11 +++++++++ 4 files changed, 75 insertions(+) create mode 100644 Tests/GoodReactorTests/Samples/ManualEventPublisher.swift diff --git a/Tests/GoodReactorTests/AnyReactorTests.swift b/Tests/GoodReactorTests/AnyReactorTests.swift index cea6318..1dbb504 100644 --- a/Tests/GoodReactorTests/AnyReactorTests.swift +++ b/Tests/GoodReactorTests/AnyReactorTests.swift @@ -143,4 +143,28 @@ final class AnyReactorTests: XCTestCase { XCTAssertEqual(model.counter, binding.wrappedValue) } + @MainActor func testReactorStartIdempontency() async { + let model = AnyReactor(ObservableModel()) + + XCTAssertEqual(model.manualEventsCount, 0) + + model.start() + + try? await Task.sleep(for: .milliseconds(100)) // subscriptions start asynchronously + await ManualEventPublisher.shared.eventPublisher.send(1) + try? await Task.sleep(for: .milliseconds(100)) // events are sent asynchronously + + XCTAssertEqual(model.manualEventsCount, 1) + + model.start() + model.start() + model.start() + + try? await Task.sleep(for: .milliseconds(100)) // subscriptions start asynchronously + await ManualEventPublisher.shared.eventPublisher.send(1) + try? await Task.sleep(for: .milliseconds(100)) // events are sent asynchronously + + XCTAssertEqual(model.manualEventsCount, 2) + } + } diff --git a/Tests/GoodReactorTests/GoodReactorTests.swift b/Tests/GoodReactorTests/GoodReactorTests.swift index 6f47d85..8c8cc03 100644 --- a/Tests/GoodReactorTests/GoodReactorTests.swift +++ b/Tests/GoodReactorTests/GoodReactorTests.swift @@ -140,4 +140,28 @@ final class GoodReactorTests: XCTestCase { XCTAssertEqual(binding.wrappedValue, 21) } + @MainActor func testReactorStartIdempontency() async { + let model = ObservableModel() + + XCTAssertEqual(model.manualEventsCount, 0) + + model.start() + + try? await Task.sleep(for: .milliseconds(100)) // subscriptions start asynchronously + await ManualEventPublisher.shared.eventPublisher.send(1) + try? await Task.sleep(for: .milliseconds(100)) // events are sent asynchronously + + XCTAssertEqual(model.manualEventsCount, 1) + + model.start() + model.start() + model.start() + + try? await Task.sleep(for: .milliseconds(100)) // subscriptions start asynchronously + await ManualEventPublisher.shared.eventPublisher.send(1) + try? await Task.sleep(for: .milliseconds(100)) // events are sent asynchronously + + XCTAssertEqual(model.manualEventsCount, 2) + } + } diff --git a/Tests/GoodReactorTests/Samples/ManualEventPublisher.swift b/Tests/GoodReactorTests/Samples/ManualEventPublisher.swift new file mode 100644 index 0000000..85afb60 --- /dev/null +++ b/Tests/GoodReactorTests/Samples/ManualEventPublisher.swift @@ -0,0 +1,16 @@ +// +// ManualEventPublisher.swift +// GoodReactor +// +// Created by te075262 on 01/07/2026. +// + +import Foundation +import GoodReactor + +final class ManualEventPublisher: @unchecked Sendable { + + @MainActor static let shared = ManualEventPublisher() + let eventPublisher = GoodReactor.PassthroughPublisher() + +} diff --git a/Tests/GoodReactorTests/Samples/ObservableModel.swift b/Tests/GoodReactorTests/Samples/ObservableModel.swift index 42ca547..67ada40 100644 --- a/Tests/GoodReactorTests/Samples/ObservableModel.swift +++ b/Tests/GoodReactorTests/Samples/ObservableModel.swift @@ -21,6 +21,12 @@ final class EmptyObject {} } map: { Mutation.didChangeTime(seconds: $0) } + + subscribe { + await ManualEventPublisher.shared.eventPublisher + } map: { + Mutation.didReceiveManualEvent(value: $0) + } } typealias Event = GoodReactor.Event @@ -45,6 +51,7 @@ final class EmptyObject {} case didReceiveValue(newValue: Int) case didAddOneWithDelay case doOneHalfOfTwoHundredRuns + case didReceiveManualEvent(value: Int) } // MARK: Destination @@ -60,6 +67,7 @@ final class EmptyObject {} var counter: Int = 9 var time: Int = 0 var object: AnyObject = EmptyObject() + var manualEventsCount: Int = 0 } @@ -159,6 +167,9 @@ final class EmptyObject {} case .mutation(.didChangeTime(let seconds)): state.time = seconds + case .mutation(.didReceiveManualEvent(let value)): + state.manualEventsCount += value + case .destination: break }