Skip to content

Commit

Permalink
Add StoreMap (#424)
Browse files Browse the repository at this point in the history
  • Loading branch information
muukii authored Oct 9, 2023
1 parent b7c6855 commit 36306ef
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 1 deletion.
124 changes: 124 additions & 0 deletions Sources/Verge/Library/StoreMap.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@

/**
Against Derived, StoreMap won't retain the value from the store.
sink function works with store directly.
state property retrieves the value from state of the store by mapping.
*/
public final class StoreMap<Store: StoreType, Mapped: Equatable>: Sendable {

public typealias State = Store.State

public let store: Store

private let _map: @Sendable (Store.State) -> Mapped

public var state: Changes<Mapped> {
store.state.map(_map)
}

public init(store: Store, map: @escaping @Sendable (borrowing Store.State) -> Mapped) {
self.store = store
self._map = map
}

}

// MARK: Implementations
extension StoreMap {

/**
Start subscribing state updates in receive closure.
It skips publishing values if the mapped value is not changed.
*/
@_disfavoredOverload
public func sinkState(
dropsFirst: Bool = false,
queue: MainActorTargetQueue = .mainIsolated(),
receive: @escaping @MainActor (Changes<Mapped>) -> Void
) -> StoreSubscription {

let subscription = store.asStore()
.sinkState(
dropsFirst: dropsFirst,
queue: queue,
receive: { [_map] state in

let mapped = state
.map(_map)

mapped.ifChanged().do { _ in
receive(mapped)
}

}
)

_ = subscription.associate(object: self)

return subscription
}

/**
Start subscribing state updates in receive closure.
It skips publishing values if the mapped value is not changed.
*/
public func sinkState(
dropsFirst: Bool = false,
queue: some TargetQueueType,
receive: @escaping (Changes<Mapped>) -> Void
) -> StoreSubscription {

let subscription = store.asStore()
.sinkState(
dropsFirst: dropsFirst,
queue: queue,
receive: { [_map] state in

let mapped = state
.map(_map)

mapped.ifChanged().do {
receive(mapped)
}

}
)

_ = subscription.associate(object: self)

return subscription
}

/**
Assigns a Store's state to a property of a store.

- Returns: a cancellable. See detail of handling cancellable from ``StoreSubscription``'s docs
*/
public func assign(
queue: some TargetQueueType = .passthrough,
to binder: @escaping (Changes<State>) -> Void
) -> StoreSubscription {
store.asStore().sinkState(queue: queue, receive: binder)
}

/**
Assigns a Store's state to a property of a store.

- Returns: a cancellable. See detail of handling cancellable from ``StoreSubscription``'s docs
*/
public func assign(
queue: MainActorTargetQueue,
to binder: @escaping (Changes<State>) -> Void
) -> StoreSubscription {
store.asStore().sinkState(queue: queue, receive: binder)
}

}

extension StoreType {

public func map<Mapped>(_ map: @escaping @Sendable (State) -> Mapped) -> StoreMap<Self, Mapped> {
.init(store: self, map: map)
}

}
7 changes: 7 additions & 0 deletions Sources/Verge/Library/StoreSubscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public final class StoreSubscription: Hashable, Cancellable {
private let source: EventEmitterCancellable
private weak var storeCancellable: VergeAnyCancellable?
private var associatedStore: (any StoreType)?
private var associatedReferences: [AnyObject] = []

init(
_ eventEmitterCancellable: EventEmitterCancellable,
Expand All @@ -43,6 +44,12 @@ public final class StoreSubscription: Hashable, Cancellable {
return self
}

func associate(object: AnyObject) -> StoreSubscription {
ensureAlive()
associatedReferences.append(object)
return self
}

/**
Make this subscription alive while the source is active.
the source means a root data store which is Store.
Expand Down
2 changes: 1 addition & 1 deletion Sources/Verge/Store/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import Combine
#endif

/// A protocol that indicates itself is a reference-type and can convert to concrete Store type.
public protocol StoreType<State>: AnyObject, ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher {
public protocol StoreType<State>: AnyObject, Sendable, ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher {
associatedtype State: Equatable
associatedtype Activity = Never

Expand Down
70 changes: 70 additions & 0 deletions Tests/VergeTests/StoreAndDerivedTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Verge
import XCTest

final class StoreAndDerivedTests: XCTestCase {

@MainActor
func test() async {

let store = Store<_, Never>(initialState: DemoState())

let nameDerived = store.derived(.select(\.name))
let countDerived = store.derived(.select(\.count))

let countMap = store.map(\.count)

await withTaskGroup(of: Void.self) { group in

for i in 0..<1000 {
group.addTask {
await withBackground {
store.commit {
$0.name = "\(i)"
}
}

}
}

group.addTask {
await withBackground {
store.commit {
$0.count = 100
}
}

XCTAssertEqual(store.state.count, 100)
XCTAssertEqual(countMap.state.primitive, 100)

// potentially it fails as EventEmitter's behavior
// If EventEmitter's buffer is not empty, commit function escape from the stack by only adding.
// XCTAssertEqual(countDerived.state.primitive, 100)
XCTAssertNotEqual(countDerived.state.primitive, 100)
}

}

print("end")

}

}

/**
Performs the given task in background
*/
public nonisolated func withBackground<Return: Sendable>(
_ thunk: @escaping @Sendable () async throws -> Return
) async rethrows -> Return {

// for now we will keep this until Swift6.
assert(Thread.isMainThread == false)

// here is the background as it's nonisolated
// to inherit current actor context, use @_unsafeInheritExecutor

// thunk closure runs on the background as it's sendable
// if it's not sendable, inherit current actor context but it's already background.
// @_inheritActorContext makes closure runs on current actor context even if it's sendable.
return try await thunk()
}
36 changes: 36 additions & 0 deletions Tests/VergeTests/StoreMapTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Verge
import XCTest

final class StoreMapTests: XCTestCase {

@MainActor
func testBasic() {

let store = Store<DemoState, Never>(initialState: .init())

let countState = store.map(\.count)

var exp = expectation(description: "")
exp.expectedFulfillmentCount = 2

let s = countState.sinkState { state in
exp.fulfill()
}

// cause update
store.commit {
$0.count += 1
}

// won't cause update
store.commit {
$0.count = 1
}

wait(for: [exp])

withExtendedLifetime(s, {})

}

}

0 comments on commit 36306ef

Please sign in to comment.