Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: custom store implementation #810

Merged
merged 3 commits into from
Sep 12, 2024
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
2 changes: 0 additions & 2 deletions CustomerIOMessagingInApp.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,4 @@ Pod::Spec.new do |spec|
spec.module_name = "CioMessagingInApp" # the `import X` name when using SDK in Swift files

spec.dependency "CustomerIOCommon", "= #{spec.version.to_s}"
# Redux-like implementation for Swift, used for managing in-app message state in MessagingInApp module.
spec.dependency "ReSwift", "= 6.1.1"
end
4 changes: 1 addition & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ let package = Package(

// Make sure the version number is same for DataPipelines cocoapods.
.package(name: "CioAnalytics", url: "https://github.com/customerio/cdp-analytics-swift.git", .exact("1.5.14+cio.1")),
// Redux-like implementation for Swift, used for managing in-app message state in MessagingInApp module.
.package(name: "ReSwift", url: "https://github.com/ReSwift/ReSwift.git", .exact("6.1.1")),
],
targets: [
// Common - Code used by multiple modules in the SDK project.
Expand Down Expand Up @@ -115,7 +113,7 @@ let package = Package(

// Messaging in-app
.target(name: "CioMessagingInApp",
dependencies: ["CioInternalCommon", .product(name: "ReSwift", package: "ReSwift")],
dependencies: ["CioInternalCommon"],
path: "Sources/MessagingInApp",
resources: [
.process("Resources/PrivacyInfo.xcprivacy"),
Expand Down
21 changes: 1 addition & 20 deletions Sources/MessagingInApp/State/Aliases.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import CioInternalCommon
import Foundation
import ReSwift

// This file contains typealias for InAppMessage module which are used to decouple the module from ReSwift library
// by leveraging custom types. This allows for easier testing and swapping out the ReSwift library in the future.
Expand All @@ -14,6 +13,7 @@ typealias InAppMessageReducer = Reducer<InAppMessageState>

// Middleware function alias for InAppMessage store with custom defined types
typealias InAppMessageMiddleware = Middleware<InAppMessageState>

/// Middleware completion closure
/// - Parameters:
/// - dispatch: Dispatch function to dispatch actions
Expand All @@ -27,25 +27,6 @@ typealias MiddlewareCompletion = (
InAppMessageAction
) -> Void

/// Helper function to create middleware for InAppMessage module
/// - Parameter completion: A closure that takes in the necessary parameters to perform the middleware logic
/// - Returns: Middleware function with given completion closure
func middleware(
completion: @escaping MiddlewareCompletion
) -> Middleware<InAppMessageState> {
{ dispatch, getState in { next in { action in
guard let inAppAction = action as? InAppMessageAction else {
DIGraphShared.shared.logger.logWithModuleTag("Invalid action type: \(action), skipping middleware", level: .debug)
return next(action)
}

let getStateOrDefault = { getState() ?? InAppMessageState() }
completion(dispatch, getStateOrDefault, next, inAppAction)
}
}
}
}

// MARK: - StoreSubscriber

/// StoreSubscriber implementation for InAppMessage store with custom defined types
Expand Down
36 changes: 36 additions & 0 deletions Sources/MessagingInApp/State/Core/Assertions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// swiftlint:disable:next file_header
//
// Assertions
// Copyright © 2015 mohamede1945. All rights reserved.
// https://github.com/mohamede1945/AssertionsTestingExample
//

import Foundation

/// drop-in fatalError replacement for testing

/**
Swift.fatalError wrapper for catching in tests

- parameter message: Message to be wrapped
- parameter file: Calling file
- parameter line: Calling line
*/
func raiseFatalError(
_ message: @autoclosure () -> String = "",
file: StaticString = #file,
line: UInt = #line
) -> Never {
Assertions.fatalErrorClosure(message(), file, line)
repeat {
RunLoop.current.run()
} while true
}

/// Stores custom assertions closures, by default it points to Swift functions. But test target can
/// override them.
enum Assertions {
static var fatalErrorClosure = swiftFatalErrorClosure
static let swiftFatalErrorClosure: (String, StaticString, UInt) -> Void
= { Swift.fatalError($0, file: $1, line: $2) }
}
16 changes: 16 additions & 0 deletions Sources/MessagingInApp/State/Core/Middleware.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// swiftlint:disable:next file_header
//
// Middleware.swift
// ReSwift
//
// Created by Benji Encz on 12/24/15.
// Copyright © 2015 ReSwift Community. All rights reserved.
//
// Modifications made:
// - Replaced Action with InAppMessageAction from Customer.io.
// - Updated visibility to internal to prevent exposing non-public types.
//

typealias DispatchFunction = (InAppMessageAction) -> Void
typealias Middleware<State> = (@escaping DispatchFunction, @escaping () -> State?)
-> (@escaping DispatchFunction) -> DispatchFunction
15 changes: 15 additions & 0 deletions Sources/MessagingInApp/State/Core/Reducer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// swiftlint:disable:next file_header
//
// Reducer.swift
// ReSwift
//
// Created by Benjamin Encz on 12/14/15.
// Copyright © 2015 ReSwift Community. All rights reserved.
//
// Modifications made:
// - Replaced Action with InAppMessageAction from Customer.io.
// - Updated visibility to internal to prevent exposing non-public types.
//

typealias Reducer<ReducerStateType> =
(_ action: InAppMessageAction, _ state: ReducerStateType?) -> ReducerStateType
168 changes: 168 additions & 0 deletions Sources/MessagingInApp/State/Core/Store.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// swiftlint:disable:next file_header
//
// Store.swift
// ReSwift
//
// Created by Benjamin Encz on 11/11/15.
// Copyright © 2015 ReSwift Community. All rights reserved.
//
// Modifications made:
// - Replaced Action with InAppMessageAction from Customer.io.
// - Constrained State to Equatable to simplify state comparison as InAppMessageState is Equatable.
// - Updated visibility to internal to prevent exposing non-public types.
// - Updated initializer to require non-optional initial State to avoid dispatching dummy init action.
// - Removed subscriptionsAutomaticallySkipRepeats as it will always be true.
// - Simplified subscription handling by removing unused subscription options.
// - Removed unused functions.
//

/**
This class is the default implementation of the `StoreType` protocol. You will use this store in most
of your applications. You shouldn't need to implement your own store.
You initialize the store with a reducer and an initial application state. If your app has multiple
reducers you can combine them by initializing a `MainReducer` with all of your reducers as an
argument.
*/
class Store<State: Equatable> {
typealias SubscriptionType = SubscriptionBox<State>

public private(set) var state: State! {
didSet {
subscriptions.forEach {
if $0.subscriber == nil {
subscriptions.remove($0)
} else {
$0.newValues(oldState: oldValue, newState: state)
}
}
}
}

public lazy var dispatchFunction: DispatchFunction! = createDispatchFunction()

private var reducer: Reducer<State>

var subscriptions: Set<SubscriptionType> = []

private var isDispatching = Synchronized<Bool>(false)

public var middleware: [Middleware<State>] {
didSet {
dispatchFunction = createDispatchFunction()
}
}

/// Initializes the store with a reducer, an initial state and a list of middleware.
///
/// Middleware is applied in the order in which it is passed into this constructor.
///
/// - parameter reducer: Main reducer that processes incoming actions.
/// - parameter state: Initial state, if any. Can be `nil` and will be
/// provided by the reducer in that case.
/// - parameter middleware: Ordered list of action pre-processors, acting
/// before the root reducer.
/// - parameter automaticallySkipsRepeats: If `true`, the store will attempt
/// to skip idempotent state updates when a subscriber's state type
/// implements `Equatable`. Defaults to `true`.
public required init(
reducer: @escaping Reducer<State>,
state: State,
middleware: [Middleware<State>] = []
) {
self.reducer = reducer
self.middleware = middleware
self.state = state
}

private func createDispatchFunction() -> DispatchFunction! {
// Wrap the dispatch function with all middlewares
middleware
.reversed()
.reduce({ [unowned self] action in
_defaultDispatch(action: action) }, { dispatchFunction, middleware in
// If the store get's deinitialized before the middleware is complete; drop
// the action without dispatching.
let dispatch: (InAppMessageAction) -> Void = { [weak self] in self?.dispatch($0) }
let getState: () -> State? = { [weak self] in self?.state }
return middleware(dispatch, getState)(dispatchFunction)
}
)
}

private func _subscribe<S: StoreSubscriber>(
_ subscriber: S,
originalSubscription: Subscription<State>,
transformedSubscription: Subscription<State>?
) where S.StoreSubscriberStateType == State {
let subscriptionBox = self.subscriptionBox(
originalSubscription: originalSubscription,
transformedSubscription: transformedSubscription,
subscriber: subscriber
)

subscriptions.update(with: subscriptionBox)

if let state = state {
originalSubscription.newValues(oldState: nil, newState: state)
}
}

public func subscribe<S: StoreSubscriber>(
_ subscriber: S,
transform: ((Subscription<State>) -> Subscription<State>) = { $0.skipRepeats() }
) where S.StoreSubscriberStateType == State {
let originalSubscription = Subscription<State>()

_subscribe(
subscriber,
originalSubscription: originalSubscription,
transformedSubscription: transform(originalSubscription)
)
}

func subscriptionBox<T>(
originalSubscription: Subscription<State>,
transformedSubscription: Subscription<T>?,
subscriber: AnyStoreSubscriber
) -> SubscriptionBox<State> {
SubscriptionBox(
originalSubscription: originalSubscription,
transformedSubscription: transformedSubscription,
subscriber: subscriber
)
}

func unsubscribe(_ subscriber: AnyStoreSubscriber) {
#if swift(>=5.0)
if let index = subscriptions.firstIndex(where: { $0.subscriber === subscriber }) {
subscriptions.remove(at: index)
}
#else
if let index = subscriptions.index(where: { $0.subscriber === subscriber }) {
subscriptions.remove(at: index)
}
#endif
}

// swiftlint:disable:next identifier_name
func _defaultDispatch(action: InAppMessageAction) {
guard !isDispatching.value else {
raiseFatalError(
"ReSwift:ConcurrentMutationError- Action has been dispatched while" +
" a previous action is being processed. A reducer" +
" is dispatching an action, or ReSwift is used in a concurrent context" +
" (e.g. from multiple threads). Action: \(action)"
)
}

isDispatching.value { $0 = true }
let newState = reducer(action, state)
isDispatching.value { $0 = false }

state = newState
}

open func dispatch(_ action: InAppMessageAction) {
dispatchFunction(action)
}
}
28 changes: 28 additions & 0 deletions Sources/MessagingInApp/State/Core/StoreSubscriber.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// swiftlint:disable:next file_header
//
// StoreSubscriber.swift
// ReSwift
//
// Created by Benjamin Encz on 12/14/15.
// Copyright © 2015 ReSwift Community. All rights reserved.
//

public protocol AnyStoreSubscriber: AnyObject {
// swiftlint:disable:next identifier_name
func _newState(state: Any)
}

public protocol StoreSubscriber: AnyStoreSubscriber {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might not be needed as we creating the same thing in alias.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To keep store generic and copy tests, it will be needed

associatedtype StoreSubscriberStateType

func newState(state: StoreSubscriberStateType)
}

public extension StoreSubscriber {
// swiftlint:disable:next identifier_name
func _newState(state: Any) {
if let typedState = state as? StoreSubscriberStateType {
newState(state: typedState)
}
}
}
Loading
Loading