Skip to content

iOS: cancelDiscovery() throws synchronously instead of rejecting, defeating .catch() #369

Description

@dienp

Mobile Device Environment

  • Device: iPhone (any); reproduces on simulator too
  • OS: iOS 17/18

Application Environment

  • React Native version: v0.83.1
  • RN Bluetooth Classic version: v1.73.0-rc.12 — code is identical in 1.73.0-rc.17 (latest) and on main

Describe the bug

cancelDiscovery() is declared as returning Promise<boolean>, but on iOS it throws
synchronously before any promise exists (src/BluetoothModule.ts:239-242 on main):

cancelDiscovery(): Promise<boolean> {
  if (Platform.OS == 'ios') throw new Error('Method not implemented.');
  return this._nativeModule.cancelDiscovery();
}

Because no promise is returned, the natural defensive call fails to protect anything:

// looks safe, is not: on iOS `.catch()` is never reached
await RNBluetoothClassic.cancelDiscovery().catch(() => false);

The throw propagates out of the caller's expression instead of becoming a rejection, so callers
who correctly handle "this platform can't do that" still break.

This is inconsistent within the module itself. Of the nine methods guarded with
Method not implemented., three reject (they're async, so the throw is converted) and six throw
synchronously:

Rejects — .catch() works Throws synchronously — .catch() never attaches
accept, pairDevice, startDiscovery cancelDiscovery, cancelAccept, openBluetoothSettings, requestBluetoothEnabled, setBluetoothAdapterName, unpairDevice

To Reproduce

On iOS:

import RNBluetoothClassic from 'react-native-bluetooth-classic';

// 1. rejection handler does not run; the error escapes synchronously
await RNBluetoothClassic.cancelDiscovery().catch(() => false);

// 2. same for the try/catch equivalent placed around an await
try {
  await RNBluetoothClassic.cancelDiscovery();
} catch {
  /* this DOES catch, because the throw happens inside the try */
}

Case 1 is the one that bites: .catch() on the returned value can only run if a value is
returned.

Expected behavior

An unsupported platform should produce a rejected promise (or resolve as a no-op, per the
docstring's "if discovery was already stopped, this will end gracefully by resolving"), matching the
declared Promise<boolean> signature and the behaviour of accept / pairDevice / startDiscovery.

Either of these would do it:

async cancelDiscovery(): Promise<boolean> {
  if (Platform.OS == 'ios') throw new Error('Method not implemented.');
  return this._nativeModule.cancelDiscovery();
}
cancelDiscovery(): Promise<boolean> {
  if (Platform.OS == 'ios') return Promise.reject(new Error('Method not implemented.'));
  return this._nativeModule.cancelDiscovery();
}

Additional context

In a production React Native app this silently disabled all External Accessory connections on
iOS. Our shared Classic connect path cancels discovery before dialling (necessary on Android, where
connecting mid-discovery is unreliable), guarded with .catch(() => false). On iOS the throw escaped
that guard, propagated out of the connect helper, and rejected every connect() — accessories were
discovered and listed normally, but connecting was impossible. It took a while to trace precisely
because the guard looks platform-neutral and the failure surfaces far from this line.

Not asking for iOS implementations of these methods — External Accessory has no discovery phase, so
"not implemented" is the correct answer. The request is only that it be delivered as a rejection so
.catch() and .then(onFulfilled, onRejected) work as the type signature implies.

Happy to open a PR normalising all six if you'd like it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions