Skip to content

Commit e6f5b50

Browse files
belleklaviyoclaude
andcommitted
feat(KlaviyoSwift): wire KlaviyoInternal into IdentityStore and SDKConfigStore (MAGE-749)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f0dfa2 commit e6f5b50

3 files changed

Lines changed: 92 additions & 6 deletions

File tree

Sources/KlaviyoSwift/Klaviyo.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public struct KlaviyoSDK {
6363
/// - Returns: a KlaviyoSDK instance
6464
@discardableResult
6565
public func initialize(with apiKey: String) -> KlaviyoSDK {
66+
KlaviyoInternal.setupSharedStores()
6667
dispatchOnMainThread(action: .initialize(apiKey))
6768
return self
6869
}

Sources/KlaviyoSwift/KlaviyoInternal.swift

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ package enum KlaviyoInternal {
2929
private static var apiKeyCancellable: Cancellable?
3030
private static let apiKeySubject = CurrentValueSubject<APIKeyResult, Never>(.failure(.notInitialized))
3131

32+
private static var sharedStoresCancellable: Cancellable?
33+
3234
private static let profileEventSubject = PassthroughSubject<Event, Never>()
3335
private static var profileEventCancellable: Cancellable?
3436
private static let eventBuffer = EventBuffer(maxBufferSize: 10, maxBufferAge: 10)
@@ -109,17 +111,28 @@ package enum KlaviyoInternal {
109111
return .failure(.notInitialized)
110112
}
111113

112-
return .success(ProfileData(
113-
email: state.email,
114-
phoneNumber: state.phoneNumber,
115-
externalId: state.externalId,
116-
anonymousId: state.anonymousId
117-
))
114+
return .success(state.identity)
118115
}
119116
.removeDuplicates()
120117
.subscribe(profileDataSubject)
121118
}
122119

120+
/// Mirrors initialized SDK state into the shared `KlaviyoCore` stores so other modules
121+
/// (Forms, Location) can observe identity and API key without importing `KlaviyoSwift`.
122+
package static func setupSharedStores() {
123+
guard sharedStoresCancellable == nil else { return }
124+
sharedStoresCancellable = klaviyoSwiftEnvironment.statePublisher()
125+
.filter { $0.initalizationState == .initialized }
126+
.map { (identity: $0.identity, apiKey: $0.apiKey) }
127+
.removeDuplicates(by: { $0.identity == $1.identity && $0.apiKey == $1.apiKey })
128+
// TCA dispatches state changes on the main thread, so these two sequential
129+
// store writes are observed together rather than torn.
130+
.sink { identity, apiKey in
131+
IdentityStore.shared.update(identity)
132+
SDKConfigStore.shared.update(KlaviyoConfig(apiKey: apiKey))
133+
}
134+
}
135+
123136
/// Fetches the current profile data once.
124137
///
125138
/// - Returns: The current profile data, if available.
@@ -154,6 +167,11 @@ package enum KlaviyoInternal {
154167
profileDataCancellable?.cancel()
155168
profileDataCancellable = nil
156169
profileDataSubject.send(.failure(.notInitialized))
170+
sharedStoresCancellable?.cancel()
171+
sharedStoresCancellable = nil
172+
// Clear the shared stores so consumers don't read stale identity/config after reset.
173+
IdentityStore.shared.update(ProfileData())
174+
SDKConfigStore.shared.update(KlaviyoConfig())
157175
}
158176

159177
// MARK: - Profile Event methods

Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ final class KlaviyoInternalTests: XCTestCase {
2626
KlaviyoInternal.resetProfileDataSubject()
2727
KlaviyoInternal.resetEventSubject()
2828
KlaviyoInternal.clearEventBuffer()
29+
IdentityStore.shared.update(ProfileData())
30+
SDKConfigStore.shared.update(KlaviyoConfig())
2931
}
3032

3133
// MARK: - Profile Data Tests
@@ -806,6 +808,71 @@ final class KlaviyoInternalTests: XCTestCase {
806808
XCTAssertEqual(currentState.queue.count, 0, "Queue should remain empty")
807809
}
808810

811+
// MARK: - Shared store wiring
812+
813+
@MainActor
814+
func testSetupSharedStoresPushesIdentityOnChange() throws {
815+
let testStore = Store(initialState: .test, reducer: KlaviyoReducer())
816+
klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() }
817+
818+
KlaviyoInternal.setupSharedStores()
819+
820+
_ = testStore.send(.setEmail("wired@example.com"))
821+
822+
let expectation = XCTestExpectation(description: "IdentityStore reflects email")
823+
DispatchQueue.main.async {
824+
XCTAssertEqual(IdentityStore.shared.current.email, "wired@example.com")
825+
expectation.fulfill()
826+
}
827+
wait(for: [expectation], timeout: 1.0)
828+
}
829+
830+
@MainActor
831+
func testSetupSharedStoresPushesAPIKeyOnChange() throws {
832+
// `.test` state is already `.initialized` with apiKey "foo".
833+
let testStore = Store(initialState: .test, reducer: KlaviyoReducer())
834+
klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() }
835+
836+
let expectation = XCTestExpectation(description: "SDKConfigStore reflects api key")
837+
SDKConfigStore.shared.publisher
838+
.dropFirst() // skip the CurrentValueSubject's initial emission
839+
.sink { config in
840+
if config.apiKey == "foo" { expectation.fulfill() }
841+
}
842+
.store(in: &cancellables)
843+
844+
KlaviyoInternal.setupSharedStores()
845+
846+
wait(for: [expectation], timeout: 1.0)
847+
XCTAssertEqual(SDKConfigStore.shared.current.apiKey, "foo")
848+
}
849+
850+
@MainActor
851+
func testResetClearsSharedStores() throws {
852+
// `.test` state is `.initialized` with apiKey "foo"; mirror it into the stores.
853+
let testStore = Store(initialState: .test, reducer: KlaviyoReducer())
854+
klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() }
855+
856+
KlaviyoInternal.setupSharedStores()
857+
_ = testStore.send(.setEmail("wired@example.com"))
858+
859+
// Confirm the stores hold real data before resetting.
860+
let propagated = XCTestExpectation(description: "identity mirrored before reset")
861+
DispatchQueue.main.async {
862+
XCTAssertEqual(IdentityStore.shared.current.email, "wired@example.com")
863+
XCTAssertEqual(SDKConfigStore.shared.current.apiKey, "foo")
864+
propagated.fulfill()
865+
}
866+
wait(for: [propagated], timeout: 1.0)
867+
868+
KlaviyoInternal.resetProfileDataSubject()
869+
870+
XCTAssertEqual(IdentityStore.shared.current, ProfileData(),
871+
"reset must clear identity back to empty")
872+
XCTAssertNil(SDKConfigStore.shared.current.apiKey,
873+
"reset must clear the API key")
874+
}
875+
809876
@MainActor
810877
func testCreateGeofenceEvent_enqueuesEventWhenAPIKeyMatches() async throws {
811878
// Given: SDK is initialized with matching API key

0 commit comments

Comments
 (0)