@@ -219,24 +219,36 @@ class ClerkExpoModule: RCTEventEmitter {
219219// MARK: - Inline View: ClerkAuthNativeView
220220
221221public class ClerkAuthNativeView : UIView {
222- private var hostingController : UIViewController ?
223222 private var currentMode : String = " signInOrUp "
224223 private var currentDismissable : Bool = true
225224 private var hasInitialized : Bool = false
225+ private var authEventSent : Bool = false
226+ private var presentedAuthVC : UIViewController ?
227+ private var isInvalidated : Bool = false
226228
227229 @objc var onAuthEvent : RCTBubblingEventBlock ?
228230
229231 @objc var mode : NSString ? {
230232 didSet {
231- currentMode = ( mode as String ? ) ?? " signInOrUp "
232- if hasInitialized { updateView ( ) }
233+ let newMode = ( mode as String ? ) ?? " signInOrUp "
234+ guard newMode != currentMode else { return }
235+ currentMode = newMode
236+ if hasInitialized {
237+ dismissAuthModal ( )
238+ presentAuthModal ( )
239+ }
233240 }
234241 }
235242
236243 @objc var isDismissable : NSNumber ? {
237244 didSet {
238- currentDismissable = isDismissable? . boolValue ?? true
239- if hasInitialized { updateView ( ) }
245+ let newDismissable = isDismissable? . boolValue ?? true
246+ guard newDismissable != currentDismissable else { return }
247+ currentDismissable = newDismissable
248+ if hasInitialized {
249+ dismissAuthModal ( )
250+ presentAuthModal ( )
251+ }
240252 }
241253 }
242254
@@ -252,65 +264,114 @@ public class ClerkAuthNativeView: UIView {
252264 super. didMoveToWindow ( )
253265 if window != nil && !hasInitialized {
254266 hasInitialized = true
255- updateView ( )
267+ presentAuthModal ( )
256268 }
257269 }
258270
259- private func updateView( ) {
260- // Remove old hosting controller
261- hostingController? . view. removeFromSuperview ( )
262- hostingController? . removeFromParent ( )
263- hostingController = nil
271+ override public func removeFromSuperview( ) {
272+ isInvalidated = true
273+ dismissAuthModal ( )
274+ super. removeFromSuperview ( )
275+ }
276+
277+ // MARK: - Modal Presentation
278+ //
279+ // The AuthView is presented as a real modal rather than embedded inline.
280+ // Embedding a UIHostingController as a child of a React Native view disrupts
281+ // ASWebAuthenticationSession callbacks during OAuth flows (e.g., SSO from the
282+ // forgot-password screen). Modal presentation provides an isolated SwiftUI
283+ // lifecycle that handles all OAuth flows correctly.
264284
285+ private func presentAuthModal( ) {
265286 guard let factory = clerkViewFactory else { return }
266287
267- guard let returnedController = factory. createAuthView (
288+ guard let authVC = factory. createAuthViewController (
268289 mode: currentMode,
269290 dismissable: currentDismissable,
270- onEvent: { [ weak self] eventName, data in
271- // Convert data dict to JSON string for codegen event
272- let jsonData = ( try ? JSONSerialization . data ( withJSONObject: data) ) ?? Data ( )
273- let jsonString = String ( data: jsonData, encoding: . utf8) ?? " {} "
274- self ? . onAuthEvent ? ( [ " type " : eventName, " data " : jsonString] )
275-
276- // Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
277- if eventName == " signInCompleted " || eventName == " signUpCompleted " {
278- let sessionId = data [ " sessionId " ] as? String
279- ClerkExpoModule . emitAuthStateChange ( type: " signedIn " , sessionId: sessionId)
291+ completion: { [ weak self] result in
292+ guard let self = self , !self . authEventSent else { return }
293+ switch result {
294+ case . success( let data) :
295+ if let _ = data [ " cancelled " ] {
296+ // User dismissed — don't send auth event
297+ return
298+ }
299+ self . authEventSent = true
300+ self . sendAuthEvent ( type: " signInCompleted " , data: data)
301+ case . failure:
302+ break
280303 }
281304 }
282305 ) else { return }
283306
284- // Attach the returned UIHostingController as a child to preserve SwiftUI lifecycle
285- if let parentVC = findViewController ( ) {
286- parentVC. addChild ( returnedController)
287- returnedController. view. frame = bounds
288- returnedController. view. autoresizingMask = [ . flexibleWidth, . flexibleHeight]
289- addSubview ( returnedController. view)
290- returnedController. didMove ( toParent: parentVC)
291- hostingController = returnedController
292- } else {
293- returnedController. view. frame = bounds
294- returnedController. view. autoresizingMask = [ . flexibleWidth, . flexibleHeight]
295- addSubview ( returnedController. view)
296- hostingController = returnedController
297- }
307+ authVC. modalPresentationStyle = . fullScreen
308+ // Try to present immediately. Only wait if a previous modal is dismissing.
309+ presentWhenReady ( authVC, attempts: 0 )
298310 }
299311
300- private func findViewController( ) -> UIViewController ? {
301- var responder : UIResponder ? = self
302- while let nextResponder = responder? . next {
303- if let vc = nextResponder as? UIViewController {
304- return vc
312+ private func dismissAuthModal( ) {
313+ presentedAuthVC? . dismiss ( animated: false )
314+ presentedAuthVC = nil
315+ }
316+
317+ /// Presents the auth view controller as soon as it's safe to do so.
318+ /// On initial mount this presents synchronously (no delay, no white flash).
319+ /// If a previous modal is still dismissing, waits for its transition coordinator
320+ /// to finish — no fixed delays.
321+ private func presentWhenReady( _ authVC: UIViewController , attempts: Int ) {
322+ guard !isInvalidated, presentedAuthVC == nil , attempts < 30 else { return }
323+ guard let rootVC = Self . topViewController ( ) else {
324+ DispatchQueue . main. async { [ weak self] in
325+ self ? . presentWhenReady ( authVC, attempts: attempts + 1 )
305326 }
306- responder = nextResponder
327+ return
307328 }
308- return nil
329+
330+ // If a previous modal is animating dismissal, wait for it via the
331+ // transition coordinator instead of a fixed delay.
332+ if let coordinator = rootVC. transitionCoordinator {
333+ coordinator. animate ( alongsideTransition: nil ) { [ weak self] _ in
334+ self ? . presentWhenReady ( authVC, attempts: attempts + 1 )
335+ }
336+ return
337+ }
338+
339+ // If there's still a presented VC (no coordinator yet), wait one frame.
340+ if rootVC. presentedViewController != nil {
341+ DispatchQueue . main. async { [ weak self] in
342+ self ? . presentWhenReady ( authVC, attempts: attempts + 1 )
343+ }
344+ return
345+ }
346+
347+ rootVC. present ( authVC, animated: false )
348+ presentedAuthVC = authVC
309349 }
310350
311- override public func layoutSubviews( ) {
312- super. layoutSubviews ( )
313- hostingController? . view. frame = bounds
351+ private static func topViewController( ) -> UIViewController ? {
352+ guard let scene = UIApplication . shared. connectedScenes
353+ . compactMap ( { $0 as? UIWindowScene } )
354+ . first ( where: { $0. activationState == . foregroundActive } ) ,
355+ let rootVC = scene. windows. first ( where: { $0. isKeyWindow } ) ? . rootViewController
356+ else { return nil }
357+
358+ var top = rootVC
359+ while let presented = top. presentedViewController {
360+ top = presented
361+ }
362+ return top
363+ }
364+
365+ private func sendAuthEvent( type: String , data: [ String : Any ] ) {
366+ let jsonData = ( try ? JSONSerialization . data ( withJSONObject: data) ) ?? Data ( )
367+ let jsonString = String ( data: jsonData, encoding: . utf8) ?? " {} "
368+ onAuthEvent ? ( [ " type " : type, " data " : jsonString] )
369+
370+ // Also emit module-level event so ClerkProvider's useNativeAuthEvents picks it up
371+ if type == " signInCompleted " || type == " signUpCompleted " {
372+ let sessionId = data [ " sessionId " ] as? String
373+ ClerkExpoModule . emitAuthStateChange ( type: " signedIn " , sessionId: sessionId)
374+ }
314375 }
315376}
316377
0 commit comments