diff --git a/CHANGELOG.md b/CHANGELOG.md index d41b661..c2c93a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ ### Fixes +- **iOS**: `endSigningSession` no longer calls `DSMManager` off the main thread. Expo dispatches a synchronous `AsyncFunction` body on a serial background queue, so `clearAllWebCookies()` and `logout()` were reached off-main on every call, including the one `useDocuSignSigning`'s `reset()` makes between flows. The guard now lives in `clearWebCookiesAsync`, the only method touching `DSMManager` and `WKWebsiteDataStore` directly, so it covers every caller. Thanks to @virajpsimformsolutions for finding and fixing this. +- **iOS**: `reset()` no longer re-enters itself to reach the main thread. The hop sat below the block that cancels an in-flight signing promise, so the re-entrant pass ran that block twice and could cancel a session that claimed the slot in between. - **iOS**: reject a blank or non-`https` `signingUrl` before presenting. `DSMEnvelopesManager.presentCaptiveSigning` validates nothing and presents unconditionally, so a malformed URL rendered an empty signing controller whose completion never fired and left the promise unsettled. `signingUrl` defaults to `""` when JS omits it, so this was reachable without a malformed URL at all. Brings iOS to parity with the Android guard below. - **Android**: reject a blank or non-`https` `signingUrl` before launching. The SDK's URL overload validates nothing and calls `startActivity` unconditionally, so a malformed URL opened an empty signing activity and left the promise unsettled. - **Android**: `presentCaptiveSigning` now clears `currentEnvelopeId` when the launch itself throws, matching the URL path. diff --git a/ios/DocuSignManager.swift b/ios/DocuSignManager.swift index 206b245..abf4e3d 100644 --- a/ios/DocuSignManager.swift +++ b/ios/DocuSignManager.swift @@ -318,7 +318,25 @@ internal final class DocuSignManager: NSObject { /// `loginWithAccessToken` runs. If you need to isolate DocuSign's WebKit /// state from the rest of your app, use a non-default data store for those /// other WebViews. + /// Hops to the main thread before touching any DSMManager or WebKit API. + /// + /// Expo dispatches a synchronous `AsyncFunction` body on a serial background queue, so every + /// caller that originates in a JS call arrives here off-main. This is the only method reaching + /// `DSMManager.clearAllWebCookies()` and `WKWebsiteDataStore` directly, so one guard here covers + /// `performLogin`, `endSigningSession` and `reset` rather than each hopping for itself. The + /// completion is dispatched on main below, so callers may touch DSMManager from it. private func clearWebCookiesAsync(completion: @escaping () -> Void) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + guard let self = self else { + completion() + return + } + self.clearWebCookiesAsync(completion: completion) + } + return + } + DSMManager.clearAllWebCookies() let dataStore = WKWebsiteDataStore.default() let types = WKWebsiteDataStore.allWebsiteDataTypes() @@ -526,7 +544,6 @@ internal final class DocuSignManager: NSObject { /// free. func endSigningSession(completion: @escaping () -> Void) { // Resolve any in-flight signing promise so the JS side does not hang. - var pendingResolved = false stateQueue.sync { if let pending = pendingCompletion { let outcome = SigningOutcome( @@ -538,10 +555,8 @@ internal final class DocuSignManager: NSObject { pendingCompletion = nil currentEnvelopeId = nil DispatchQueue.main.async { pending(.success(outcome)) } - pendingResolved = true } } - _ = pendingResolved // silence unused-warning; kept for future telemetry clearWebCookiesAsync { [weak self] in guard let self = self else { @@ -591,14 +606,9 @@ internal final class DocuSignManager: NSObject { return } - if !Thread.isMainThread { - DispatchQueue.main.async { [weak self] in - guard let self = self else { completion(); return } - self.reset(completion: completion) - } - return - } - + // No main-thread hop here. clearWebCookiesAsync guards itself, and re-entering reset() from + // main would run the pending-cancellation block above a second time, cancelling any session + // that claimed the slot in between. clearWebCookiesAsync { [weak self] in guard let self = self else { completion(); return } _ = DSMManager.logout()