# react-native-watch-connectivity-pro — LLM Reference > A React Native (iOS-only) library bridging Apple's WatchConnectivity framework > (`WCSession`) to JavaScript. Phone-side only: it runs in the iOS app process. > The watchOS app implements its own `WCSessionDelegate` in Swift — this package > does not ship any watch-side code. This document is the canonical machine-readable description of the package for LLMs and coding agents. It describes what the code ACTUALLY does (as of the 0.3.0 reliability fixes — thread safety, queue-drain repair, reply-handler TTL, stable file-transfer ids, logging gate/redaction, event `source` tags, `sessionReachabilityDidChange`, built-in `plistSafe` sanitization), including quirks — not what an idealized version would do. When README.md and this file disagree, trust this file. ## Package layout - `src/index.ts` — the real JS API (TypeScript source; `react-native` field points here, so Metro uses it) - `src/types.ts` — TypeScript types re-exported from `src/index.ts` - `lib/` — react-native-builder-bob output (commonjs / module / typescript). `main`/`module` point here for non-Metro consumers - `index.js`, `index.d.ts` (package root) — LEGACY, not referenced by any package.json entry point. Do not extend these; they predate `src/` and their `sendMessage(message, callback)` signature passes a raw JS function to a promise-based native method, which crashes under the RN New Architecture interop layer - `ios/RNWatchConnectivityPro.swift` — the entire native implementation (one class, `RCTEventEmitter` subclass + `WCSessionDelegate`) - `ios/RNWatchConnectivityPro.m` — `RCT_EXTERN_MODULE` bridge declarations. Every native method exposed to JS must be declared here too ## Architecture in one paragraph A single Obj-C-exported Swift class `RNWatchConnectivityPro` (legacy NativeModule, NOT a TurboModule) activates `WCSession.default` in its `init` (so the session is live before JS calls anything), implements `WCSessionDelegate`, and forwards every delegate callback to JS as `RCTEventEmitter` events. JS accesses it via `NativeModules.RNWatchConnectivityPro` + `NativeEventEmitter`. On RN New Architecture (bridgeless) it works through the legacy-module interop layer. **Thread safety (internal, no API impact):** all shared native state (`messageQueue`, `replyHandlers`, `fileTransfers`, `isProcessingQueue`, `lastWatchStateChange`, `fileLoggingEnabled`) is now serialized on private serial `DispatchQueue`s (`stateQueue` for general state, `logIOQueue` for the log file) rather than being touched ad hoc from whatever thread a `WCSessionDelegate` callback or RN bridge call happened to land on. This is a reliability fix for data races under concurrent watch traffic — it does not change any method signature, event shape, or timing contract observable from JS. ## The four WatchConnectivity channels (and when to use each) | Channel | Native API | Requires reachable? | Delivery | Use for | |---|---|---|---|---| | Interactive message | `sendMessage` / `sendMessageWithReply` | YES (watch app foreground) | immediate or error | request/response, live pings | | Application context | `updateApplicationContext` | no (session activated only) | latest-wins, replaces previous | "current state" snapshots (selection, config) | | User info transfer | `transferUserInfo` | no | FIFO queue, guaranteed, survives relaunch (OS-level) | important one-off payloads (auth tokens, data updates) | | File transfer | `transferFile` | no | background, progress-tracked | files | **Payload rule for ALL channels:** dictionaries must contain only property-list types (String, number, Bool, Date, Data, Array, Dictionary). A single `undefined`/`null`/`NaN` anywhere in the object graph makes WCSession reject the ENTIRE payload with "Payload contains unsupported type" — and the failure is easy to miss because it surfaces as a rejected promise the caller may be swallowing. **BUILT IN (fixed):** every outbound send path in `src/index.ts` (`sendMessage`, `updateApplicationContext`, `transferUserInfo`, `transferCurrentComplicationUserInfo`, `replyToMessage`; `sendAuthTokensToWatch` inherits it via `transferUserInfo`) now runs the payload through the exported `plistSafe()` helper (`src/plistSafe.ts`) before it reaches native. Semantics: strips `undefined`/`null` everywhere (object values AND array elements); strips non-finite numbers (`NaN`/`Infinity`/`-Infinity`) including as array elements; preserves `Date` instances untouched (a supported plist/NSDate type); filters dropped array entries out densely (no holes, since WCSession arrays can't contain `NSNull` either). Callers no longer need to hand-roll their own stripping logic, though `plistSafe` is still exported for callers who want to pre-sanitize a payload themselves (e.g. before diffing against a previous context). ## JS API surface (src/index.ts — default export) - `initSession(): Promise` — activates session if needed, resolves current state. NOTE: `WCSession.activate()` is async; on a cold start the resolved state often reports `isWatchAppInstalled: false` / `sessionState: 'NotActivated'` even when a watch is paired. Never cache a negative from this call — re-query until it flips true, or listen for `watchStateUpdated`. `WatchState.sessionState` (`src/types.ts`) is `'NotActivated' | 'Inactive' | 'Activated' | 'Unknown'` — `'Unknown'` was added as a type-level catch-all for any native `activationState` the mapping doesn't recognize; `WatchState` also carries optional `lastStateChange` (epoch seconds of the last delegate state change) and `queuedMessages` (current in-memory queue length) fields. - `sendMessage(message, replyHandler?): Promise` — routes through native `sendMessageWithReply(message, replyId)`. The reply callback is NOT passed to native; instead a string `replyId` is registered in a JS-side map and the native reply comes back on the internal event `internal_messageReply`. This design exists specifically to dodge the New-Arch interop bug (see Quirks #2). Payload is passed through `plistSafe()` before it reaches native (see Payload rule above). If the session is not activated or the watch is unreachable, native rejects with code `QUEUED` after adding the message to its in-memory queue. - `updateApplicationContext(context): Promise` — thin passthrough, payload run through `plistSafe()` first. Rejects `ERR_WATCH_SESSION_NOT_ACTIVATED` if session not activated. - `getApplicationContext(): Promise` — returns the LAST context THIS phone sent (`session.applicationContext`), not what the watch sent back. - `transferUserInfo(userInfo): Promise<{id, data}>` — queues an OS-level transfer, payload run through `plistSafe()` first. The returned `id` is a random UUID minted per call; it does NOT correlate with the ids in `userInfoTransferFinished` events (each event mints its own UUID). Do not try to match them. UNCHANGED by the reliability fixes — only `getFileTransfers` ids became stable (see below); userInfo ids are still uncorrelated. - `getCurrentUserInfo(): Promise` — outstanding (not-yet-delivered) user-info transfers, again with freshly minted uncorrelated ids (also unchanged). - `transferFile(path, metadata?): Promise` — starts a file transfer, returns `{id, file, fileName, fileSize, metadata, progress, timestamp}`. Progress arrives via `fileTransferProgress` events (throttled to every 10%). - `getFileTransfers(): Promise` — **FIXED:** now returns the SAME id for a transfer it's already tracking, and only registers a KVO progress observer the first time it sees that transfer (`ios/RNWatchConnectivityPro.swift`'s `fileTransferId(for:)` helper). Previously every call minted a fresh UUID per outstanding transfer and re-registered a duplicate KVO observer, so ids were unstable and observers accumulated. Still prefer holding on to the id returned by `transferFile` where possible, but repeated `getFileTransfers()` polling is now safe. - `cancelFileTransfer(id): Promise` — only works with an id this JS session minted (from `transferFile`/`getFileTransfers`). - `replyToMessage(handlerId, response): Promise` — completes a watch→phone message's reply handler, payload run through `plistSafe()` first. `handlerId` comes from the `messageReceived` event. Must be called within 30s of the message arriving — see reply-handler TTL below. - `getQueueStatus(): Promise<{count, isProcessing, oldestMessage, newestMessage}>` — status of the package's OWN in-memory message queue (numbers are epoch-seconds timestamps, 0 when empty). No per-message detail (type/contents) is exposed — you cannot inspect what's queued beyond count + timestamps. - `processMessageQueue(): Promise<{processed: number}>` — force-drain the queue (batches of 5, 1s between batches). - `clearMessageQueue(): Promise` — drop all queued messages. Also cleans up any reply handlers stored for the dropped messages. - `getReachability(): Promise<{isReachable, isPaired, isWatchAppInstalled, activating?, pingSucceeded?, fromTimeout?}>` (`GetReachabilityResult` in `src/types.ts`) — enhanced reachability: if `isReachable` is false it fires a `{type:'ping'}` sendMessage at the watch and waits up to 300 ms for a reply before answering. THE WATCH APP MUST reply to messages for this to work (any reply counts) — if the watchOS delegate never implements/calls a reply handler, the ping can never succeed and the result falls back to the raw state after the 300ms timeout (`fromTimeout: true`). Extra flags tell you how the answer was derived. - `checkWatchConnectivityStatus(): Promise` — diagnostic: logs full session state, re-activates if needed, drains the queue if reachable, and — **CHANGED:** the fake `messageReceived` event (`{type:'test', isTest:true, sender:'swift'}`, body tagged `source:'test'`) is now emitted ONLY when file logging is enabled (native `isFileLoggingEnabled()` check), which defaults to ON in Debug / OFF in Release. Production (Release) listeners no longer see it at all; Debug-build listeners should still tolerate/ignore `source === 'test'` (or `isTest`) messages. - `isComplicationEnabled(): Promise`; `getRemainingComplicationTransfers(): Promise`; `transferCurrentComplicationUserInfo(userInfo): Promise` — complication support (daily-budget-limited by Apple). `transferCurrentComplicationUserInfo` payload run through `plistSafe()` first. - `sendAuthTokensToWatch(jwt, refreshToken): Promise` — convenience: `transferUserInfo({type:'authTokens', jwt, refreshToken, timestamp})`. Pure JS, no dedicated native method. Inherits `plistSafe()` sanitization via `transferUserInfo`. - `setFileLoggingEnabled(enabled): Promise` — **NEW.** Toggles whether native writes verbose logs to `Documents/watch_connectivity.log` (console/Xcode logging is always on regardless). Defaults to `true` in Debug builds, `false` in Release. The JS wrapper checks whether the native method exists and resolves `false` (no-op) on older native builds that predate this method, so it's safe to call unconditionally from JS without a native-version check. - `addListener(eventName, listener)` / `removeAllListeners(eventName)` — event subscription. For `messageReceived`, if the native event carries `hasReplyHandler`/`handlerId`, the listener receives the event augmented with a ready-made `replyHandler(response)` function that calls `replyToMessage` for you. ## Events (NativeEventEmitter) Exported as `Events` from the package: - `messageReceived` — fired for: (1) real interactive messages from the watch, body `{message, source:'message'}` or `{message, source:'message', hasReplyHandler:true, handlerId}`; (2) EVERY received user-info transfer (re-emitted for convenience), body tagged `source:'userInfo'`; (3) EVERY received application context (re-emitted), body tagged `source:'applicationContext'`; (4) the diagnostic test message from `checkWatchConnectivityStatus`, body tagged `source:'test'` (Debug-only now, see below). **FIXED:** every body now carries a `source: 'message' | 'userInfo' | 'applicationContext' | 'test'` field — you no longer have to infer the channel purely from your own payload's `type` field, though doing so is still good practice for cross-channel dedup. - `watchStateUpdated` — body `{watchState: {isPaired, isReachable, isComplicationEnabled, isWatchAppInstalled, sessionState, lastStateChange, queuedMessages}}`. Fires on activation complete, deactivate, inactive, `sessionWatchStateDidChange`, AND — **FIXED (was a stale caveat):** `sessionReachabilityDidChange` is now implemented (`ios/RNWatchConnectivityPro.swift` line ~1242), so a pure reachability flip with no accompanying watch-state change now reaches JS promptly via this event and also triggers a queue drain if messages are pending. You no longer need to poll `getReachability` to catch that case, though it's still a valid fallback. - `userInfoReceived` — body `{userInfo: {id, data, transferTime}}` (also mirrored to `messageReceived`, see above). - `userInfoTransferFinished` — body `{userInfo: {id, data}, error?}` — a phone→watch user-info transfer left the queue. - `fileTransferProgress` — body `{id, progress (0..1), fileName, metadata}` — every ~10%. - `fileTransferFinished` / `fileTransferError` — completion events, body includes `{id, file, fileName, metadata, ...}`. - `fileReceived` — watch→phone file arrived; file is MOVED to the app's Documents directory (overwriting same-named files); body `{id, file, fileName, fileSize, metadata?, timestamp}`. - `activationError` — body `{error: {domain, code, localizedDescription}}`. - `messageQueueUpdated` — body `{count, isProcessing, oldestMessage, newestMessage}` — the internal queue changed. - `internal_messageReply` — internal only; do not subscribe. ## Message queue semantics (IMPORTANT) - The queue lives in memory in the native module instance. It does NOT survive app termination. "Offline queueing" means "queued until the watch is reachable or the app dies, whichever comes first." This has NOT changed — do not rely on the queue for data that must survive a process restart; use `transferUserInfo` (OS-guaranteed, survives relaunch) for that. - **FIXED:** both the `sendMessageWithReply` path (i.e. the `src/index.ts` `sendMessage`) AND the bare native `sendMessage` method now enqueue on failure before rejecting `QUEUED` (`ios/RNWatchConnectivityPro.swift` lines ~182 and ~197 — "parity with sendMessageWithReply"). Previously the bare native `sendMessage` (used by the legacy root `index.js` contract) rejected `QUEUED` WITHOUT enqueuing, silently dropping the message. - The queue auto-drains on: session activation, `sessionWatchStateDidChange` with reachable=true, `sessionReachabilityDidChange` with reachable=true (newly implemented, see Events above), `checkWatchConnectivityStatus` when reachable, and manual `processMessageQueue()`. Batches of 5, 1 s apart. - **FIXED (was KNOWN BUG in 0.2.3):** messages whose send fails during a drain are now correctly re-queued instead of lost. The drain is `DispatchGroup`-based: failures from each batch's async `errorHandler` are collected and merged back into the front of the queue only in `group.notify`, AFTER every send in the batch has completed — no more race between the re-queue append and the synchronous end of the drain loop. The drain also now stops (rather than hot-looping) once a batch has any requeued failures, only continuing to drain while sends keep succeeding and the watch stays reachable. The queue is still in-memory-only (see first bullet), so this is a reliability improvement, not a durability guarantee. - **FIXED (was leak quirk):** queued messages with reply handlers are no longer unbounded. A stored reply handler now expires after **30 seconds** (`scheduleReplyHandlerExpiry`, `stateQueue.asyncAfter(deadline: .now() + 30)`) — if the message is never delivered (or is delivered after the TTL) the caller gets a default reply `{"error": "reply timeout", "acknowledged": false}` instead of the handler leaking forever. This applies both to phone→watch queued sends and to reply handlers registered for watch→phone messages the JS side never answers. ## Quirks & gotchas (hard-won production knowledge) 1. **Import-time throw:** `src/index.ts` throws `LINKING_ERROR` at MODULE LOAD if `NativeModules.RNWatchConnectivityPro` is nullish. If your bundler evaluates the package before native modules register (early-imported files, Jest without mocks), the throw poisons the module cache permanently. In early-loading code, access `NativeModules.RNWatchConnectivityPro` directly instead of importing the package. 2. **New Architecture interop bug:** calling a promise-based native method with a trailing JS function argument (e.g. legacy `sendMessage(msg, callback)`) makes the TurboModule interop layer try to convert the function to `RCTPromiseResolveBlock` and throw: "Error while converting JavaScript argument 1 to Objective C type RCTPromiseResolveBlock". On hot paths this repeated throw/catch is a suspected contributor to Hermes EXC_BAD_ACCESS crashes. The `src/index.ts` reply-id design avoids this; never reintroduce function arguments to native calls. 3. **Plist-only payloads:** see channel table above. `undefined`/`null` anywhere → whole payload rejected. **Now sanitized automatically** for any caller going through the `src/index.ts` default export's send methods (see `plistSafe` under "Payload rule" above). Still applies in full if you call `NativeModules.RNWatchConnectivityPro` directly, bypassing the JS wrapper — sanitize first in that case. 4. **Cold-start false negatives:** `initSession`/`getReachability` right after launch report not-installed/not-reachable because activation is async. Also `didReceiveApplicationContext` on the counterpart may not fire on first activation — the receiver should ALSO read `session.receivedApplicationContext` directly after activating. 5. **Identical-context suppression:** iOS may skip the watch's `didReceiveApplicationContext` when the new context equals the stored one. If you need the delegate to fire every time, add a changing field (e.g. `_ts: Date.now()`) — and/or send the same payload via `transferUserInfo` as a belt-and-braces second channel. 6. **FIXED (was: `checkWatchConnectivityStatus` unconditionally injects a fake message):** the diagnostic test message (`{type:'test', isTest:true, sender:'swift'}`, body `source:'test'`) is now gated on `isFileLoggingEnabled()` — it only fires when file logging is on, which defaults to ON in Debug / OFF in Release. Production (Release) listeners no longer receive it at all. Debug-build listeners should still filter on `source === 'test'` (or `isTest`) to be safe. The body also now carries the `source` discriminator described under Events above, so filtering is more reliable than before regardless. 7. **File logging — CHANGED (was: unconditional, uncapped, full payloads logged):** file logging to `Documents/watch_connectivity.log` now defaults to ON in Debug builds and OFF in Release builds, and is toggleable at runtime via `setFileLoggingEnabled(enabled): Promise`. Console/Xcode logging is unaffected (always on). Payload VALUES are no longer written to the file — only keys and the `type` field are logged (`logToFile` call sites log `keys=...` / `type=...`, not the values), so token/secret content no longer ends up on disk via this path. The log file is capped at 512KB (`maxLogBytes`) and file I/O runs async on a dedicated `logIOQueue`, off the main/state queues. 8. **`sendMessage` requires the watch app to be live in the foreground** (or briefly backgrounded-but-runnable). "Paired and on wrist" is NOT "reachable". Design phone→watch pushes around `updateApplicationContext` + `transferUserInfo`, and treat `sendMessage` as opportunistic. 9. **FIXED (was: reply handlers on the watch side leak):** when the phone receives a watch message with a reply handler and JS never calls `replyToMessage`, the stored handler now expires after a 30s TTL (`scheduleReplyHandlerExpiry`) and sends a default reply (`{"error": "Invalid reply data from JavaScript", "acknowledged": true}` is the JS-side-missing-data default; the TTL-expiry default is `{"error": "reply timeout", "acknowledged": false}`) to the watch instead of leaking. Same TTL mechanism also covers phone→watch queued-message reply handlers (see Message queue semantics above). 10. **Event ids are PARTIALLY stable now.** `getFileTransfers()` ids are FIXED — stable across repeated calls for the same outstanding transfer (see JS API surface above). UserInfo transfer ids (`transferUserInfo`'s returned id, `getCurrentUserInfo()`'s ids, and `userInfoTransferFinished` event ids) are UNCHANGED — still minted per call/event and do not correlate across APIs. Match user-info transfers on your own payload fields, not on ids; file transfers can now be tracked by id from `getFileTransfers()` as well as `transferFile()`. ## Consuming app integration map (Monitor My Solar) The production consumer accesses the module three ways (all must keep working — they define the de-facto compatibility surface): - `NativeModules.RNWatchConnectivityPro` directly (watchDataService.ts, WatchConnectivityService.ts, DebugScreen) — methods used: `initSession`, `getReachability`, `checkWatchConnectivityStatus`, `updateApplicationContext`, `transferUserInfo`; events: `messageReceived`, `watchStateUpdated`, `userInfoReceived`, `fileTransferProgress`. - The package default export via a try/require wrapper (`src/utils/watchConnectivityWrapper.ts`). - The app keeps its own plist sanitizer (`plistSafe`) and its own throttle/dedupe layers; auth tokens go watch-ward via `transferUserInfo` (`type: 'authTokens'`). Note: the package now does its own `plistSafe` sanitization internally on every send path (see above) — the app's own sanitizer is redundant for calls that go through the JS wrapper, though it's still needed for the direct-`NativeModules` call sites listed above and is harmless to keep either way. Breaking any of: method names above, event names, event body shapes, the `QUEUED` rejection code, or the `{type,...}` payload conventions is a breaking change for the shipped app. ## New Architecture status - Current: legacy NativeModule + `RCTEventEmitter`, running under the New-Arch interop layer (RN 0.86, bridgeless). Functional, but unvalidated by codegen and exposed to interop marshalling edge cases (see Quirk #2). - Planned: codegen'd backward-compatible TurboModule (spec in `src/NativeRNWatchConnectivityPro.ts`, `codegenConfig` in package.json), keeping the same method/event names so old-arch and existing consumers are unaffected. ## Versioning Published on npm as `react-native-watch-connectivity-pro` (0.2.3 current). Semver: any change to method names, event names, event payload shapes, or rejection codes is MAJOR. Additive fields on event bodies / result objects are MINOR. The consuming app pins via `file:packages/...` during development.