Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/replay-layout-off-main.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 12 additions & 10 deletions PostHog/ApplicationViewLayoutPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions PostHog/PostHogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions PostHog/Surveys/PostHogSurveyIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
80 changes: 80 additions & 0 deletions PostHogTests/ApplicationViewLayoutPublisherTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Foundation
@testable import PostHog
import Testing
import UIKit

@Suite("Application View Publisher Test", .serialized, .resetsGlobalState)
final class ApplicationViewLayoutPublisherTest {
Expand Down Expand Up @@ -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<Void>()
}

private final class ThreadRecordingView: UIView {
private(set) var laidOutOffMainThread = false

override func layoutSubviews() {
if !Thread.isMainThread {
laidOutOffMainThread = true
}
super.layoutSubviews()
}
}
#endif
1 change: 1 addition & 0 deletions api/posthog-ios.public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading