Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
20 changes: 19 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/CombineCommunity/CombineExt.git", from: "1.8.1"),
.package(url: "https://github.com/apple/swift-async-algorithms.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.1.3"))
.package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.1.3")),
.package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.4.0")
],
targets: [
.target(
Expand Down
36 changes: 24 additions & 12 deletions Tests/GoodReactorTests/AnyReactorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,17 @@ final class AnyReactorTests: XCTestCase {
XCTAssertEqual(model.counter, 9)

for _ in 0..<10 {
await model.send(action: .debounceTest) // send event 10x in a second
try? await Task.sleep(for: .milliseconds(100))
await model.send(action: .debounceTest) // all sends fall within the 500 ms debounce window
}

XCTAssertEqual(model.counter, 9) // event should be waiting in debouncer

await waitUntil("Debounced event did not fire") { model.counter == 10 }

// negative check: give any spurious extra debounce fires time to land
try? await Task.sleep(for: .seconds(1))

XCTAssertEqual(model.counter, 10) // event should be debounced by now
XCTAssertEqual(model.counter, 10, "Debounced event fired more than once")
}

@MainActor func testBinding() {
Expand All @@ -144,27 +146,37 @@ final class AnyReactorTests: XCTestCase {
}

@MainActor func testReactorStartIdempontency() async {
let model = AnyReactor(ObservableModel())
let base = ObservableModel()
let model = AnyReactor(base)
let publisher = base.manualEventPublisher

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
await waitUntil("Subscription was not created after start()") {
await publisher.activeSubscriberCount == 1
}

XCTAssertEqual(model.manualEventsCount, 1)
// start() is forwarded to the wrapped reactor, so subscriptions
// are stored under the base reactor and registered synchronously
let subscriptionCount = MapTables.subscriptions.value(forKey: base)?.count ?? 0
XCTAssertGreaterThan(subscriptionCount, 0)

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(MapTables.subscriptions.value(forKey: base)?.count, subscriptionCount)

await publisher.send(1)
await waitUntil("Manual event was not delivered") { model.manualEventsCount == 1 }

await publisher.send(1)
await waitUntil("Manual event was not delivered") { model.manualEventsCount == 2 }

XCTAssertEqual(model.manualEventsCount, 2)
let subscriberCount = await publisher.activeSubscriberCount
XCTAssertEqual(subscriberCount, 1, "Duplicate subscriptions were created")
}

}
35 changes: 23 additions & 12 deletions Tests/GoodReactorTests/GoodReactorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,17 @@ final class GoodReactorTests: XCTestCase {
XCTAssertEqual(model.counter, 9)

for _ in 0..<10 {
await model.send(action: .debounceTest) // send event 10x in a second
try? await Task.sleep(for: .milliseconds(100))
await model.send(action: .debounceTest) // all sends fall within the 500 ms debounce window
}

XCTAssertEqual(model.counter, 9) // event should be waiting in debouncer

await waitUntil("Debounced event did not fire") { model.counter == 10 }

// negative check: give any spurious extra debounce fires time to land
try? await Task.sleep(for: .seconds(1))

XCTAssertEqual(model.counter, 10) // event should be debounced by now
XCTAssertEqual(model.counter, 10, "Debounced event fired more than once")
}

@MainActor func testBinding() {
Expand All @@ -142,26 +144,35 @@ final class GoodReactorTests: XCTestCase {

@MainActor func testReactorStartIdempontency() async {
let model = ObservableModel()
let publisher = model.manualEventPublisher

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)
await waitUntil("Subscription was not created after start()") {
await publisher.activeSubscriberCount == 1
}

let subscriptionCount = MapTables.subscriptions.value(forKey: model)?.count ?? 0
XCTAssertGreaterThan(subscriptionCount, 0)

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
// subscription tasks are registered synchronously in start(),
// so duplicates would be visible immediately
XCTAssertEqual(MapTables.subscriptions.value(forKey: model)?.count, subscriptionCount)

await publisher.send(1)
await waitUntil("Manual event was not delivered") { model.manualEventsCount == 1 }

await publisher.send(1)
await waitUntil("Manual event was not delivered") { model.manualEventsCount == 2 }

XCTAssertEqual(model.manualEventsCount, 2)
let subscriberCount = await publisher.activeSubscriberCount
XCTAssertEqual(subscriberCount, 1, "Duplicate subscriptions were created")
}

}
49 changes: 49 additions & 0 deletions Tests/GoodReactorTests/Helpers/TestWaiting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// TestWaiting.swift
// GoodReactor
//
// Created by Andrej Jasso on 04/07/2026.
//

import XCTest
@testable import GoodReactor

extension XCTestCase {

/// Polls `condition` until it evaluates to `true` or `timeout` elapses.
///
/// Use instead of fixed `Task.sleep` delays: passes as soon as the condition
/// holds (fast on fast machines) and only fails after the full timeout
/// (robust on slow CI runners).
@MainActor func waitUntil(
timeout: Duration = .seconds(5),
_ message: @autoclosure () -> String = "Condition not met within timeout",
condition: @MainActor () async -> Bool,
file: StaticString = #filePath,
line: UInt = #line
) async {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)

while clock.now < deadline {
if await condition() { return }
try? await Task.sleep(for: .milliseconds(10))
}

if await condition() { return }
XCTFail(message(), file: file, line: line)
}

}

extension PassthroughPublisher {

/// Number of live subscribers currently connected to this publisher.
/// Test-only introspection used to deterministically wait for
/// asynchronous subscription setup and teardown.
var activeSubscriberCount: Int {
subscribers.removeNils()
return subscribers.count
}

}
88 changes: 88 additions & 0 deletions Tests/GoodReactorTests/MemoryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// MemoryTests.swift
// GoodReactor
//
// Created by Andrej Jasso on 04/07/2026.
//
// Covers https://github.com/GoodRequest/GoodReactor/issues/13

import XCTest
@testable import GoodReactor

@available(iOS 17.0, macOS 14.0, *)
final class MemoryTests: XCTestCase {

/// A started reactor must deallocate once the last strong reference is dropped —
/// external subscriptions must not keep it alive.
@MainActor func testStartedReactorDeallocates() async {
weak var weakModel: ObservableModel?

do {
let model = ObservableModel()
model.start()
await model.send(action: .addOne)
XCTAssertEqual(model.counter, 10)
weakModel = model
}

await waitUntil("Started reactor leaked") { weakModel == nil }
}

/// A type-erased reactor and its wrapped base must both deallocate.
@MainActor func testAnyReactorDeallocates() async {
weak var weakBase: ObservableModel?
weak var weakModel: AnyReactor<ObservableModel.Action, ObservableModel.Destination, ObservableModel.State>?

do {
let base = ObservableModel()
let model = AnyReactor(base)
model.start()
await model.send(action: .addOne)
XCTAssertEqual(model.counter, 10)
weakBase = base
weakModel = model
}

await waitUntil("AnyReactor leaked") { weakModel == nil }
await waitUntil("Wrapped base reactor leaked") { weakBase == nil }
}

/// When a started reactor deallocates, its subscription tasks must be
/// cancelled and the subscribers disconnected from the publisher.
@MainActor func testSubscriptionsAreReleasedAfterDealloc() async {
var model: ObservableModel? = ObservableModel()
let publisher = model!.manualEventPublisher

model?.start()

await waitUntil("Subscription was not created after start()") {
await publisher.activeSubscriberCount == 1
}

model = nil

await waitUntil("Subscriber was not released after reactor deallocated") {
await publisher.activeSubscriberCount == 0
}

// sending to a publisher after its only subscriber is gone must be safe
await publisher.send(1)
}

/// A reactor referenced by an in-flight event handler is kept alive only
/// until the event finishes, then deallocates.
@MainActor func testReactorDeallocatesAfterRunningEventFinishes() async {
weak var weakModel: ObservableModel?

do {
let model = ObservableModel()
weakModel = model
Task { await model.send(action: .resetToZero) } // async handler runs for ~1 s
}

XCTAssertNotNil(weakModel, "Reactor deallocated while an event was still running")

await waitUntil("Reactor leaked after running event finished") { weakModel == nil }
}

}
16 changes: 0 additions & 16 deletions Tests/GoodReactorTests/Samples/ManualEventPublisher.swift

This file was deleted.

7 changes: 6 additions & 1 deletion Tests/GoodReactorTests/Samples/ObservableModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@ final class EmptyObject {}
@available(iOS 17.0, *)
@Observable final class ObservableModel: Reactor {

@ObservationIgnored let manualEventPublisher = PassthroughPublisher<Int>()

func transform() {
subscribe {
await ExternalTimer.shared.timePublisher
} map: {
Mutation.didChangeTime(seconds: $0)
}

// captured as a local so the subscription task does not retain self
let manualEventPublisher = self.manualEventPublisher

subscribe {
await ManualEventPublisher.shared.eventPublisher
manualEventPublisher
} map: {
Mutation.didReceiveManualEvent(value: $0)
}
Expand Down
9 changes: 9 additions & 0 deletions docs/css/39.cc2d61d1.css

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/css/989.4f123103.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 0 additions & 9 deletions docs/css/documentation-topic.3bca6578.css

This file was deleted.

9 changes: 9 additions & 0 deletions docs/css/documentation-topic.b031fba4.css

Large diffs are not rendered by default.

This file was deleted.

9 changes: 0 additions & 9 deletions docs/css/index.12bb178a.css

This file was deleted.

9 changes: 9 additions & 0 deletions docs/css/index.d0b63544.css

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/css/topic.59e2bdb7.css

Large diffs are not rendered by default.

9 changes: 0 additions & 9 deletions docs/css/topic.ee15af52.css

This file was deleted.

9 changes: 0 additions & 9 deletions docs/css/tutorials-overview.06e8bcf7.css

This file was deleted.

9 changes: 9 additions & 0 deletions docs/css/tutorials-overview.9c2b2457.css

Large diffs are not rendered by default.

Loading