Skip to content
Open
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/quiet-clerk-host-back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/expo-native-components": patch
---

Allow the iOS host back button in embedded auth and profile views to dismiss its enclosing native navigation screen immediately. `onHostBack` now runs only when there is no screen to pop, such as when the view is the stack's root screen or is presented in a modal.
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {

override func makeHostedController() -> UIViewController? {
let hostBackAction: (() -> Void)? = currentHostBackButton
? { [weak self] in self?.onHostBack([:]) }
? { [weak self] in
guard let self else { return }
if !self.popEnclosingNavigationRoute() {
self.onHostBack([:])
}
}
: nil

return ClerkNativeBridge.shared.makeAuthViewController(
Expand Down
53 changes: 44 additions & 9 deletions packages/expo-native-components/ios/ClerkNativeViewHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public class ClerkNativeViewHost: ExpoView {
private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self)
private var hasInitialized: Bool = false
private var configuredObserver: NSObjectProtocol?
private var isPoppingHostRoute = false

public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
Expand All @@ -23,6 +24,7 @@ public class ClerkNativeViewHost: ExpoView {
super.didMoveToWindow()

guard window != nil else {
isPoppingHostRoute = false
if hasInitialized {
hostedViewDidDetachFromWindow()
}
Expand Down Expand Up @@ -58,6 +60,39 @@ public class ClerkNativeViewHost: ExpoView {

func hostedViewDidDetachFromWindow() {}

func popEnclosingNavigationRoute() -> Bool {
guard !isPoppingHostRoute else { return true }

// Follow controller containment, not the responder chain: past a presented controller the
// responder chain continues to its presenter, so a host inside a modal would pop the screen
// behind the modal instead of letting `onHostBack` dismiss it.
var route = enclosingViewController(of: self)

while let candidate = route {
if let navigationController = candidate.parent as? UINavigationController,
navigationController.viewControllers.count > 1 {
guard navigationController.topViewController === candidate else { return false }

isPoppingHostRoute = true
guard navigationController.popViewController(animated: true) != nil else {
isPoppingHostRoute = false
return false
}
if let coordinator = navigationController.transitionCoordinator {
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
self?.isPoppingHostRoute = false
}
} else {
isPoppingHostRoute = false
}
return true
}
route = candidate.parent
}

return false
}

private func addConfiguredObserver() {
guard configuredObserver == nil else { return }

Expand Down Expand Up @@ -162,7 +197,7 @@ private final class ClerkNativeHostingCoordinator {
controller.view.frame = containerView.bounds
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]

if let parentVC = findViewController(from: containerView) {
if let parentVC = enclosingViewController(of: containerView) {
parentVC.addChild(controller)
containerView.addSubview(controller.view)
controller.didMove(toParent: parentVC)
Expand Down Expand Up @@ -190,15 +225,15 @@ private final class ClerkNativeHostingCoordinator {
guard let containerView else { return }
hostingController?.view.frame = containerView.bounds
}
}

private func findViewController(from view: UIView) -> UIViewController? {
var responder: UIResponder? = view
while let nextResponder = responder?.next {
if let vc = nextResponder as? UIViewController {
return vc
}
responder = nextResponder
private func enclosingViewController(of view: UIView) -> UIViewController? {
var responder: UIResponder? = view
while let nextResponder = responder?.next {
if let viewController = nextResponder as? UIViewController {
return viewController
}
return nil
responder = nextResponder
}
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ public class ClerkUserProfileNativeView: ClerkUserProfileCustomPageHost {

override func makeHostedController() -> UIViewController? {
let hostBackAction: (() -> Void)? = currentHostBackButton
? { [weak self] in self?.onHostBack([:]) }
? { [weak self] in
guard let self else { return }
if !self.popEnclosingNavigationRoute() {
self.onHostBack([:])
}
}
: nil

return ClerkNativeBridge.shared.makeUserProfileViewController(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,126 @@ final class ClerkNativeViewHostTests: XCTestCase {
unmount(hostView, from: window)
}

@MainActor
func testHostBackFallsBackWhenThereIsNoNavigationController() {
let hostView = TestClerkNativeViewHost(appContext: nil)
let container = UIViewController()
container.view.addSubview(hostView)

XCTAssertFalse(hostView.popEnclosingNavigationRoute())
}

@MainActor
func testHostBackDoesNotPopTheRootScreen() {
let root = UIViewController()
let navigation = UINavigationController(rootViewController: root)
let hostView = TestClerkNativeViewHost(appContext: nil)
root.view.addSubview(hostView)

XCTAssertFalse(hostView.popEnclosingNavigationRoute())
XCTAssertTrue(navigation.topViewController === root)
}

@MainActor
func testHostBackPopsTheEnclosingScreen() {
let root = UIViewController()
let screen = UIViewController()
let navigation = UINavigationController(rootViewController: root)
navigation.setViewControllers([root, screen], animated: false)
let hostView = TestClerkNativeViewHost(appContext: nil)
screen.view.addSubview(hostView)

XCTAssertTrue(hostView.popEnclosingNavigationRoute())
XCTAssertTrue(navigation.topViewController === root)
XCTAssertFalse(hostView.popEnclosingNavigationRoute())
}

@MainActor
func testHostBackPopsTheScreenContainingAChildController() {
let root = UIViewController()
let screen = UIViewController()
let child = UIViewController()
screen.addChild(child)
screen.view.addSubview(child.view)
child.didMove(toParent: screen)
let navigation = UINavigationController(rootViewController: root)
navigation.setViewControllers([root, screen], animated: false)
let hostView = TestClerkNativeViewHost(appContext: nil)
child.view.addSubview(hostView)

XCTAssertTrue(hostView.popEnclosingNavigationRoute())
XCTAssertTrue(navigation.topViewController === root)
}

@MainActor
func testHostBackDoesNotPopAScreenBelowTheTopOfTheStack() {
let root = UIViewController()
let screen = UIViewController()
let top = UIViewController()
let navigation = UINavigationController(rootViewController: root)
navigation.setViewControllers([root, screen, top], animated: false)
let hostView = TestClerkNativeViewHost(appContext: nil)
screen.view.addSubview(hostView)

XCTAssertFalse(hostView.popEnclosingNavigationRoute())
XCTAssertEqual(navigation.viewControllers, [root, screen, top])
}

@MainActor
func testHostBackInAModalDoesNotPopTheScreenBehindIt() {
let root = UIViewController()
let screen = UIViewController()
screen.definesPresentationContext = true
let navigation = UINavigationController(rootViewController: root)
navigation.setViewControllers([root, screen], animated: false)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = navigation
window.makeKeyAndVisible()
window.layoutIfNeeded()
let modal = UIViewController()
modal.modalPresentationStyle = .currentContext
screen.present(modal, animated: false)
let hostView = TestClerkNativeViewHost(appContext: nil)
modal.view.addSubview(hostView)
// The responder chain continues from the modal to the pushed screen behind it.
XCTAssertTrue(modal.next === screen)

XCTAssertFalse(hostView.popEnclosingNavigationRoute())
XCTAssertEqual(navigation.viewControllers, [root, screen])

modal.dismiss(animated: false)
window.isHidden = true
}

@MainActor
func testHostBackInAModalFromANestedStackDoesNotPopTheOuterScreen() {
let outerRoot = UIViewController()
let outerScreen = UIViewController()
let outer = UINavigationController(rootViewController: outerRoot)
outer.setViewControllers([outerRoot, outerScreen], animated: false)
let inner = UINavigationController(rootViewController: UIViewController())
outerScreen.addChild(inner)
outerScreen.view.addSubview(inner.view)
inner.didMove(toParent: outerScreen)
inner.definesPresentationContext = true
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = outer
window.makeKeyAndVisible()
window.layoutIfNeeded()
let modal = UIViewController()
modal.modalPresentationStyle = .currentContext
inner.present(modal, animated: false)
let hostView = TestClerkNativeViewHost(appContext: nil)
modal.view.addSubview(hostView)
XCTAssertTrue(modal.next === inner)

XCTAssertFalse(hostView.popEnclosingNavigationRoute())
XCTAssertEqual(outer.viewControllers, [outerRoot, outerScreen])

modal.dismiss(animated: false)
window.isHidden = true
}

@MainActor
private func mountInWindow(_ hostView: UIView) -> UIWindow {
let window = UIWindow(frame: UIScreen.main.bounds)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
*/
export interface EmbeddedNavigationProps {
/**
* Shows a back button on the component's root screen and calls this when it
* is tapped. Pop your own route in response.
* Shows a back button on the component's root screen. On iOS, tapping it pops
* the native navigation screen that contains the component, and this callback
* runs only when there is no such screen to pop, such as when the component is
* the stack's root screen or is presented in a modal. On Android, tapping it
* always calls this. Pop your own route or dismiss your modal in response.
*
* The component keeps its own navigation chrome, so screen titles, back
* buttons, swipe-back, and transitions inside the component stay native.
Expand Down