From 1a594073c96b6861933a3f60df6689d6f2ce9f30 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:02:11 -0400 Subject: [PATCH] Add hosted navigation SPI for framework integrations Hosts that embed UserProfileView or AuthView inside their own navigation chrome (e.g. the Expo SDK) can now hide Clerk's navigation bars and drive the components' internal stacks. Setting the new @_spi(FrameworkIntegration) ClerkHostedNavigation value in the environment hides the bars; AuthView additionally reports stack depth changes and executes pop()/popToRoot(), since unlike UserProfileView its path cannot be owned externally. Behavior is unchanged when the environment value is absent. --- .../ClerkKitUI/Common/HostedNavigation.swift | 90 +++++++++++++++++++ .../ClerkKitUI/Components/Auth/AuthView.swift | 20 +++++ .../UserProfile/UserProfileView.swift | 6 ++ Tests/UI/HostedNavigationTests.swift | 63 +++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 Sources/ClerkKitUI/Common/HostedNavigation.swift create mode 100644 Tests/UI/HostedNavigationTests.swift diff --git a/Sources/ClerkKitUI/Common/HostedNavigation.swift b/Sources/ClerkKitUI/Common/HostedNavigation.swift new file mode 100644 index 000000000..5b10fd0b5 --- /dev/null +++ b/Sources/ClerkKitUI/Common/HostedNavigation.swift @@ -0,0 +1,90 @@ +// +// HostedNavigation.swift +// Clerk +// + +#if os(iOS) || os(macOS) + +import SwiftUI + +/// Lets a host app that embeds Clerk components inside its own navigation chrome +/// (e.g. Clerk's Expo SDK) hide Clerk's navigation bars while observing and driving +/// the component's internal navigation stack. +/// +/// Setting an instance in the environment via `\.clerkHostedNavigation` hides the +/// navigation bars of `UserProfileView` and `AuthView` (iOS only). When the +/// environment value is `nil` (the default), component behavior is unchanged. +/// +/// `UserProfileView` hosts should prefer the public `navigationPath:` initializer and +/// own the stack directly — hiding is the only effect this environment value has on it. +/// `AuthView` owns an internal stack, so it additionally reports depth changes through +/// ``onDepthChange`` and executes ``pop()`` / ``popToRoot()``. +/// +/// Only one embedded component drives an instance at a time; the most recently +/// appeared component wins. +@_spi(FrameworkIntegration) +@MainActor +public final class ClerkHostedNavigation { + /// Called with the number of screens pushed above the embedded component's root + /// whenever its internal navigation stack changes. + public var onDepthChange: ((Int) -> Void)? + + private var popHandler: ((_ toRoot: Bool) -> Void)? + + public init() {} + + /// Pops one screen off the embedded component's internal stack. No-op at the root. + public func pop() { + popHandler?(false) + } + + /// Pops the embedded component's internal stack back to its root screen. + public func popToRoot() { + popHandler?(true) + } + + func register(popHandler: @escaping (_ toRoot: Bool) -> Void) { + self.popHandler = popHandler + } + + func unregister() { + popHandler = nil + } + + func reportDepth(_ depth: Int) { + onDepthChange?(depth) + } +} + +@_spi(FrameworkIntegration) +extension EnvironmentValues { + /// Hosted-navigation coordinator for embedding Clerk components headerless inside + /// a host-owned navigation UI. `nil` (the default) leaves Clerk's built-in + /// navigation chrome untouched. + @Entry public var clerkHostedNavigation: ClerkHostedNavigation? +} + +private struct HostedNavigationBarHiddenModifier: ViewModifier { + @Environment(\.clerkHostedNavigation) private var hostedNavigation + + func body(content: Content) -> some View { + #if os(iOS) + if hostedNavigation != nil { + content.toolbar(.hidden, for: .navigationBar) + } else { + content + } + #else + content + #endif + } +} + +extension View { + /// Hides the navigation bar when hosted navigation is active (iOS only). + func hostedNavigationBarHidden() -> some View { + modifier(HostedNavigationBarHiddenModifier()) + } +} + +#endif diff --git a/Sources/ClerkKitUI/Components/Auth/AuthView.swift b/Sources/ClerkKitUI/Components/Auth/AuthView.swift index e86707c2a..305950efe 100644 --- a/Sources/ClerkKitUI/Components/Auth/AuthView.swift +++ b/Sources/ClerkKitUI/Components/Auth/AuthView.swift @@ -65,6 +65,7 @@ public struct AuthView: View { @Environment(Clerk.self) private var clerk @Environment(\.clerkTheme) private var theme @Environment(\.dismiss) private var dismiss + @Environment(\.clerkHostedNavigation) private var hostedNavigation /// Navigation state for the auth flow. @State private var navigation = AuthNavigation() @@ -129,6 +130,7 @@ public struct AuthView: View { dismissToolbarItem } #endif + .hostedNavigationBarHidden() .navigationDestination(for: Destination.self) { $0.view #if os(iOS) @@ -136,6 +138,7 @@ public struct AuthView: View { dismissToolbarItem } #endif + .hostedNavigationBarHidden() .authFooter(macOSDismissAction: showDismissButton ? { dismiss() } : nil) .environment(navigation) .environment(authState) @@ -162,6 +165,23 @@ public struct AuthView: View { if let callbackContinuation = clerk.callbackContinuation { resumeAuth(callbackContinuation) } + if let hostedNavigation { + hostedNavigation.register { [navigation] toRoot in + guard !navigation.path.isEmpty else { return } + if toRoot { + navigation.path = [] + } else { + navigation.path.removeLast() + } + } + hostedNavigation.reportDepth(navigation.path.count) + } + } + .onDisappear { + hostedNavigation?.unregister() + } + .onChange(of: navigation.path.count) { _, newCount in + hostedNavigation?.reportDepth(newCount) } .task { let checkpoint = authState.environmentRefreshCheckpoint(for: clerk) diff --git a/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift b/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift index 874ac6826..faca74c11 100644 --- a/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift +++ b/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift @@ -163,6 +163,7 @@ public struct UserProfileView: View { profileContent(user: user) .navigationDestination(for: Route.self) { route in view(for: route) + .hostedNavigationBarHidden() .environment(sheetNavigation) .environment(codeLimiter) .environment( @@ -209,7 +210,10 @@ public struct UserProfileView: View { UserProfileUpdateProfileView(user: user) } .sheet(isPresented: $sheetNavigation.authViewIsPresented) { + // The add-account sheet is modal over the host, so it keeps Clerk's own + // navigation chrome even when this profile is hosted. AuthView() + .environment(\.clerkHostedNavigation, nil) } .task { for await event in clerk.auth.events { @@ -336,8 +340,10 @@ public struct UserProfileView: View { } #endif } + .hostedNavigationBarHidden() .navigationDestination(for: UserProfileBuiltInDestination.self) { destination in view(for: destination) + .hostedNavigationBarHidden() .environment(sheetNavigation) .environment(codeLimiter) .environment( diff --git a/Tests/UI/HostedNavigationTests.swift b/Tests/UI/HostedNavigationTests.swift new file mode 100644 index 000000000..90b41f8e2 --- /dev/null +++ b/Tests/UI/HostedNavigationTests.swift @@ -0,0 +1,63 @@ +@_spi(FrameworkIntegration) @testable import ClerkKitUI +import Testing + +@MainActor +struct HostedNavigationTests { + @Test + func reportDepthForwardsToOnDepthChange() { + let hostedNavigation = ClerkHostedNavigation() + var received: [Int] = [] + hostedNavigation.onDepthChange = { received.append($0) } + + hostedNavigation.reportDepth(1) + hostedNavigation.reportDepth(0) + + #expect(received == [1, 0]) + } + + @Test + func popRoutesToRegisteredHandler() { + let hostedNavigation = ClerkHostedNavigation() + var pops: [Bool] = [] + hostedNavigation.register { toRoot in pops.append(toRoot) } + + hostedNavigation.pop() + hostedNavigation.popToRoot() + + #expect(pops == [false, true]) + } + + @Test + func popIsNoOpWithoutRegisteredHandler() { + let hostedNavigation = ClerkHostedNavigation() + + hostedNavigation.pop() + hostedNavigation.popToRoot() + } + + @Test + func unregisterStopsRoutingPops() { + let hostedNavigation = ClerkHostedNavigation() + var pops: [Bool] = [] + hostedNavigation.register { toRoot in pops.append(toRoot) } + hostedNavigation.unregister() + + hostedNavigation.pop() + + #expect(pops.isEmpty) + } + + @Test + func lastRegisteredHandlerWins() { + let hostedNavigation = ClerkHostedNavigation() + var first = 0 + var second = 0 + hostedNavigation.register { _ in first += 1 } + hostedNavigation.register { _ in second += 1 } + + hostedNavigation.pop() + + #expect(first == 0) + #expect(second == 1) + } +}