Skip to content

fix(bindings): restore the eight iOS methods React Native could not reach - #416

Merged
bahdotsh merged 5 commits into
mainfrom
fix/rn-ios-bridge-selector-parity
Aug 26, 2026
Merged

fix(bindings): restore the eight iOS methods React Native could not reach#416
bahdotsh merged 5 commits into
mainfrom
fix/rn-ios-bridge-selector-parity

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Aug 26, 2026

Copy link
Copy Markdown
Member

What was broken

RCT_EXTERN_METHOD does not declare a Swift method. It records a selector string that React Native resolves against the class at module load, in parseExportedMethods. A selector no Swift method implements is dropped there behind an RCTLogWarn and the JS method is simply absent.

Neither half's compiler sees the other: the .m compiles standalone against the macro, and OfflineProtocolModule.swift is the one bridge source no CI job compiles at all (it needs real React headers). So three separate drifts shipped, costing eight methods across 0.21.0 to 0.24.0:

Method(s) Shape Broken since Symptom
wipePersistedState label renamed on one side only (userId: vs profile:) 0.21.0 boot WARN, undefined is not a function
setBatteryState, getIsCharging, updateRelayConfig, getRelayConfig never declared in the shim 0.22.0 silent: no WARN, and create() swallows the failure in a console.warn
dataListSpaces, dataFlushAll, dataWipeAll labelled first parameter 0.23.0 boot WARN, method unresolvable

The reported issue was wipePersistedState only. The other seven were found by the guard added here.

Android was never affected. Its dispatch is by method name and position; Kotlin parameter names never participate, and the Kotlin side was correct throughout.

Behaviour change worth calling out to app teams

updateRelayConfig / getRelayConfig being absent meant that since 0.22.0 every relay setting an application passed to create() was discarded on iOS, behind a console.warn. Apps that set allowRelay, minBatteryForRelay or relayPriority will see those settings take effect on iOS for the first time on the release carrying this fix. Same for the battery feed, which had no iOS writer.

Why the three data methods are fixed in Swift, not in the shim

Swift exports f(resolver:rejecter:) as fWithResolver:rejecter:, not f:rejecter: (verified empirically against swiftc, not from memory). React Native names the JS method after the selector text before its first colon, so spelling dataListSpacesWithResolver: in the shim would have renamed the JS method rather than repairing it. Dropping the label in Swift is the only fix that restores dataListSpaces as a callable JS name, and it matches the other 162 methods.

The guard

react_native_ios_objc_shim_and_swift_agree_on_every_selector reads OfflineProtocolModule.m, OfflineProtocolModule.swift and src/index.ts and compares them as sets in three directions:

  1. every declared selector is implemented in Swift (else RN drops the binding);
  2. every exported Swift method is declared (else it is unreachable, with no diagnostic at all);
  3. every native method the TypeScript calls is one the shim exports under that name.

This is C5's mechanism applied to a selector table rather than a constant, and it is a Rust guard for C5's stated reason: for sources CI typechecks at most and never runs, a source-reading guard is the only reachable pin.

The set of Swift methods held to this is derived, not listed: an @objc method is one React Native exports exactly when it takes the promise pair. That excludes the two NotificationCenter targets and RN's own addListener/removeListeners overrides without naming them, and puts a new bridge method inside the invariant the moment it is written.

Verification

  • Negative control: the guard was written first and run against the unfixed tree. It failed, naming the four dangling selectors.
  • Mutation tests, each restored and checksum-verified (no git checkout, which would have eaten the uncommitted fix):
    • deleting a RCT_EXTERN_METHOD → direction 2 fires naming getIsCharging:rejecter:;
    • pointing a JS call at an unexported name → direction 3 fires naming it.
  • cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, cargo test --workspace (27 suites), RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps: all clean.
  • Swift: the full hand-written iOS source set typechecks under the BRIDGE_MAINTENANCE.md symlink-farm recipe (swiftc -typecheck, exit 0), and that harness was itself negative-controlled with a deliberate bad type before the clean run was trusted. swift test in ios/: 252 tests pass.
  • No UDL change, so no binding regeneration; no FFI, wire or Android changes.

Docs

docs/bridges/swift.md S1 gains the label-agreement and unlabelled-first-parameter rules; docs/bridges/README.md C5 gains the selector table as its seventh entry; BRIDGE_MAINTENANCE.md gains a step and a Common Issues entry with the command to run.

Review round 2

Three findings from review, all addressed.

The guard could pass while checking nothing

Directions 1 and 2 each assert their parser found at least 150 methods, on the grounds that two empty sets agree perfectly. Direction 3 had no such floor, and it is the one that needs it most: the TypeScript scan keys off the literal OfflineProtocolNativeModule., so renaming that binding, destructuring it, or moving to a TurboModule spec would match nothing, find nothing, and pass. It now counts calls and requires 150 (there are 167). Mutation-tested: renaming the identifier fails with only found 0 native-module calls.

Thirteen conversions abort the app instead of rejecting

Review flagged processFileChunk's data.map { UInt8($0.intValue) }. Grepping the class found thirteen, not one:

Sites Reachable from
12 array conversions a peer's malformed BLE fragment, a Wi-Fi Direct or internet frame, an MLS ciphertext, a Welcome, a key package, a file chunk
initialTtl create(), for any app passing a number above 255

UInt8(_:) traps: it aborts the process rather than returning something the bridge could reject. Arrays now convert through a throwing jsBytes helper that lands in the rejection every one of these call sites already had; initialTtl is clamped, matching Android, which truncates through toUByte() and starts normally where iOS crashed.

These were never masked by the ABI bug above. Array arguments cross as NSArray * against [NSNumber], which has agreed since the UniFFI migration, so each has been live in every release that shipped the method.

Pinned by react_native_ios_bridge_bounds_every_byte_it_builds_from_javascript, which fails on any UInt8(...) in the module whose argument does not carry its own bound (exactly:, clamping:, min(, uint8Value, a mask or a shift). It reads text because what makes a conversion safe is local to where it is written; processFileChunk's scalar narrowings are bounded by a guard several lines above instead, which no textual rule can see. Mutation-tested: restoring one trapping conversion fails naming $0.intValue.

forwardMessage is now tracked, not just described

Its optional priority cannot cross the bridge at all: React Native forces every NSNumber argument to non-null (Android cannot express a nullable number), so a null one is refused in RCTModuleMethod.mm before invokeWithTarget: runs. Neither resolver nor rejecter fires and the promise never settles. It is #if RCT_DEBUG-only, so release builds are fine. No declaration fixes it; filed as #417 with the three candidate contract changes, and linked from BRIDGE_MAINTENANCE.md and docs/bridges/swift.md.

Verification

Both new guards mutation-tested and restored checksum-verified. cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps all clean. The full hand-written iOS source set typechecks (swiftc -typecheck, exit 0) under the BRIDGE_MAINTENANCE.md recipe, negative-controlled first; swift test 252 pass; tsc --noEmit clean. Still no UDL change, so no binding regeneration.

Review round 3

One finding, plus two comments of my own that were wrong.

The same bug class survived round 2, in a conversion that is not a byte

Round 2 swept the module for trapping conversions and pinned the result with a guard that reads every UInt8(...). Both DORS config paths were outside that net, because their conversion is an Int:

let historyWindowRaw = (config["historyWindowSize"] as? NSNumber)?.uint64Value ?? current.historyWindowSize
let historyWindow = max(1, min(100, Int(historyWindowRaw)))   // clamp is too late

That reads as bounded and is not, because the narrowing runs first. historyWindowRaw is a UInt64, a negative JavaScript number reaches uint64Value as UInt64.max by C conversion, and Int(UInt64.max) traps. So create({dors: {historyWindowSize: -1}}) aborted the app, and so did the same field through updateDorsConfig. A value like 1e20 saturates to the same place.

Fixed by clamping in the domain the value arrives in rather than by making the narrowing safe: historyWindow is only ever consumed as a UInt64, so the round-trip through Int is deleted outright and the two UInt64(historyWindow) call sites become identity conversions and go with it. Nothing narrows any more, so there is nothing left to get wrong.

No new guard, deliberately. Whether Int(raw) is safe depends on what raw already is, which the text cannot say, and the module is full of legitimate widenings (Int(hops), Int(progress.chunksSent)) that a textual rule would flag. This is the processFileChunk situation again: held by review and the checklist. Both bridge docs now carry the rule that a clamp wrapped around a narrowing conversion does not count, which is the part that generalises.

I re-swept every other narrowing in the file while I was here. The rest are clean: the DORS UInt64(...uint64Value) forms are identity conversions of non-trapping accessors, the data-layer calls use UInt32(truncating:), UInt16(finalPort) sits behind an explicit 0...65535 guard, and the remaining Int(...) sites convert engine outputs rather than JavaScript input.

Two comments on the new guards were wrong

  • The byte guard's empty-argument skip claimed to exempt [UInt8](...). It never reaches that check at all: the ] before the paren means UInt8( does not match in the first place. What the skip actually exempts is the zero-argument UInt8().
  • The ABI classifier claimed Int? boxes into an NSNumber. It cannot. @objc refuses a method outright when a parameter is an optional value type, so only reference types ever reach that branch.

Neither affected behaviour, but a guard that explains itself wrongly is a guard someone edits wrongly later.

Verification

cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps all clean. The full hand-written iOS source set typechecks under the BRIDGE_MAINTENANCE.md symlink-farm recipe (swiftc -typecheck, exit 0), negative-controlled first, and the negative control doubles as proof of the fix's key assumption: it failed with cannot convert value of type 'UInt64' to specified type 'String', which is the compiler confirming historyWindow now infers as UInt64. The injected error was removed and the file checksum-verified back to its pre-injection state. Still no UDL change, so no binding regeneration.

Downstream: OFF-2462 closes once this ships and the companion app bumps its pin.

…each

`RCT_EXTERN_METHOD` does not declare a Swift method, it records a selector
React Native resolves against the class at module load. One it cannot find
is dropped with a log line and the JS method is simply absent. Neither
compiler sees both halves of the bridge, and `OfflineProtocolModule.swift`
is the one bridge source no CI job compiles, so three separate drifts
shipped:

- `wipePersistedState` kept the pre-rename `userId:` label and had been
  uncallable since 0.21.0, so logging out could not erase the account it
  had just signed out of.
- `setBatteryState`, `getIsCharging`, `updateRelayConfig` and
  `getRelayConfig` were written in Swift, Kotlin and TypeScript and never
  declared in the shim, so since 0.22.0 every relay setting an application
  passed to `create()` was discarded on iOS behind a `console.warn`.
- `dataListSpaces`, `dataFlushAll` and `dataWipeAll` took a labelled first
  parameter, which Swift exports as `dataListSpacesWithResolver:` rather
  than `dataListSpaces:`, and stopped resolving in 0.23.0. These are fixed
  in Swift rather than in the shim: React Native names the JS method after
  the selector text before its first colon, so writing the `With` form in
  the shim would rename the JS method instead of repairing it.

Android was never affected; its dispatch is by method name and position.

`react_native_ios_objc_shim_and_swift_agree_on_every_selector` now reads
both bridge halves plus `src/index.ts` and compares them as sets, in all
three directions. The set of Swift methods it holds to this is derived
rather than listed: an `@objc` method is one React Native exports exactly
when it takes the promise pair, so a new bridge method is inside the
invariant the moment it is written.
…selectors

The selector fix in the parent commit restored eight methods React Native
could not reach. Extending its guard to compare parameter types found seven
more that resolved and then ran on the wrong bits.

`RCT_EXTERN_METHOD` stringifies its argument. React Native parses that text at
module load, picks the `RCTConvert` converter from the type it reads there, and
picks the calling convention from the Swift parameter's runtime encoding, then
calls the one through a function pointer cast to the other. Pair
`nonnull NSNumber *` with a Swift `Int` and `+[RCTConvert NSNumber:]` returns an
object pointer that is then read as a 64-bit integer, so the method runs on the
pointer bits of a tagged `NSNumber` and never on the number. Pair it with a
`Double` and an integer register is read as a floating-point one. The selector
still resolves, the method still runs, and nothing is logged.

The type table in `BRIDGE_MAINTENANCE.md` had mapped `Int` to
`nonnull NSNumber *` since v0.3.3, the release that also introduced the first
method to follow it, so every method that took a number followed it too:

- `sendMessage`, `sendMessageRich`, `sendPresenceUpdate`: every priority and
  status fell through to the `default:` arm, so an app's choice was discarded;
- `setBatteryLevel`, `setBatteryState`: the level clamped to a bound rather
  than recording the battery, which is an input to relay eligibility;
- `processFileChunk`, `blePeerDiscovered`: the value reached a narrowing
  conversion that traps, aborting the application.

Fixed in the shim rather than in Swift: the selector is unchanged either way,
and this direction leaves all seven Swift bodies alone. The two conversions
that now receive real values are made safe for them, since out-of-range input
from JavaScript is a caller mistake and belongs in a rejected promise rather
than in an abort.

The guard gains a third direction, comparing the ABI class of every parameter
behind a shared selector. Both classifiers are exhaustive over the vocabulary
the bridge uses and refuse anything else rather than guessing, so a new type
has to be classified deliberately: a wrong equivalence here is invisible in
exactly the way the guard exists to prevent.

Verified: the type direction was written first and run against the unfixed
tree, where it named all nine mismatched parameters. Two mutations, each
restored and checksum-verified: reverting one type fires the ABI direction
naming it, and an unclassified type fires the refusal. `cargo fmt`, `clippy`,
`cargo test --workspace` and rustdoc are clean; the full hand-written iOS
source set typechecks (exit 0) under the `BRIDGE_MAINTENANCE.md` recipe,
itself negative-controlled first, and `swift test` passes 252 tests.
The selector guard reads three files and compares them in three
directions. Two of those directions assert up front that their parser
actually found something, on the grounds that two empty sets agree
with each other perfectly and prove absolutely nothing.

The third direction doesn't, and it is the one most likely to need
it. The TypeScript scan hunts for a single identifier string,
`OfflineProtocolNativeModule.`. Rename that binding, destructure it,
or move the package to a TurboModule spec, and the scan matches
nothing at all. No matches means no findings, no findings means the
assertion passes, and a guard that passes because it read an empty
set is worse than no guard, because by then you trust it.

So count the calls and require a plausible number of them, the same
way the other two directions do. There are 167 today.

Checked the same way the rest of this guard was: renaming the
identifier in index.ts makes it fail with "only found 0 native-module
calls in bindings/react-native/src/index.ts", which is the sentence
you want to be reading at that point.
…byte

`UInt8(_:)` traps. It does not return nil, it does not throw, it does
not truncate: it kills the process. Thirteen conversions in the iOS
bridge used it on numbers that came straight from JavaScript.

Twelve of them turn an array argument into bytes, so any element
outside 0...255 aborts the app. That covers a BLE fragment, a Wi-Fi
Direct or internet frame, an MLS ciphertext, a Welcome, a key package
and a file chunk, which means most of them are reachable by a peer
sending us something malformed rather than only by our own caller
being careless. The thirteenth is `initialTtl`, typed as an unbounded
`number` in TypeScript, so an application passing 300 to `create()`
takes the app down on iOS while Android quietly truncates it and
starts. Nobody notices until someone's field device starts crashing
on boot and you get to explain why.

This is not great.

Route the arrays through a helper that throws instead, landing in the
rejection every one of these call sites already had, and clamp
`initialTtl` the way every other numeric config field in that file is
already clamped. An out-of-range value from JavaScript is a caller
mistake, and a caller mistake belongs in a rejected promise, not in a
crash report.

Worth being clear that these were never hiding behind the argument
ABI bug fixed earlier on this branch. Array arguments cross as
`NSArray *` against `[NSNumber]`, which has agreed since the UniFFI
migration, so every one of these has been live in every release that
shipped the method.

Pin it with a guard that fails on any `UInt8` conversion in that file
whose argument doesn't carry its own bound. It has to read text
rather than count call sites, because the thing that makes a
conversion safe is local to where it's written. The scalar narrowings
in `processFileChunk` are bounded by a `guard` several lines above
instead, which no textual rule can see, so they stay held by that
clause and by review.

While at it, document the one case that can't be fixed here.
`forwardMessage` takes an optional priority, React Native forces
every `NSNumber` argument to non-null because Android can't express a
nullable number, and it drops the call before the Swift method is
entered. Neither the resolver nor the rejecter runs, so the promise
never settles and the caller waits forever. No spelling of the
declaration fixes that one; it needs a contract change in three
languages.

See #417 for the contract change that would actually fix it.
The previous commit swept this bridge for conversions that trap on
JavaScript input, fixed thirteen of them, and added a guard that
fails on any `UInt8(...)` whose argument is not bounded where it is
written.

It missed two, and the guard could not have caught them, because
they are not byte conversions.

Both DORS config paths clamp `historyWindowSize` with
`max(1, min(100, Int(raw)))`, which reads as bounded and is not: the
narrowing runs *first*. `raw` is a `UInt64` straight off
`NSNumber.uint64Value`, and a negative JavaScript number lands there
as `UInt64.max`, so the `Int` conversion traps and aborts the
process before the clamp around it ever runs. A
`historyWindowSize` of -1 kills the app through `create()`, and
again through `updateDorsConfig`.

So clamp in the domain the value arrives in. `historyWindow` is only
ever consumed as a `UInt64`, so dropping the round-trip through
`Int` deletes the trapping conversion outright instead of making a
pointless one safe, and the two `UInt64(historyWindow)` call sites
become identity conversions and go with it. The symlink-farm
typecheck agrees it now infers as `UInt64`; the negative control
proved the harness by complaining about exactly that type.

No new guard for this one. Whether `Int(raw)` is safe depends on
what `raw` already is, which the text cannot say, and this file is
full of legitimate widenings like `Int(hops)` that a textual rule
would flag. Review and the checklist hold it instead, and both
bridge docs now say that a clamp wrapped *around* a narrowing
conversion does not count.

While at it, two comments on the new guards were simply wrong. The
byte guard's empty-argument skip claimed to exempt `[UInt8](...)`,
which never reaches it at all because the `]` stops it matching the
search; what it actually exempts is the zero-argument `UInt8()`. And
the ABI classifier claimed `Int?` boxes into an `NSNumber`. It
cannot: `@objc` refuses an optional value-type parameter outright,
so only reference types ever reach that branch.
@bahdotsh
bahdotsh merged commit fee2288 into main Aug 26, 2026
18 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant