From 4cb388818986ea6e5aaa3b888c025765e2ec3549 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 6 Oct 2026 10:13:53 -0400 Subject: [PATCH 1/4] chore!: pass the reload result to reloadFeatureFlags' callback reloadFeatureFlags(_:) now hands its callback a PostHogFeatureFlagsLoaded, the same type onFeatureFlags receives, so callers can tell a failed reload from a successful one. errorsLoading is true when the request failed or the SDK isn't set up; flags and variants then hold the last known values. Part of #557. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-reload-flags-result.md | 7 ++ PostHog/PostHogSDK.swift | 32 ++++-- PostHogExample/ContentView.swift | 2 +- PostHogObjCExample/AppDelegate.m | 2 +- .../PostHogDeviceBucketingTests.swift | 4 +- PostHogTests/PostHogEventSnapshotTests.swift | 2 +- PostHogTests/PostHogFeatureFlagsTest.swift | 100 +++++++++++------- PostHogTests/PostHogGeoIpTest.swift | 4 +- PostHogTests/PostHogSDKTest.swift | 15 ++- api/posthog-ios.public-api.txt | 2 +- 10 files changed, 109 insertions(+), 61 deletions(-) create mode 100644 .changeset/v4-reload-flags-result.md diff --git a/.changeset/v4-reload-flags-result.md b/.changeset/v4-reload-flags-result.md new file mode 100644 index 000000000..798bdff41 --- /dev/null +++ b/.changeset/v4-reload-flags-result.md @@ -0,0 +1,7 @@ +--- +'posthog-ios': major +--- + +**Breaking:** `reloadFeatureFlags(_:)` now passes a `PostHogFeatureFlagsLoaded` to its callback, so you can tell whether the reload failed. + +`errorsLoading` is `true` when the request failed or the SDK isn't set up, and `flags` and `variants` then hold the last known values. Update closures from `{ ... }` to `{ result in ... }` (or `{ _ in ... }`). In Objective-C, the block now takes a `PostHogFeatureFlagsLoaded *` argument. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index cb1d99d0b..589e806f7 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2303,29 +2303,43 @@ let maxRetryDelay = 30.0 /// Reloads feature flags for the current user and group context. @objc public func reloadFeatureFlags() { - reloadFeatureFlags { + reloadFeatureFlags { _ in // No use case } } - /// Reloads feature flags and invokes a callback when finished. + /// Reloads feature flags and invokes a callback with the result when finished. /// - /// - Parameter callback: Invoked when the reload finishes, or immediately if the reload - /// is skipped (SDK disabled/opted-out, or no remote config available). + /// ```swift + /// PostHogSDK.shared.reloadFeatureFlags { result in + /// if result.errorsLoading { + /// // The reload failed. result.variants holds the last known flags. + /// } + /// } + /// ``` + /// + /// - Parameter callback: Invoked when the reload finishes, possibly on a background thread. + /// If the request fails, ``PostHogFeatureFlagsLoaded/errorsLoading`` is `true` and the flags are + /// the last known ones. If the reload is skipped because the SDK isn't set up, it's invoked + /// right away with no flags and `errorsLoading` set to `true`. @objc(reloadFeatureFlagsWithCallback:) - public func reloadFeatureFlags(_ callback: @escaping () -> Void) { + public func reloadFeatureFlags(_ callback: @escaping (PostHogFeatureFlagsLoaded) -> Void) { if !isEnabled() { - callback() + callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true)) return } guard let remoteConfig else { - callback() + callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true)) return } - remoteConfig.reloadFeatureFlags { _ in - callback() + // nil means the request failed; report the last known flags, like onFeatureFlags. + remoteConfig.reloadFeatureFlags { featureFlags in + callback(PostHogFeatureFlagsLoaded( + featureFlags: featureFlags ?? remoteConfig.getFeatureFlags() ?? [:], + errorsLoading: featureFlags == nil + )) } } diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 9229609c3..cdb969a0b 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -55,7 +55,7 @@ class FeatureFlagsModel: ObservableObject { func reload() { isReloading = true - PostHogSDK.shared.reloadFeatureFlags { + PostHogSDK.shared.reloadFeatureFlags { _ in self.isReloading = false } } diff --git a/PostHogObjCExample/AppDelegate.m b/PostHogObjCExample/AppDelegate.m index 533514a4e..3df93236d 100644 --- a/PostHogObjCExample/AppDelegate.m +++ b/PostHogObjCExample/AppDelegate.m @@ -94,7 +94,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( NSLog(@"getFeatureFlagResult payload: %@", [[PostHogSDK shared] getFeatureFlagResult:@"myFlag"].payload); [[PostHogSDK shared] reloadFeatureFlags]; - [[PostHogSDK shared] reloadFeatureFlagsWithCallback:^(){ + [[PostHogSDK shared] reloadFeatureFlagsWithCallback:^(PostHogFeatureFlagsLoaded *result){ NSLog(@"called"); }]; diff --git a/PostHogTests/PostHogDeviceBucketingTests.swift b/PostHogTests/PostHogDeviceBucketingTests.swift index da8d55138..65aadbff5 100644 --- a/PostHogTests/PostHogDeviceBucketingTests.swift +++ b/PostHogTests/PostHogDeviceBucketingTests.swift @@ -102,7 +102,7 @@ class PostHogDeviceBucketingTests { let deviceId = sut.getDeviceId() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -120,7 +120,7 @@ class PostHogDeviceBucketingTests { sut.identify("user-123") await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } diff --git a/PostHogTests/PostHogEventSnapshotTests.swift b/PostHogTests/PostHogEventSnapshotTests.swift index 600b5902e..cf7d31de1 100644 --- a/PostHogTests/PostHogEventSnapshotTests.swift +++ b/PostHogTests/PostHogEventSnapshotTests.swift @@ -118,7 +118,7 @@ final class PostHogEventSnapshotTests { ) let loaded = AsyncLatch() - sut.reloadFeatureFlags { loaded.signal() } + sut.reloadFeatureFlags { _ in loaded.signal() } await loaded.wait() let request = try #require(server.flagsRequests.first) diff --git a/PostHogTests/PostHogFeatureFlagsTest.swift b/PostHogTests/PostHogFeatureFlagsTest.swift index 1b8defcf7..8be15bbe1 100644 --- a/PostHogTests/PostHogFeatureFlagsTest.swift +++ b/PostHogTests/PostHogFeatureFlagsTest.swift @@ -309,7 +309,7 @@ enum PostHogFeatureFlagsTest { // Verify they can be retrieved by testing the internal state // Since getPersonPropertiesForFlags is private, we'll test via flag loading await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -348,7 +348,7 @@ enum PostHogFeatureFlagsTest { sut.setPersonPropertiesForFlags(["property2": "value2", "shared": "updated"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -386,7 +386,7 @@ enum PostHogFeatureFlagsTest { sut.resetPersonPropertiesForFlags() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -423,7 +423,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("organization", properties: properties) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -464,7 +464,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("team", properties: ["role": "engineering"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -502,7 +502,7 @@ enum PostHogFeatureFlagsTest { sut.resetGroupPropertiesForFlags("organization") await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -540,7 +540,7 @@ enum PostHogFeatureFlagsTest { sut.resetGroupPropertiesForFlags() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -569,7 +569,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("organization", properties: ["org_plan": "enterprise"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -614,7 +614,7 @@ enum PostHogFeatureFlagsTest { sut.capture("test_event", properties: ["event_prop": "value"], userProperties: ["user_plan": "premium"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -651,7 +651,7 @@ enum PostHogFeatureFlagsTest { sut.group(type: "organization", key: "org123", groupProperties: ["org_plan": "enterprise"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -685,7 +685,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -703,7 +703,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -721,7 +721,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -739,7 +739,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -754,7 +754,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -777,7 +777,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -804,7 +804,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -831,7 +831,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -865,7 +865,7 @@ enum PostHogFeatureFlagsTest { func returnsAllFlagsIncludingDisabled() async { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let all = sut.getAllFeatureFlags() @@ -894,7 +894,7 @@ enum PostHogFeatureFlagsTest { func decodesPayloads() async { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let byKey = Dictionary(uniqueKeysWithValues: (sut.getAllFeatureFlags() ?? []).map { ($0.key, $0) }) @@ -911,7 +911,7 @@ enum PostHogFeatureFlagsTest { func matchesSingleKeyResult() async throws { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let flags = try #require(sut.getAllFeatureFlags()) @@ -932,7 +932,7 @@ enum PostHogFeatureFlagsTest { config.flushAt = 1 let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } server.reset(batchCount: 1) @@ -1033,7 +1033,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1073,7 +1073,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1104,7 +1104,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1134,7 +1134,7 @@ enum PostHogFeatureFlagsTest { // Initially no evaluation contexts await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1153,7 +1153,7 @@ enum PostHogFeatureFlagsTest { // Reload flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1402,7 +1402,7 @@ enum PostHogFeatureFlagsTest { // Receive a /flags response first await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1510,7 +1510,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"]) sut.identify("test_user") - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1525,7 +1525,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true) @@ -1538,7 +1538,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.reloadFeatureFlags() - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) } @@ -1555,7 +1555,7 @@ enum PostHogFeatureFlagsTest { stubFlagsRequiringOverride(delay: 0) await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true) @@ -1563,7 +1563,7 @@ enum PostHogFeatureFlagsTest { HTTPStubsResponse(jsonObject: ["error": "nope"], statusCode: 400, headers: nil) } await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true, "a failed reload must not clear cached flags") @@ -1588,7 +1588,7 @@ enum PostHogFeatureFlagsTest { /// Waits for the main-queue delivery that follows a completed reload. private func reload(_ sut: PostHogSDK) async { await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } await MainActor.run {} } @@ -1652,6 +1652,34 @@ enum PostHogFeatureFlagsTest { #expect(received.last?.variants["string-value"] as? String == "test") } + @Test("reloadFeatureFlags passes the loaded flags to its callback") + func reloadCallbackResult() async { + let sut = track(PostHogSDK.with(config)) + + let result = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: $0) } + } + + #expect(result.errorsLoading == false) + #expect(result.variants["string-value"] as? String == "test") + } + + @Test("reloadFeatureFlags reports errorsLoading with the last known flags when the request fails") + func reloadCallbackErrorsLoading() async { + let sut = track(PostHogSDK.with(config)) + await reload(sut) + + server.flagsResponseHandler = { _ in + HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + } + let result = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: $0) } + } + + #expect(result.errorsLoading == true) + #expect(result.variants["string-value"] as? String == "test") + } + @Test("a listener registered while a delivery is queued on main ends on the newest values") func lateListenerAfterQueuedDelivery() async { let sut = track(PostHogSDK.with(config)) @@ -1671,7 +1699,7 @@ enum PostHogFeatureFlagsTest { var received: [PostHogFeatureFlagsLoaded] = [] await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in sut.onFeatureFlags { received.append($0) } mainBlocked.signal() continuation.resume() @@ -1753,7 +1781,7 @@ enum PostHogFeatureFlagsTest { let mainBlocked = DispatchSemaphore(value: 0) DispatchQueue.main.async { _ = mainBlocked.wait(timeout: .now() + 5) } await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in sut.remoteConfig?.canReloadFlagsForTesting = false sut.reset() sut.onFeatureFlags { received.append($0) } diff --git a/PostHogTests/PostHogGeoIpTest.swift b/PostHogTests/PostHogGeoIpTest.swift index 8291066c3..bcbaa1a35 100644 --- a/PostHogTests/PostHogGeoIpTest.swift +++ b/PostHogTests/PostHogGeoIpTest.swift @@ -69,7 +69,7 @@ class PostHogGeoIpTests { let sut = getSut() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -87,7 +87,7 @@ class PostHogGeoIpTests { let sut = getSut(disableGeoIp: true) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 33f54a6de..908df5d4f 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -512,12 +512,11 @@ final class PostHogSDKTests { let sut = getSut() sut.close() - var called = false - sut.reloadFeatureFlags { - called = true - } + var result: PostHogFeatureFlagsLoaded? + sut.reloadFeatureFlags { result = $0 } - #expect(called) + #expect(result?.errorsLoading == true) + #expect(result?.flags.isEmpty == true) } @Test("captures a screen event") @@ -630,7 +629,7 @@ final class PostHogSDKTests { let group = DispatchGroup() group.enter() - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in group.leave() } @@ -1323,7 +1322,7 @@ final class PostHogSDKTests { _ = sut.getFeatureFlag("some_key") let reloaded = XCTestExpectation(description: "second flag lookup completed") - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in _ = sut.getFeatureFlag("some_key") reloaded.fulfill() } @@ -1349,7 +1348,7 @@ final class PostHogSDKTests { // Change the mock server to return a different value for the same key server.disabledFlag = true - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in // Second call gets a true value _ = sut.getFeatureFlag("disabled-flag") sut.capture("force_batch_flush") diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 19a16e208..75787a27a 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -309,7 +309,7 @@ PostHog | PostHogSDK.register(_:) | method | @objc(registerProperties:) func reg PostHog | PostHogSDK.registerPushNotificationToken(_:) | method | @objc func registerPushNotificationToken(_ deviceToken: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken: PostHog | PostHogSDK.registerPushNotificationToken(_:appId:) | method | @objc func registerPushNotificationToken(_ deviceToken: String, appId: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken:appId: PostHog | PostHogSDK.reloadFeatureFlags() | method | @objc func reloadFeatureFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlags -PostHog | PostHogSDK.reloadFeatureFlags(_:) | method | @objc(reloadFeatureFlagsWithCallback:) func reloadFeatureFlags(_ callback: @escaping () -> Void) | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlagsWithCallback: +PostHog | PostHogSDK.reloadFeatureFlags(_:) | method | @objc(reloadFeatureFlagsWithCallback:) func reloadFeatureFlags(_ callback: @escaping (PostHogFeatureFlagsLoaded) -> Void) | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlagsWithCallback: PostHog | PostHogSDK.reset() | method | @objc func reset() | c:@M@PostHog@objc(cs)PostHogSDK(im)reset PostHog | PostHogSDK.resetGroupPropertiesForFlags() | method | @objc func resetGroupPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlags PostHog | PostHogSDK.resetGroupPropertiesForFlags(_:) | method | @objc(resetGroupPropertiesForFlagsWithGroupType:) func resetGroupPropertiesForFlags(_ groupType: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlagsWithGroupType: From 59786d95a625e94e935d632d330dd04faf1dec28 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 6 Oct 2026 13:39:04 -0400 Subject: [PATCH 2/4] fix: snapshot a failed reload's last known flags once for all callbacks Coalesced reload callbacks each read the live cache on failure, so a reset() in one callback emptied the last known flags reported to the next. The load now captures its result once, before fan-out. Also updates the setPersonPropertiesForFlags example and note for the new callback argument. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogRemoteConfig.swift | 37 ++++++++++++++----- PostHog/PostHogSDK.swift | 16 ++++---- .../Surveys/PostHogSurveyIntegration.swift | 4 +- PostHogTests/PostHogFeatureFlagsTest.swift | 22 ++++++++++- PostHogTests/PostHogRemoteConfigTest.swift | 6 +-- 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/PostHog/PostHogRemoteConfig.swift b/PostHog/PostHogRemoteConfig.swift index e6d27ad06..937b2a658 100644 --- a/PostHog/PostHogRemoteConfig.swift +++ b/PostHog/PostHogRemoteConfig.swift @@ -280,7 +280,7 @@ class PostHogRemoteConfig { } func reloadFeatureFlags( - callback: (([String: Any]?) -> Void)? = nil + callback: ((FeatureFlagsLoadResult) -> Void)? = nil ) { reloadFeatureFlags(callback: callback, surveyCompletion: nil) } @@ -290,7 +290,7 @@ class PostHogRemoteConfig { } private func reloadFeatureFlags( - callback: (([String: Any]?) -> Void)?, + callback: ((FeatureFlagsLoadResult) -> Void)?, surveyCompletion: (([String: Any]?) -> Void)? ) { guard canReloadFlagsForTesting else { @@ -300,7 +300,7 @@ class PostHogRemoteConfig { guard let storageManager = config.storageManager else { hedgeLog("No PostHogStorageManager found in config, skipping loading feature flags") - callback?(nil) + callback?(failedLoadResult()) surveyCompletion?(nil) return } @@ -422,7 +422,7 @@ class PostHogRemoteConfig { anonymousId: String?, deviceId: String? = nil, groups: [String: String], - callback: (([String: Any]?) -> Void)? = nil, + callback: ((FeatureFlagsLoadResult) -> Void)? = nil, surveyCompletion: (([String: Any]?) -> Void)? = nil, coalesceWithCurrentRequest: Bool = false ) { @@ -492,15 +492,16 @@ class PostHogRemoteConfig { self.processErrorTrackingConfig(nil) self.notifyFeatureFlagsAndRelease(cachedFeatureFlags) - callback?(cachedFeatureFlags) + callback?(FeatureFlagsLoadResult(featureFlags: cachedFeatureFlags, lastKnownFeatureFlags: cachedFeatureFlags)) return } // Safely handle optional data guard var data = data else { hedgeLog("Error: Flags response data is nil") + let result = self.failedLoadResult() self.notifyFeatureFlagsAndRelease(nil) - callback?(nil) + callback?(result) return } @@ -512,8 +513,9 @@ class PostHogRemoteConfig { let featureFlagPayloads = data["featureFlagPayloads"] as? [String: Any] else { hedgeLog("Error: Flags response missing correct featureFlags format") + let result = self.failedLoadResult() self.notifyFeatureFlagsAndRelease(nil) - callback?(nil) + callback?(result) return } @@ -576,7 +578,7 @@ class PostHogRemoteConfig { } self.notifyFeatureFlagsAndRelease(loadedFeatureFlags) - callback?(loadedFeatureFlags) + callback?(FeatureFlagsLoadResult(featureFlags: loadedFeatureFlags, lastKnownFeatureFlags: loadedFeatureFlags ?? [:])) } } } @@ -794,6 +796,12 @@ class PostHogRemoteConfig { } } + /// Snapshots the cached flags for a failed load. Taken before listeners and callbacks run. + private func failedLoadResult() -> FeatureFlagsLoadResult { + let cached = featureFlagsLock.withLock { getCachedFeatureFlags() ?? [:] } + return FeatureFlagsLoadResult(featureFlags: nil, lastKnownFeatureFlags: cached) + } + private func notifyFeatureFlagsAndRelease(_ featureFlags: [String: Any]?) { let (pending, surveyWaiters): (PendingFeatureFlagsRequest?, [([String: Any]?) -> Void]) = loadingFeatureFlagsLock.withLock { self.loadingFeatureFlags = false @@ -812,7 +820,7 @@ class PostHogRemoteConfig { anonymousId: pending.anonymousId, deviceId: pending.deviceId, groups: pending.groups, - callback: { flags in pending.callbacks.forEach { $0(flags) } } + callback: { result in pending.callbacks.forEach { $0(result) } } ) } else { for waiter in surveyWaiters { @@ -1324,7 +1332,16 @@ private struct PendingFeatureFlagsRequest { let anonymousId: String? let deviceId: String? let groups: [String: String] - let callbacks: [([String: Any]?) -> Void] + let callbacks: [(FeatureFlagsLoadResult) -> Void] +} + +/// The outcome of one feature flags load, captured once when it completes so every coalesced +/// callback sees the same values even if the cache changes while they run (for example a `reset()`). +struct FeatureFlagsLoadResult { + /// The loaded flags, or `nil` if the load failed. + let featureFlags: [String: Any]? + /// The loaded flags, or the cached flags when the load failed. + let lastKnownFeatureFlags: [String: Any] } #if TESTING diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 589e806f7..ca5d78ef0 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2098,7 +2098,7 @@ let maxRetryDelay = 30.0 /// /// ```swift /// PostHogSDK.shared.setPersonPropertiesForFlags(["plan": "premium"], reloadFeatureFlags: false) - /// PostHogSDK.shared.reloadFeatureFlags { + /// PostHogSDK.shared.reloadFeatureFlags { _ in /// let flagValue = PostHogSDK.shared.isFeatureEnabled("new_feature") /// } /// ``` @@ -2107,9 +2107,10 @@ let maxRetryDelay = 30.0 /// leaves your app in control of when flags load. /// /// - Note: `reset()` clears person properties set here, so they must be set again afterwards. - /// - Note: `reloadFeatureFlags(_:)` reports that the reload finished, not that it succeeded. If the - /// request fails, or the project is over its feature flag quota, the handler still runs and the - /// flags you read are the previously cached ones. + /// - Note: The `reloadFeatureFlags(_:)` handler runs whether or not the reload succeeded. If the + /// request fails, `errorsLoading` is `true` and the flags you read are the previously cached ones. + /// If the project is over its feature flag quota, `errorsLoading` is `false` but the flags are + /// still the previously cached ones. /// /// - Parameters: /// - properties: Dictionary of person properties to include in flag evaluation @@ -2334,11 +2335,10 @@ let maxRetryDelay = 30.0 return } - // nil means the request failed; report the last known flags, like onFeatureFlags. - remoteConfig.reloadFeatureFlags { featureFlags in + remoteConfig.reloadFeatureFlags { result in callback(PostHogFeatureFlagsLoaded( - featureFlags: featureFlags ?? remoteConfig.getFeatureFlags() ?? [:], - errorsLoading: featureFlags == nil + featureFlags: result.lastKnownFeatureFlags, + errorsLoading: result.featureFlags == nil )) } } diff --git a/PostHog/Surveys/PostHogSurveyIntegration.swift b/PostHog/Surveys/PostHogSurveyIntegration.swift index d229d5706..60739f15c 100644 --- a/PostHog/Surveys/PostHogSurveyIntegration.swift +++ b/PostHog/Surveys/PostHogSurveyIntegration.swift @@ -239,7 +239,9 @@ ) { getCachedOrReload( getCached: remoteConfig.getFeatureFlags, - reload: { remoteConfig.reloadFeatureFlags(callback: $0) }, + reload: { callback in + remoteConfig.reloadFeatureFlags(callback: callback.map { callback in { callback($0.featureFlags) } }) + }, forceReload: forceReload, callback: callback ) diff --git a/PostHogTests/PostHogFeatureFlagsTest.swift b/PostHogTests/PostHogFeatureFlagsTest.swift index 8be15bbe1..4dac34174 100644 --- a/PostHogTests/PostHogFeatureFlagsTest.swift +++ b/PostHogTests/PostHogFeatureFlagsTest.swift @@ -1474,7 +1474,7 @@ enum PostHogFeatureFlagsTest { let flags = await withCheckedContinuation { (continuation: CheckedContinuation<[String: Any]?, Never>) in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: nil, groups: [:], callback: { flags in - continuation.resume(returning: flags) + continuation.resume(returning: flags.featureFlags) }) // displaces the reload above out of the pending slot @@ -1680,6 +1680,26 @@ enum PostHogFeatureFlagsTest { #expect(result.variants["string-value"] as? String == "test") } + @Test("coalesced reload callbacks keep the failed attempt's last known flags if one calls reset()") + func reloadCallbackCoalescedFailureReset() async { + let sut = track(PostHogSDK.with(config)) + await reload(sut) + + server.flagsResponseHandler = { _ in + HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + } + // The first reload is in flight, so the next two coalesce into one pending request. + sut.reloadFeatureFlags { _ in } + sut.reloadFeatureFlags { _ in sut.reset() } + let (result, flagAfterReset) = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: ($0, sut.getFeatureFlag("string-value"))) } + } + + #expect(result.errorsLoading == true) + #expect(result.variants["string-value"] as? String == "test") + #expect(flagAfterReset == nil) + } + @Test("a listener registered while a delivery is queued on main ends on the newest values") func lateListenerAfterQueuedDelivery() async { let sut = track(PostHogSDK.with(config)) diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index 1810c894a..a6defd2ac 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -430,11 +430,11 @@ enum PostHogRemoteConfigTest { let bothResults = AsyncLatch(count: 2) sut.loadFeatureFlags(distinctId: "user1", anonymousId: nil, groups: [:]) { flags in - firstResult = flags + firstResult = flags.featureFlags bothResults.signal() } sut.loadFeatureFlags(distinctId: "user2", anonymousId: nil, groups: [:]) { flags in - secondResult = flags + secondResult = flags.featureFlags bothResults.signal() } @@ -456,7 +456,7 @@ enum PostHogRemoteConfigTest { await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "single_user", anonymousId: nil, groups: [:]) { flags in - result = flags + result = flags.featureFlags continuation.resume() } } From 82701b823d560e0f677090da84ae07709473569d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 14:08:54 -0400 Subject: [PATCH 3/4] test: gate the first failed reload so the reset regression always coalesces The coalesced-failure + reset() test assumed the first reload was still in flight when the next two registered. Hold its response until both callbacks are pending, and check both shared one request. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHogTests/PostHogFeatureFlagsTest.swift | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/PostHogTests/PostHogFeatureFlagsTest.swift b/PostHogTests/PostHogFeatureFlagsTest.swift index 4dac34174..f4a7d6a96 100644 --- a/PostHogTests/PostHogFeatureFlagsTest.swift +++ b/PostHogTests/PostHogFeatureFlagsTest.swift @@ -1685,16 +1685,32 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await reload(sut) + // Hold the first failed response so the next two reloads coalesce into one pending request + let firstResponseGate = DispatchSemaphore(value: 0) + let lock = NSLock() + var failedRequests = 0 server.flagsResponseHandler = { _ in - HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + let count = lock.withLock { + failedRequests += 1 + return failedRequests + } + if count == 1 { + _ = firstResponseGate.wait(timeout: .now() + 5) + } + return HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) } - // The first reload is in flight, so the next two coalesce into one pending request. sut.reloadFeatureFlags { _ in } - sut.reloadFeatureFlags { _ in sut.reset() } + var requestsBeforeReset = 0 + sut.reloadFeatureFlags { _ in + requestsBeforeReset = lock.withLock { failedRequests } + sut.reset() + } let (result, flagAfterReset) = await withCheckedContinuation { continuation in sut.reloadFeatureFlags { continuation.resume(returning: ($0, sut.getFeatureFlag("string-value"))) } + firstResponseGate.signal() } + #expect(requestsBeforeReset == 2) #expect(result.errorsLoading == true) #expect(result.variants["string-value"] as? String == "test") #expect(flagAfterReset == nil) From 609cfe215b696e2a391ff0694ddcf5a5968e1554 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:20:09 -0400 Subject: [PATCH 4/4] chore!: mark off-main public callbacks @Sendable for v4 (#914) * chore!: mark off-main public callbacks @Sendable for v4 The reloadFeatureFlags callback, beforeSend blocks, getAnonymousId, pushIdentityProvider and logSanitizer run off the main thread but weren't @Sendable. In Swift 6 apps a reloadFeatureFlags closure written in main-actor code traps at runtime; the other callbacks race silently. Also make PostHogFeatureFlagsLoaded Sendable and add a Swift 6 language-mode test target that calls the API as an app would. Co-Authored-By: Claude Opus 5.5 (1M context) * chore: match the public API snapshot to CI's Xcode 26.3 output Xcode 26.3's symbol graph leaves @Sendable out of declaration fragments, which 26.4 prints; the mangled names still differ. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: store bridged flag values in PostHogFeatureFlagsLoaded The init kept the original variant values after filtering, so a mutable NSString passed via bootstrap stayed mutable inside a Sendable payload. Store the `as? Bool`/`as? String` results instead. Co-Authored-By: Claude Opus 5.5 (1M context) * docs: clarify the Sendable callbacks changeset Name the config properties unambiguously and warn that array conversions crash in both directions. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .changeset/v4-sendable-callbacks.md | 7 ++ Package.swift | 9 ++ PostHog/Logs/PostHogLogsConfig.swift | 4 +- .../Models/PostHogFeatureFlagsLoaded.swift | 15 ++- PostHog/PostHogConfig.swift | 11 +- PostHog/PostHogSDK.swift | 5 +- .../PostHogPushSubscriptionHandler.swift | 3 +- ...PostHogSessionReplayConsoleLogConfig.swift | 6 +- PostHog/Utils/BeforeSendChain.swift | 6 +- PostHog/Utils/BoxedBeforeSend.swift | 4 +- PostHogExample/ContentView.swift | 6 +- .../PostHogCallbackIsolationTests.swift | 103 ++++++++++++++++++ api/posthog-ios.public-api.txt | 8 +- 13 files changed, 162 insertions(+), 25 deletions(-) create mode 100644 .changeset/v4-sendable-callbacks.md create mode 100644 PostHogSwift6Tests/PostHogCallbackIsolationTests.swift diff --git a/.changeset/v4-sendable-callbacks.md b/.changeset/v4-sendable-callbacks.md new file mode 100644 index 000000000..68bb07b2a --- /dev/null +++ b/.changeset/v4-sendable-callbacks.md @@ -0,0 +1,7 @@ +--- +'posthog-ios': major +--- + +**Breaking:** mark callbacks the SDK can call off the main thread `@Sendable` — `reloadFeatureFlags(_:)`, the `BeforeSendBlock` and `PostHogBeforeSendLogBlock` used by `setBeforeSend(_:)` on `PostHogConfig` and `PostHogLogsConfig`, `PostHogConfig.getAnonymousId`, `PostHogConfig.pushIdentityProvider` and its `completion`, and `sessionReplayConfig.captureLogsConfig.logSanitizer` — and make `PostHogFeatureFlagsLoaded` `Sendable`. This fixes a crash in Swift 6 apps when a `reloadFeatureFlags(_:)` callback is written in main-actor code. + +To update main-actor state from `reloadFeatureFlags(_:)` or `pushIdentityProvider`, hop with `Task { @MainActor in ... }`. The other callbacks return a value immediately, so they must not read main-actor state. Declare closures you store in your own variables as `@Sendable` to avoid new Swift 5 warnings. Use the `BeforeSendBlock` and `PostHogBeforeSendLogBlock` typealiases for any `setBeforeSend(_:)` arrays you build or pass around, including your own helpers: in Swift 5 mode, converting an array between the old function type and these types compiles but crashes at runtime, in either direction. diff --git a/Package.swift b/Package.swift index 0c095023b..db82f2080 100644 --- a/Package.swift +++ b/Package.swift @@ -102,6 +102,15 @@ let package = Package( .process("Resources"), ] ), + // Compiles in Swift 6 language mode to check the public API the way a Swift 6 app sees it. + .testTarget( + name: "PostHogSwift6Tests", + dependencies: ["PostHog"], + path: "PostHogSwift6Tests", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), ], swiftLanguageModes: [.v5] ) diff --git a/PostHog/Logs/PostHogLogsConfig.swift b/PostHog/Logs/PostHogLogsConfig.swift index 2a5d11c8b..99b4fc272 100644 --- a/PostHog/Logs/PostHogLogsConfig.swift +++ b/PostHog/Logs/PostHogLogsConfig.swift @@ -13,7 +13,7 @@ import Foundation /// /// - Parameter record: The log record about to be queued. /// - Returns: The record to queue, or `nil` to drop it. -public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRecord? +public typealias PostHogBeforeSendLogBlock = @Sendable (PostHogLogRecord) -> PostHogLogRecord? /// Configuration for the PostHog logs subsystem. Mutate fields on `config.logs` /// before calling `PostHogSDK.setup(_:)`. @@ -105,7 +105,7 @@ public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRec @available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) instead") @objc public func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) { setBeforeSend(blocks.map { box in - { record in box.invokeSafely(with: record) } + { @Sendable record in box.invokeSafely(with: record) } }) } diff --git a/PostHog/Models/PostHogFeatureFlagsLoaded.swift b/PostHog/Models/PostHogFeatureFlagsLoaded.swift index 4ce21c835..1782054cb 100644 --- a/PostHog/Models/PostHogFeatureFlagsLoaded.swift +++ b/PostHog/Models/PostHogFeatureFlagsLoaded.swift @@ -22,7 +22,9 @@ import Foundation /// subscription.unsubscribe() /// ``` @objc(PostHogFeatureFlagsLoaded) -public final class PostHogFeatureFlagsLoaded: NSObject { +public final class PostHogFeatureFlagsLoaded: NSObject, @unchecked Sendable { + // @unchecked: every property is a `let`, and `variants` only holds Swift `Bool` and `String` values (see init). + /// The keys of the enabled feature flags. @objc public let flags: [String] @@ -38,11 +40,16 @@ public final class PostHogFeatureFlagsLoaded: NSObject { @objc public let errorsLoading: Bool init(featureFlags: [String: Any], errorsLoading: Bool) { - let enabled = featureFlags.filter { _, value in + // Store the bridged values, not the originals: a bootstrap `NSMutableString` would stay mutable. + var enabled: [String: Any] = [:] + for (key, value) in featureFlags { if let bool = value as? Bool { - return bool + if bool { + enabled[key] = bool + } + } else if let string = value as? String { + enabled[key] = string } - return value is String } flags = Array(enabled.keys) variants = enabled diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index e8baf0cc0..85f54a007 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -13,7 +13,7 @@ import Foundation /// /// - Parameter event: The event about to be queued. /// - Returns: The event to queue, or `nil` to drop it. -public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? +public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Runtime configuration for a `PostHogSDK` instance. /// @@ -205,7 +205,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// here stalls the SDK's push retry/offline-resume flow (though not your app's main thread). /// /// Default: `nil` (requests carry no identity token). - @objc public var pushIdentityProvider: ((_ distinctId: String, _ appId: String, _ completion: @escaping (String?) -> Void) -> Void)? + @objc public var pushIdentityProvider: (@Sendable (_ distinctId: String, _ appId: String, _ completion: @escaping @Sendable (String?) -> Void) -> Void)? #if os(iOS) || targetEnvironment(macCatalyst) /// Enables UIKit element interaction autocapture on iOS and Mac Catalyst. @@ -282,11 +282,12 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Hook used to customize newly generated anonymous IDs. /// /// The SDK passes its generated UUID v7 and stores the UUID returned by this closure. - /// Existing stored anonymous IDs are not regenerated. + /// Existing stored anonymous IDs are not regenerated. The closure can be called on any thread, + /// so it must not read main-actor state. /// /// - Parameter uuid: The SDK-generated anonymous UUID. /// - Returns: The UUID to persist as the anonymous ID. - @objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid } + @objc public var getAnonymousId: (@Sendable (UUID) -> UUID) = { uuid in uuid } /// Pre-seeded identity and feature-flag state applied during setup, before any /// network request completes. @@ -604,7 +605,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? @available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: BeforeSendBlock...) instead") @objc public func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) { setBeforeSend(blocks.map { box in - { event in box.invokeSafely(with: event) } + { @Sendable event in box.invokeSafely(with: event) } }) } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index ca5d78ef0..84f077925 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2322,9 +2322,10 @@ let maxRetryDelay = 30.0 /// - Parameter callback: Invoked when the reload finishes, possibly on a background thread. /// If the request fails, ``PostHogFeatureFlagsLoaded/errorsLoading`` is `true` and the flags are /// the last known ones. If the reload is skipped because the SDK isn't set up, it's invoked - /// right away with no flags and `errorsLoading` set to `true`. + /// right away with no flags and `errorsLoading` set to `true`. To update UI or other main-actor + /// state, hop to the main actor, for example with `Task { @MainActor in ... }`. @objc(reloadFeatureFlagsWithCallback:) - public func reloadFeatureFlags(_ callback: @escaping (PostHogFeatureFlagsLoaded) -> Void) { + public func reloadFeatureFlags(_ callback: @escaping @Sendable (PostHogFeatureFlagsLoaded) -> Void) { if !isEnabled() { callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true)) return diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 198c96bb5..e0bb2748b 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -697,7 +697,8 @@ final class PostHogPushSubscriptionHandler { return completion(cached) } - var completed = false + // Guarded by stateLock. + nonisolated(unsafe) var completed = false // A provider that never calls its completion would hold isSending for the whole process and // wedge every later send. Bound the wait: if the mint doesn't land in time, fall back to a // token-less send. A late real completion doesn't deliver (via `completed`) but still diff --git a/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift b/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift index 48d498eb2..a77c6a33d 100644 --- a/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift @@ -23,9 +23,11 @@ /// - Detect log level (best effort) /// - Process OSLog messages to remove metadata /// + /// The block runs on a background thread, so it must not read main-actor state. + /// /// - Parameter output: The raw console output to process. /// - Returns: A `PostHogConsoleLogEntry` to capture, or `nil` to skip the log output. - @objc public var logSanitizer: ((String) -> PostHogConsoleLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer + @objc public var logSanitizer: (@Sendable (String) -> PostHogConsoleLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer /// The minimum log level to capture in session replay. /// Only log messages with this level or higher will be captured. @@ -38,7 +40,7 @@ @objc public var minLogLevel: PostHogConsoleLogLevel = .error /// Default implementation for processing console output. - static func defaultLogSanitizer(_ message: String) -> PostHogConsoleLogEntry? { + @Sendable static func defaultLogSanitizer(_ message: String) -> PostHogConsoleLogEntry? { let message = String(message) // Determine console log level let level: PostHogConsoleLogLevel = { diff --git a/PostHog/Utils/BeforeSendChain.swift b/PostHog/Utils/BeforeSendChain.swift index 446ad4cc2..098c1b084 100644 --- a/PostHog/Utils/BeforeSendChain.swift +++ b/PostHog/Utils/BeforeSendChain.swift @@ -5,11 +5,13 @@ import Foundation -/// Composable `(T) -> T?` pipeline shared by the events and logs configs. +/// Composable `@Sendable (T) -> T?` pipeline shared by the events and logs configs. /// Blocks run in registration order; the first `nil` drops the value. /// Reference type so concurrent `set` / `run` see a coherent block snapshot. final class BeforeSendChain { - typealias Block = (T) -> T? + // @Sendable to match the public block types: converting their arrays to a + // non-Sendable element type fails at runtime (`_arrayForceCast`). + typealias Block = @Sendable (T) -> T? private let lock = NSLock() private var block: Block = { $0 } diff --git a/PostHog/Utils/BoxedBeforeSend.swift b/PostHog/Utils/BoxedBeforeSend.swift index f67fbfe1c..f3c29e862 100644 --- a/PostHog/Utils/BoxedBeforeSend.swift +++ b/PostHog/Utils/BoxedBeforeSend.swift @@ -13,7 +13,7 @@ internal import PostHogObjCExceptionSupport /// ObjC wrapper for the events `beforeSend` block. Use with /// `PostHogConfig.setBeforeSend(_:)`. -@objc public final class BoxedBeforeSendBlock: NSObject { +@objc public final class BoxedBeforeSendBlock: NSObject, Sendable { /// Wrapped event callback. @objc public let block: BeforeSendBlock @@ -35,7 +35,7 @@ internal import PostHogObjCExceptionSupport /// ObjC wrapper for the logs `beforeSend` block. Use with /// `PostHogLogsConfig.setBeforeSend(_:)`. -@objc public final class BoxedBeforeSendLogBlock: NSObject { +@objc public final class BoxedBeforeSendLogBlock: NSObject, Sendable { /// Wrapped log callback. @objc public let block: PostHogBeforeSendLogBlock diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index cdb969a0b..9a5fc9cbe 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -36,6 +36,7 @@ class SignInViewModel: NSObject, ObservableObject, ASWebAuthenticationPresentati } } +@MainActor class FeatureFlagsModel: ObservableObject { @Published var boolValue: Bool? @Published var stringValue: String? @@ -56,7 +57,10 @@ class FeatureFlagsModel: ObservableObject { isReloading = true PostHogSDK.shared.reloadFeatureFlags { _ in - self.isReloading = false + // The callback can run on a background thread; @Published changes belong on main. + Task { @MainActor in + self.isReloading = false + } } } } diff --git a/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift new file mode 100644 index 000000000..d9b646a4e --- /dev/null +++ b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift @@ -0,0 +1,103 @@ +// +// PostHogCallbackIsolationTests.swift +// PostHogSwift6Tests +// + +import Foundation +import PostHog +import Testing + +/// Calls the public callback APIs from main-actor code, as a Swift 6 app would. +/// +/// In Swift 6 mode, a closure written in main-actor code and passed as a non-`Sendable` +/// parameter is main-actor isolated, and the runtime traps if the SDK calls it off the main thread. +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct PostHogCallbackIsolationTests { + @MainActor final class AppState { + var reloadResult: PostHogFeatureFlagsLoaded? + var loaded: PostHogFeatureFlagsLoaded? + var deliveredOnMain = false + } + + /// Nothing listens on this port, so requests fail fast without leaving the machine. + private func makeSDK(bootstrapFlags: [String: Any]? = nil) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_swift6_\(UUID().uuidString)", host: "http://127.0.0.1:9") + config.captureApplicationLifecycleEvents = false + config.preloadFeatureFlags = false + if let bootstrapFlags { + let bootstrap = PostHogBootstrapConfig() + bootstrap.featureFlags = bootstrapFlags + config.bootstrap = bootstrap + } + return PostHogSDK.with(config) + } + + @Test("reloadFeatureFlags callback written in main-actor code runs without an isolation trap") + func reloadCallbackFromMainActorCode() async { + let sdk = makeSDK() + defer { sdk.close() } + let state = AppState() + + // The SDK invokes this from its network completion, off the main thread. + await withCheckedContinuation { continuation in + sdk.reloadFeatureFlags { result in + // Sending the result into the hop requires it to be Sendable. + Task { @MainActor in + state.reloadResult = result + continuation.resume() + } + } + } + + #expect(state.reloadResult != nil) + } + + @Test("onFeatureFlags callback written in main-actor code runs on main with a Sendable payload") + func onFeatureFlagsDeliversOnMainActor() async { + let sdk = makeSDK(bootstrapFlags: ["swift6-flag": true]) + defer { sdk.close() } + let state = AppState() + + var subscription: PostHogFeatureFlagsSubscription? + await withCheckedContinuation { continuation in + subscription = sdk.onFeatureFlags { loaded in + // Touches main-actor state directly, with no hop. + guard state.loaded == nil else { return } + state.loaded = loaded + state.deliveredOnMain = Thread.isMainThread + continuation.resume() + } + } + subscription?.unsubscribe() + + #expect(state.deliveredOnMain) + // Sending the payload to another task requires it to be Sendable. + let loaded = state.loaded + let flags = await Task.detached { loaded?.flags ?? [] }.value + #expect(flags == ["swift6-flag"]) + } + + @Test("onFeatureFlags payload keeps its variant values when a mutable bootstrap value changes") + func onFeatureFlagsPayloadIsImmutable() async { + let source = NSMutableString(string: "original") + let sdk = makeSDK(bootstrapFlags: ["swift6-variant": source]) + defer { sdk.close() } + let state = AppState() + + var subscription: PostHogFeatureFlagsSubscription? + await withCheckedContinuation { continuation in + subscription = sdk.onFeatureFlags { loaded in + guard state.loaded == nil else { return } + state.loaded = loaded + continuation.resume() + } + } + subscription?.unsubscribe() + + source.setString("changed") + let loaded = state.loaded + let variant = await Task.detached { loaded?.variants["swift6-variant"] as? String }.value + #expect(variant == "original") + } +} diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 75787a27a..68fa353c9 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -72,8 +72,8 @@ PostHog | PostHogConfig.sendFeatureFlagEvent | property | @objc var sendFeatureF PostHog | PostHogConfig.sessionReplay | property | @objc var sessionReplay: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplay PostHog | PostHogConfig.sessionReplayConfig | property | @objc let sessionReplayConfig: PostHogSessionReplayConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplayConfig PostHog | PostHogConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) | c:@M@PostHog@objc(cs)PostHogConfig(im)setBeforeSend: -PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFcd_tF -PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFcGF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFYbcd_tF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFYbcGF PostHog | PostHogConfig.setDefaultPersonProperties | property | @objc var setDefaultPersonProperties: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)setDefaultPersonProperties PostHog | PostHogConfig.surveys | property | @objc var surveys: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveys PostHog | PostHogConfig.surveysConfig | property | @objc var surveysConfig: PostHogSurveysConfig { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveysConfig @@ -230,8 +230,8 @@ PostHog | PostHogLogsConfig.resourceAttributes | property | @objc var resourceAt PostHog | PostHogLogsConfig.serviceName | property | @objc var serviceName: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceName PostHog | PostHogLogsConfig.serviceVersion | property | @objc var serviceVersion: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceVersion PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) | c:@M@PostHog@objc(cs)PostHogLogsConfig(im)setBeforeSend: -PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFcd_tF -PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFcGF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFYbcd_tF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFYbcGF PostHog | PostHogNextSurveyQuestion | class | @objc class PostHogNextSurveyQuestion | c:@M@PostHog@objc(cs)PostHogNextSurveyQuestion PostHog | PostHogNextSurveyQuestion.isSurveyCompleted | property | let isSurveyCompleted: Bool | s:7PostHog0aB18NextSurveyQuestionC02isD9CompletedSbvp PostHog | PostHogNextSurveyQuestion.questionIndex | property | let questionIndex: Int | s:7PostHog0aB18NextSurveyQuestionC13questionIndexSivp