Skip to content

forwardMessage's optional priority cannot cross the iOS bridge: the promise never settles in debug builds #417

Description

@bahdotsh

Found while fixing the iOS bridge ABI mismatches in #416. That PR corrected fifteen methods and pinned both halves with a guard, but forwardMessage cannot be fixed the same way: it needs a contract change across TypeScript, Swift and Kotlin, so it is filed rather than folded in.

What happens

forwardMessage is the one bridge method with a genuinely optional number. TypeScript passes an explicit null whenever the caller omits the priority (bindings/react-native/src/index.ts:1268):

const priority = params.priority ?? null;
const messageId = await OfflineProtocolNativeModule.forwardMessage(
  params.originalMessageJson,
  params.newRecipient,
  priority
);

Swift takes it as NSNumber? (OfflineProtocolModule.swift:1448-1450) and the shim declares it (NSNumber *)priority without a nullability qualifier (OfflineProtocolModule.m:65). That is the only spelling that matches the Swift side, and React Native refuses it.

React Native forces every NSNumber argument to non-null regardless of how it is declared (React/Base/RCTModuleMethod.mm:415-431):

/**
 * Special case - Numbers are not nullable in Android, so we
 * don't support this for now. In future we may allow it.
 */
if ([typeName isEqualToString:@"NSNumber"]) {
  BOOL unspecified = (nullability == RCTNullabilityUnspecified);
  if (!argument.unused && (nullability == RCTNullable || unspecified)) {
    RCTLogArgumentError(... "but React requires that all NSNumber arguments are
                            explicitly marked as `nonnull` to ensure compatibility
                            with Android.");
  }
  nullability = RCTNonnullable;
}

A nonnull argument that arrives nil makes the argument block log "must not be null" and return NO (RCTModuleMethod.mm:451-452), and invokeWithBridge:module:arguments: then aborts before [_invocation invokeWithTarget:module] (RCTModuleMethod.mm:558-564):

if (!block(bridge, index, RCTNilIfNull(json))) {
  // Invalid argument, abort
  RCTLogArgumentError(self, index, json, "could not be processed. Aborting method call.");
  return nil;
}

The Swift method never runs. Neither resolver nor rejecter is called, so the promise never settles and await forwardMessage(...) hangs forever, behind a redbox.

Scope

  • Debug builds only. The whole nullability block is inside #if RCT_DEBUG. In a release build it is compiled out, nil reaches [RCTConvert NSNumber:], and the method works as intended. So this is a developer-experience bug, not a shipped-app bug, which is why it is not a release blocker.
  • iOS only. Android takes priority: Int? and null-checks it (OfflineProtocolModule.kt:1609-1620).
  • Only forwardMessage. It is the sole bridge method with a nullable NSNumber; every other number argument is required. The guard added in fix(bindings): restore the eight iOS methods React Native could not reach #416 (react_native_ios_objc_shim_and_swift_agree_on_every_selector) checks that the two halves agree on the ABI class, and they do agree here: both sides say Object. The mismatch is with React Native's own rule, not between our two files.

Why there is no shim-only fix

Every available spelling loses:

Spelling Result
(NSNumber *)priority (today) unspecified nullability, forced non-null, null rejected
(nonnull NSNumber *)priority same rejection, and now the declaration also claims something false
(nullable NSNumber *)priority RCTLogArgumentError at module load, then forced non-null anyway
(NSInteger)priority matches no Swift NSNumber?; also cannot represent absence

React Native's message names the reason: numbers are not nullable on Android, so the bridge refuses to make them nullable on iOS. The contract has to stop relying on a nullable number.

Options

  1. Sentinel value. TypeScript sends -1 for absent, Swift and Kotlin map -1 to nil before calling the core. Smallest change; the shim becomes (NSInteger)priority and the ABI table gets simpler. Costs a magic number in three languages.
  2. Two methods. forwardMessage and forwardMessageWithPriority, each with a total signature. No sentinel, but a new selector to keep in sync and a branch in the TypeScript.
  3. Priority inside the options object. forwardMessage already takes originalMessageJson; an options dictionary matching sendMessageRich would carry priority as an optional field, where nil is expressible. Most consistent with the rest of the API and the largest diff.

Option 1 or 3. Whichever is chosen, the change lands in TypeScript, Swift and Kotlin together, and bindings/react-native/ios/BRIDGE_MAINTENANCE.md should lose the paragraph that currently documents the breakage and gain the rule instead.

Reproduction

Debug build, iOS:

await protocol.forwardMessage({
  originalMessageJson: json,
  newRecipient: addr,
  // priority omitted -> TypeScript sends null
});
// never resolves, never rejects

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions