Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Sources/OneWay/LoggingOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// OneWay
// The MIT License (MIT)
//
// Copyright (c) 2022-2025 SeungYeop Yeom ( https://github.com/DevYeom ).
//

import Foundation

/// A set of options that determines what information is logged by a ``Store``.
public struct LoggingOptions: OptionSet, Sendable {
public let rawValue: Int

public init(rawValue: Int) {
self.rawValue = rawValue
}

/// Logs all actions sent to the store.
public static let action = LoggingOptions(rawValue: 1 << 0)

/// Logs all state changes in the store.
public static let state = LoggingOptions(rawValue: 1 << 1)

/// Logs all actions and state changes.
public static let all: LoggingOptions = [.action, .state]

/// Disables all logging.
public static let none: LoggingOptions = []
}
56 changes: 56 additions & 0 deletions Sources/OneWay/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
#if canImport(Foundation)
import Foundation
#endif
#if canImport(OSLog)
import OSLog
#endif

/// `Store` is an actor that holds and manages state values.
///
Expand All @@ -32,6 +35,16 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
didSet {
if oldValue != state {
continuation.yield(state)
#if canImport(OSLog) && DEBUG
if loggingOptions.contains(.state) {
let timestamp = Date.now.formatted(iso8601FormatStyle)
logger.debug("""
[\(timestamp)] State changed:
- \(String(describing: oldValue))
+ \(String(describing: self.state))
""")
}
#endif
}
}
}
Expand All @@ -48,6 +61,10 @@ where R.Action: Sendable, R.State: Sendable & Equatable {

private let reducer: R
private let clock: C
#if canImport(OSLog) && DEBUG
private let logger = Logger(subsystem: "com.devyeom.oneway", category: "Store")
#endif
private var loggingOptions = LoggingOptions.none
private let continuation: AsyncStream<State>.Continuation
private var isProcessing: Bool = false
private var actionQueue: [Action] = []
Expand All @@ -63,16 +80,19 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
/// - reducer: The reducer responsible for transitioning the current state to the next
/// state in response to actions.
/// - state: The initial state used to create the store.
/// - loggingOptions: A set of options for logging. Defaults to `none`.
/// - clock: The clock that determines how time-based effects (such as debounce or throttle)
/// are scheduled. Defaults to `ContinuousClock`.
public init(
reducer: @Sendable @autoclosure () -> R,
state: State,
loggingOptions: LoggingOptions = .none,
clock: C = ContinuousClock()
) {
self.initialState = state
self.state = state
self.reducer = reducer()
self.loggingOptions = loggingOptions
self.clock = clock
(states, continuation) = AsyncStream<State>.makeStream()
Task { await bindExternalEffect() }
Expand All @@ -93,6 +113,12 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
isProcessing = true
await Task.yield()
for action in actionQueue {
#if canImport(OSLog) && DEBUG
if loggingOptions.contains(.action) {
let timestamp = Date.now.formatted(iso8601FormatStyle)
logger.debug("[\(timestamp)] Action: \(String(describing: action))")
}
#endif
let effect = reducer.reduce(state: &state, action: action)
let isThrottled = await throttleIfNeeded(for: effect)
if !isThrottled {
Expand All @@ -117,6 +143,26 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
throttleTimestamps.removeAll()
}

/// Sets the logging options for the store to control what information is logged.
///
/// You can use this method to dynamically change the logging behavior of the store after it
/// has been initialized. For example, you might want to enable logging only for certain
/// user interactions or when debugging a specific issue.
///
/// ```swift
/// // Enable logging for both actions and state changes.
/// await store.debug(.all)
///
/// // Disable all logging.
/// await store.debug(.none)
/// ```
///
/// - Parameter loggingOptions: A set of `LoggingOptions` that determines what information
/// is logged.
public func debug(_ loggingOptions: LoggingOptions) {
self.loggingOptions = loggingOptions
}

private func throttleIfNeeded(for effect: AnyEffect<Action>) async -> Bool {
guard case let .throttle(id, interval, latest) = effect.method else {
return false
Expand Down Expand Up @@ -215,3 +261,13 @@ private struct EffectIDWrapper: Hashable, @unchecked Sendable {
self.id = id
}
}

#if canImport(OSLog) && DEBUG
private let iso8601FormatStyle = Date.ISO8601FormatStyle()
.year()
.month()
.day()
.timeZone(separator: .omitted)
.time(includingFractionalSeconds: true)
.timeSeparator(.colon)
#endif
27 changes: 27 additions & 0 deletions Sources/OneWay/ViewStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
await store.reset()
}
}

/// Sets the logging options for the store to control what information is logged.
///
/// You can use this method to dynamically change the logging behavior of the store after it
/// has been initialized. For example, you might want to enable logging only for certain
/// user interactions or when debugging a specific issue.
///
/// ```swift
/// // Enable logging for both actions and state changes.
/// @StateObject private var store = ViewStore(
/// reducer: HomeReducer(),
/// state: HomeReducer.State()
/// )
/// .debug(.all)
///
/// // Disable all logging.
/// store.debug(.none)
/// ```
///
/// - Parameter loggingOptions: A set of `LoggingOptions` that determines what information
/// is logged.
public func debug(_ loggingOptions: LoggingOptions) -> Self {
Task { @MainActor in
await store.debug(loggingOptions)
}
return self
}
}

#if canImport(Combine)
Expand Down
13 changes: 13 additions & 0 deletions Tests/OneWayTests/StoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ final class StoreTests: XCTestCase {
}

func test_sendSeveralActions() async {
await sut.debug(.all)
await sut.send(.increment)
await sut.send(.increment)
await sut.send(.twice)
Expand Down Expand Up @@ -236,6 +237,18 @@ final class StoreTests: XCTestCase {
await clock.advance(by: .seconds(100))
await sut.expect(\.count, 4)
}

func test_logging_options() async {
let all = Store(
reducer: TestReducer(clock: TestClock()),
state: TestReducer.State(count: 0, text: ""),
loggingOptions: .all
)
await all.debug(.all)
await all.debug(.none)
await all.debug(.action)
await all.debug(.state)
}
}

#if canImport(Combine)
Expand Down
11 changes: 11 additions & 0 deletions Tests/OneWayTests/ViewStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,17 @@ final class ViewStoreTests: XCTestCase {
]
)
}

func test_logging_options() async {
let _ = await ViewStore(
reducer: TestReducer(),
state: TestReducer.State(count: 0)
)
.debug(.all)
.debug(.none)
.debug(.action)
.debug(.state)
}
}

private struct TestReducer: Reducer {
Expand Down