diff --git a/.changeset/replay-layout-off-main.md b/.changeset/replay-layout-off-main.md new file mode 100644 index 000000000..694e2dfc8 --- /dev/null +++ b/.changeset/replay-layout-off-main.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": patch +--- + +Stop session replay's `UIView.layoutSublayers(of:)` hook from running UIKit layout on a background thread, which crashed the host app in the Auto Layout engine. Add `captureViewLayoutChanges` to `PostHogConfig` to remove the hook, which session replay and surveys both honour. diff --git a/PostHog/ApplicationViewLayoutPublisher.swift b/PostHog/ApplicationViewLayoutPublisher.swift index 9a37052b1..faee5fe80 100644 --- a/PostHog/ApplicationViewLayoutPublisher.swift +++ b/PostHog/ApplicationViewLayoutPublisher.swift @@ -64,17 +64,19 @@ extension UIView { @objc func ph_swizzled_layoutSublayers(of layer: CALayer) { - ph_swizzled_layoutSublayers(of: layer) // call original, not altering execution logic - // Only notify on main thread - layoutSublayers can be called on background threads - // during thread cleanup (CA::Transaction::release_thread), which can cause crashes - // in the Auto Layout engine (NSISEngine) since it's not thread-safe. - if Thread.isMainThread { - ApplicationViewLayoutPublisher.shared.layoutSubviews() - } else { - DispatchQueue.main.async { - ApplicationViewLayoutPublisher.shared.layoutSubviews() - } + // Core Animation can call `layoutSublayers(of:)` on a background thread when it commits a + // thread-local transaction during thread cleanup (`CA::Transaction::release_thread`). + // UIKit's implementation is main-thread only: it runs Auto Layout, and `NSISEngine` raises + // `NSInternalInconsistencyException` off the main thread, which terminates the host app. + // Do not forward the call here. Mark the layer instead, so the layout pass and the + // notification both run on the main thread on the next Core Animation commit. + guard Thread.isMainThread else { + DispatchQueue.main.async { layer.setNeedsLayout() } + return } + + ph_swizzled_layoutSublayers(of: layer) // call original, not altering execution logic + ApplicationViewLayoutPublisher.shared.layoutSubviews() } } #endif diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 1d39e45bc..8ffa8e1f0 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -167,6 +167,24 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Default: true @objc public var enableSwizzling: Bool = true + /// Watch `UIView` layout passes to detect screen changes. + /// + /// The SDK hooks `UIView.layoutSublayers(of:)` to learn when the screen changed. Session replay + /// takes a wireframe or a screenshot from that hook, and surveys use it to find the moment to + /// show a survey. Set this to `false` to remove the hook. + /// + /// When disabled, session replay records console logs, network telemetry and interaction events + /// but captures no screen content, so recordings have no visuals. Surveys appear only when the + /// app becomes active or when an event triggers them. + /// + /// Use this only if the hook causes a problem in your app. Both products are much less useful + /// without it. + /// + /// Note: iOS only. Requires `enableSwizzling` to be `true`. + /// + /// Default: true + @objc public var captureViewLayoutChanges: Bool = true + #if os(iOS) || os(macOS) /// Automatically register the device's APNs token with PostHog by swizzling /// `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`, so Workflows can diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index d23fa0ea6..277481125 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -270,7 +270,7 @@ } // flutter captures snapshots, so we don't need to capture them here - if isNotFlutter() { + if isNotFlutter(), postHog.config.captureViewLayoutChanges { let interval = postHog.config.sessionReplayConfig.throttleDelay viewLayoutToken = DI.main.viewLayoutPublisher.onViewLayout.subscribe(throttle: interval, trailing: true) { [weak self] in // called on main thread diff --git a/PostHog/Surveys/PostHogSurveyIntegration.swift b/PostHog/Surveys/PostHogSurveyIntegration.swift index 30fe9f62b..ddd818a36 100644 --- a/PostHog/Surveys/PostHogSurveyIntegration.swift +++ b/PostHog/Surveys/PostHogSurveyIntegration.swift @@ -98,8 +98,10 @@ self?.onEvent(event: event) } // TODO: listen to screen view events - didLayoutViewToken = DI.main.viewLayoutPublisher.onViewLayout.subscribe(throttle: 5) { [weak self] in - self?.showNextSurvey() + if postHog?.config.captureViewLayoutChanges ?? true { + didLayoutViewToken = DI.main.viewLayoutPublisher.onViewLayout.subscribe(throttle: 5) { [weak self] in + self?.showNextSurvey() + } } didBecomeActiveToken = DI.main.appLifecyclePublisher.onDidBecomeActive.subscribe { [weak self] in self?.showNextSurvey() diff --git a/PostHogTests/ApplicationViewLayoutPublisherTest.swift b/PostHogTests/ApplicationViewLayoutPublisherTest.swift index 3a64cc4ae..9c40b1443 100644 --- a/PostHogTests/ApplicationViewLayoutPublisherTest.swift +++ b/PostHogTests/ApplicationViewLayoutPublisherTest.swift @@ -9,6 +9,7 @@ import Foundation @testable import PostHog import Testing + import UIKit @Suite("Application View Publisher Test", .serialized, .resetsGlobalState) final class ApplicationViewLayoutPublisherTest { @@ -69,5 +70,84 @@ registrationToken = nil } + + @MainActor + @Test("does not run UIKit layout off the main thread") + func layoutStaysOnMainThread() async throws { + let sut = ApplicationViewLayoutPublisher.shared + // Subscribing installs the swizzle on `UIView.layoutSublayers(of:)`. + registrationToken = sut.onViewLayout.subscribe(throttle: 0) {} + + let view = ThreadRecordingView() + let layer = view.layer + + await withCheckedContinuation { continuation in + let thread = Thread { + view.layoutSublayers(of: layer) + continuation.resume() + } + thread.start() + } + + #expect(view.laidOutOffMainThread == false, "UIKit layout must never run on a background thread") + + registrationToken = nil + } + } + + @Suite("View layout capture opt-out", .serialized, .resetsGlobalState) + struct ViewLayoutCaptureOptOutTest { + // Surveys are the second subscriber of the shared publisher, and the publisher installs the + // swizzle for any subscriber. The opt-out only removes the hook if surveys honour it too. + @Test("surveys skip the layout publisher when captureViewLayoutChanges is false", arguments: [true, false]) + func surveysHonourOptOut(captureViewLayoutChanges: Bool) throws { + let server = MockPostHogServer() + server.start() + let mockPublisher = MockViewLayoutPublisher() + DI.main.viewLayoutPublisher = mockPublisher + defer { + DI.main.viewLayoutPublisher = ApplicationViewLayoutPublisher.shared + server.stop() + } + + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.captureViewLayoutChanges = captureViewLayoutChanges + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.captureApplicationLifecycleEvents = false + PostHogStorage(config).reset() + + let postHog = PostHogSDK.with(config) + PostHogSurveyIntegration.clearInstalls() + let integration = PostHogSurveyIntegration() + try #require(integration.install(postHog) == .installed) + defer { + integration.uninstall(postHog) + postHog.close() + postHog.reset() + } + + if captureViewLayoutChanges { + #expect(mockPublisher.onViewLayout.subscriberCount > 0) + } else { + #expect(mockPublisher.onViewLayout.subscriberCount == 0) + } + } + } + + private final class MockViewLayoutPublisher: ViewLayoutPublishing { + let onViewLayout = PostHogThrottledMulticastCallback() + } + + private final class ThreadRecordingView: UIView { + private(set) var laidOutOffMainThread = false + + override func layoutSubviews() { + if !Thread.isMainThread { + laidOutOffMainThread = true + } + super.layoutSubviews() + } } #endif diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 19e4d45a3..fcab4d3b4 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -58,6 +58,7 @@ PostHog | PostHogConfig.captureElementInteractions | property | @objc var captur PostHog | PostHogConfig.capturePushNotificationOpened | property | @objc var capturePushNotificationOpened: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)capturePushNotificationOpened PostHog | PostHogConfig.capturePushNotificationSubscriptions | property | @objc var capturePushNotificationSubscriptions: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)capturePushNotificationSubscriptions PostHog | PostHogConfig.captureScreenViews | property | @objc var captureScreenViews: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureScreenViews +PostHog | PostHogConfig.captureViewLayoutChanges | property | @objc var captureViewLayoutChanges: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureViewLayoutChanges PostHog | PostHogConfig.dataMode | property | @objc var dataMode: PostHogConfig.PostHogDataMode | c:@M@PostHog@objc(cs)PostHogConfig(py)dataMode PostHog | PostHogConfig.debug | property | @objc var debug: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)debug PostHog | PostHogConfig.defaultHost | type.property | static let defaultHost: String | s:7PostHog0aB6ConfigC11defaultHostSSvpZ