Repository: firebase/firebase-ios-sdk (language: cpp)

Issue:
HTTPSCallable does not invoke completion blocks on the main thread
### Description

Issue: `HTTPSCallable`'s `callWithObject:completion` does not invoke the completion block on the main thread.

Regression: This appears to be a regression from #14085.

### Reproducing the issue

1. Create an HTTPSCallable function.
2. Invoke it with `callWithObject:completion`

Expected results: observe that the completion block is called on the main thread.
Actual results: the completion block is called on a background thread.

### Firebase SDK Version

11.11.1

### Xcode Version

16.2

### Installation Method

CocoaPods

### Firebase Product(s)

Functions

### Targeted Platforms

iOS

### Relevant Log Output

```shell

```

### If using Swift Package Manager, the project's Package.resolved

_No response_

### If using CocoaPods, the project's Podfile.lock

_No response_


Discussion / hints:
I couldn't figure out how to label this issue, so I've labeled it for a human to triage. Hang tight.
cc @ncooke3 @yakovmanshin 
Seems like an unintended consequence of that old change.

On the other hand, as I was refactoring some Functions components last year, it never occurred to me that calling completion handlers on the main thread was even as little as an *informal* convention—more like a fortunate coincidence. In practice, I wouldn’t rely on this behavior in client code; though, of course, some sort of harmonization would be welcome (so far there’s only [one method](https://github.com/firebase/firebase-ios-sdk/blob/main/FirebaseFunctions/Sources/Functions.swift#L462-L502) in the entire module that explicitly schedules completions on the main thread).
@bdaz, as a workaround until a fix is released, you could use the previous release or wrap the contents of your callback in `DispatchQueue.main.async {}`

Fix patch:
diff --git a/FirebaseFunctions/CHANGELOG.md b/FirebaseFunctions/CHANGELOG.md
index f244f3f58e8..3c5253793be 100644
--- a/FirebaseFunctions/CHANGELOG.md
+++ b/FirebaseFunctions/CHANGELOG.md
@@ -1,3 +1,7 @@
+# Unreleased
+- [fixed] Fix regression from 11.6.0 where `HTTPSCallable` did not invoke
+  completion block on main thread (#14653).
+
 # 11.10.0
 - [added] Streaming callable functions are now supported.
 
diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift
index b423ac4195a..671a7b67dce 100644
--- a/FirebaseFunctions/Sources/HTTPSCallable.swift
+++ b/FirebaseFunctions/Sources/HTTPSCallable.swift
@@ -76,15 +76,15 @@ open class HTTPSCallable: NSObject {
   ///   - data: Parameters to pass to the trigger.
   ///   - completion: The block to call when the HTTPS request has completed.
   @objc(callWithObject:completion:) open func call(_ data: Any? = nil,
-                                                   completion: @escaping (HTTPSCallableResult?,
+                                                   completion: @escaping @MainActor (HTTPSCallableResult?,
                                                                           Error?) -> Void) {
     if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) {
       Task {
         do {
           let result = try await call(data)
-          completion(result, nil)
+          await completion(result, nil)
         } catch {
-          completion(nil, error)
+          await completion(nil, error)
         }
       }
     } else {
@@ -98,9 +98,13 @@ open class HTTPSCallable: NSObject {
       ) { result in
         switch result {
         case let .success(callableResult):
-          completion(callableResult, nil)
+          DispatchQueue.main.async {
+            completion(callableResult, nil)
+          }
         case let .failure(error):
-          completion(nil, error)
+          DispatchQueue.main.async {
+            completion(nil, error)
+          }
         }
       }
     }
diff --git a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift
index 878cec1c9a0..0fa9c21e862 100644
--- a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift
+++ b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift
@@ -867,6 +867,38 @@ class IntegrationTests: XCTestCase {
       XCTAssertEqual(response, expected)
     }
   }
+
+  func testFunctionsReturnsOnMainThread() {
+    let expectation = expectation(description: #function)
+    functions.httpsCallable(
+      "scalarTest",
+      requestAs: Int16.self,
+      responseAs: Int.self
+    ).call(17) { result in
+      guard case .success = result else {
+        return XCTFail("Unexpected failure.")
+      }
+      XCTAssert(Thread.isMainThread)
+      expectation.fulfill()
+    }
+    waitForExpectations(timeout: 5)
+  }
+
+  func testFunctionsThrowsOnMainThread() {
+    let expectation = expectation(description: #function)
+    functions.httpsCallable(
+      "httpErrorTest",
+      requestAs: [Int].self,
+      responseAs: Int.self
+    ).call([]) { result in
+      guard case .failure = result else {
+        return XCTFail("Unexpected failure.")
+      }
+      XCTAssert(Thread.isMainThread)
+      expectation.fulfill()
+    }
+    waitForExpectations(timeout: 5)
+  }
 }
 
 // MARK: - Streaming
diff --git a/FirebaseFunctions/Tests/ObjCIntegration/FIRIntegrationTests.m b/FirebaseFunctions/Tests/ObjCIntegration/FIRIntegrationTests.m
index 124f2eb9044..1d3f88981ee 100644
--- a/FirebaseFunctions/Tests/ObjCIntegration/FIRIntegrationTests.m
+++ b/FirebaseFunctions/Tests/ObjCIntegration/FIRIntegrationTests.m
@@ -290,4 +290,28 @@ - (void)testTimeout {
   [self waitForExpectations:@[ expectation ] timeout:10];
 }
 
+- (void)testFunctionsReturnsOnMainThread {
+  XCTestExpectation *expectation = [[XCTestExpectation alloc] init];
+  FIRHTTPSCallable *function = [_functions HTTPSCallableWithName:@"scalarTest"];
+  [function callWithObject:@17
+                completion:^(FIRHTTPSCallableResult *_Nullable result, NSError *_Nullable error) {
+                  XCTAssert(NSThread.isMainThread);
+                  XCTAssertNotNil(result);
+                  [expectation fulfill];
+                }];
+  [self waitForExpectations:@[ expectation ] timeout:10];
+}
+
+- (void)testFunctionErrorsOnMainThread {
+  XCTestExpectation *expectation = [[XCTestExpectation alloc] init];
+  FIRHTTPSCallable *function = [_functions HTTPSCallableWithName:@"httpErrorTest"];
+  [function callWithObject:@{}
+                completion:^(FIRHTTPSCallableResult *_Nullable result, NSError *_Nullable error) {
+                  XCTAssert(NSThread.isMainThread);
+                  XCTAssertNotNil(error);
+                  [expectation fulfill];
+                }];
+  [self waitForExpectations:@[ expectation ] timeout:10];
+}
+
 @end
diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift
index 476fe116853..255b2785451 100644
--- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift
+++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift
@@ -290,7 +290,7 @@ class FunctionsTests: XCTestCase {
         }
 
         XCTAssertEqual(error as NSError, networkError)
-
+        XCTAssert(Thread.isMainThread)
         completionExpectation.fulfill()
       }
 


Test patch: