From 3f60feff8cef818457da54d76774f00035a1702f Mon Sep 17 00:00:00 2001 From: Viraj Date: Tue, 23 Jun 2026 18:37:07 +0530 Subject: [PATCH 1/3] fix(ios): run endSigningSession DSMManager calls on main thread Expo AsyncFunction handlers are dispatched on AsyncFunctionQueue (a non-main queue). DSMManager APIs (clearAllWebCookies, logout) must run on the main thread, so endSigningSession could touch them off-main and intermittently hang or crash. Hop to main before calling clearWebCookiesAsync. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/DocuSignManager.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ios/DocuSignManager.swift b/ios/DocuSignManager.swift index 206b245..320f67a 100644 --- a/ios/DocuSignManager.swift +++ b/ios/DocuSignManager.swift @@ -543,6 +543,16 @@ internal final class DocuSignManager: NSObject { } _ = pendingResolved // silence unused-warning; kept for future telemetry + // DSMManager APIs (clearAllWebCookies, logout) must run on the main thread. + // Expo async functions are dispatched on AsyncFunctionQueue (non-main), so + // we must hop to main before touching any DSMManager API. + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.endSigningSession(completion: completion) + } + return + } + clearWebCookiesAsync { [weak self] in guard let self = self else { completion() From 3dabed4cc6ad17035ec65b550b144de85efca6a5 Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 29 Aug 2026 14:46:08 +0200 Subject: [PATCH 2/3] refactor(ios): move the main-thread guard into clearWebCookiesAsync The hop landed in endSigningSession, below the stateQueue.sync that cancels an in-flight signing promise, so the re-entrant pass ran that block a second time. Harmless when the slot is already empty, but a presentCaptiveSigning arriving in between would be cancelled by the re-entry. clearWebCookiesAsync is the only method reaching DSMManager.clearAllWebCookies() and WKWebsiteDataStore directly, so guarding it covers performLogin, endSigningSession and reset from one place, and no caller has to know the threading contract. Its completion is already dispatched on main, so the DSMManager.logout() every caller runs from it stays safe. reset() loses its own hop for the same reason: it sat below the same cancellation block and had the same re-entrant double-execution. Drop the pendingResolved flag in endSigningSession while here. It was assigned, never read, and carried a line existing only to silence the resulting warning. --- ios/DocuSignManager.swift | 42 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/ios/DocuSignManager.swift b/ios/DocuSignManager.swift index 320f67a..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,20 +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 - - // DSMManager APIs (clearAllWebCookies, logout) must run on the main thread. - // Expo async functions are dispatched on AsyncFunctionQueue (non-main), so - // we must hop to main before touching any DSMManager API. - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.endSigningSession(completion: completion) - } - return - } clearWebCookiesAsync { [weak self] in guard let self = self else { @@ -601,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() From 4c391045c7ee9f97f039b152d6dc0866409e0786 Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 29 Aug 2026 14:46:12 +0200 Subject: [PATCH 3/3] docs: record the iOS main-thread fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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.