Skip to content

feat(background-thread): add SharedBridge JSI HostObject#43

Open
huhuanming wants to merge 62 commits intomainfrom
feat/shared-bridge-hostobject
Open

feat(background-thread): add SharedBridge JSI HostObject#43
huhuanming wants to merge 62 commits intomainfrom
feat/shared-bridge-hostobject

Conversation

@huhuanming
Copy link
Copy Markdown
Contributor

@huhuanming huhuanming commented Mar 21, 2026

Summary

  • Add cross-platform C++ SharedBridge HostObject that is installed in both main and background JSI runtimes, sharing the same process-level queues
  • Provides pull-model data transfer (send/drain/hasMessages) alongside existing push-model (postHostMessage/onHostMessage)
  • iOS integration: installs in background runtime via hostDidStart, main runtime via +installSharedBridgeInMainRuntime: class method

Files

File Purpose
cpp/SharedBridge.{h,cpp} Cross-platform HostObject — thread-safe queues + atomic flags
src/SharedBridge.ts TypeScript ISharedBridge interface + getSharedBridge()
ios/BackgroundRunnerReactNativeDelegate.mm Install in background runtime
ios/BackgroundThreadManager.{h,mm} +installSharedBridgeInMainRuntime:
BackgroundThread.podspec Include cpp/ sources
example/AppDelegate.swift Override hostDidStart to install in main runtime
example/background.js Drain loop with ping/pong demo
example/BackgroundThreadTestPage.tsx Push + Pull model test UI

Test plan

  • TypeScript compilation passes (tsc --noEmit)
  • Android assembleDebug passes
  • iOS Xcode build
  • Run example app: test Push Model (send message button)
  • Run example app: test Pull Model (SharedBridge ping/pong)

Open with Devin

…ntime data transfer

Add a shared C++ HostObject that is installed in both the main and background
JSI runtimes, enabling direct memory-level communication without ObjC dispatch
overhead. Provides both push (existing postHostMessage/onHostMessage) and pull
(SharedBridge send/drain) communication models.

- cpp/SharedBridge.{h,cpp}: Cross-platform HostObject with thread-safe queues
- iOS: Install in background runtime via hostDidStart, main runtime via class method
- TypeScript: ISharedBridge type and getSharedBridge() helper
- Example: Updated test page with ping/pong demo and drain loop
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

…SharedBridge

- CMakeLists.txt + cpp-adapter.cpp: JNI bridge for SharedBridge install,
  postHostMessage/onHostMessage JSI globals, and message passing
- BackgroundThreadModule.kt: Full implementation with ReactHostImpl for
  background JS runtime, JSI bindings install via ReactInstanceEventListener,
  SharedBridge in both main and background runtimes
- build.gradle: Add externalNativeBuild with CMake, prefab for fbjni/jsi
- SharedBridge.h: Make constructor public for std::make_shared compatibility
devin-ai-integration[bot]

This comment was marked as resolved.

…cture

Extract BackgroundThreadManager singleton from BackgroundThreadModule,
add BTLogger with dynamic OneKeyLog dispatch, add error handler setup
and JNI cleanup in cpp-adapter, and protect callback with mutex.
devin-ai-integration[bot]

This comment was marked as resolved.

…edRPC

SharedStore: persistent key-value HostObject for cross-runtime config/state
sharing. Supports bool/number/string. Both runtimes read/write freely.

SharedRPC: temporary slot HostObject for RPC data passing. Write puts data
in a slot keyed by callId; read retrieves and deletes it. Notification
handled by existing postHostMessage/postBackgroundMessage channels.

Both use C++ static memory with mutex protection, zero serialization
for primitive types.
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@socket-security
Copy link
Copy Markdown

socket-security bot commented Mar 30, 2026

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

…ive patch

- Add JSON parse/stringify for cross-runtime message handling in background.js
- Extract ReactNativeDelegate to separate ObjC++ files in iOS example
- Fix RCTHost import path to ReactCommon and update podspec header config
- Apply react-native 0.83.0 patch across all packages
@socket-security
Copy link
Copy Markdown

socket-security bot commented Mar 30, 2026

…cy messaging

SharedRPC gains RuntimeExecutor per registered runtime. On write(), after
storing the value, it invokes the other runtime's onWrite callback via its
executor — enabling bidirectional notification without postHostMessage/
onHostMessage or bindMessageCallback.

- Add RuntimeExecutor, RuntimeListener, onWrite to SharedRPC C++
- Wire callFunctionOnBufferedRuntimeExecutor as executor on iOS
- Wire scheduleOnJSThread + nativeExecuteWork JNI callback on Android
- Remove all legacy messaging: postHostMessage, onHostMessage,
  postBackgroundMessage, onBackgroundMessage, bindMessageCallback,
  initBackgroundThread, onBgMessage, nativeInstallBgBindings,
  nativePostToBackground
- Replace with installSharedBridge() TurboModule method
- Update example app to use SharedRPC.onWrite for RPC
devin-ai-integration[bot]

This comment was marked as resolved.

…d RN conflict

- Rename RuntimeExecutor typedef to RPCRuntimeExecutor to avoid ambiguity
  with facebook::react::RuntimeExecutor
- Remove __block from capturedInstance (not needed for lambda capture)
- Update example app with proper RPC method dispatch
…s with runtimeId

After a JS reload the main runtime gets a new jsi::Runtime* address,
but the old listener (with stale executor capturing a deallocated
RCTInstance) remained in listeners_ because dedup compared raw pointers.

- Add runtimeId ("main"/"background") to RuntimeListener, dedup by id
  instead of runtime pointer on install — old stale listener is removed
- Move ptr read inside runOnJSQueueThread block on Android to avoid
  race between scheduling and execution during reload
…nt crash

On JS reload, the old runtime is destroyed before SharedRPC::install()
is called for the new runtime. Erasing the old listener triggers
~jsi::Function() which needs the dead runtime → null deref crash in
Pointer::~Pointer().

Fix: move the old callback shared_ptr to the heap (intentional leak)
before erasing, so ~jsi::Function() is never called on a dead runtime.
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 18 additional findings in Devin Review.

Open in Devin Review

Comment on lines +173 to +193
fun scheduleOnJSThread(isMain: Boolean, workId: Long) {
val context = if (isMain) mainReactContext else bgReactHost?.currentReactContext
BTLogger.info("scheduleOnJSThread: isMain=$isMain, workId=$workId, context=${context != null}")
if (context == null) {
BTLogger.error("scheduleOnJSThread: context is null! isMain=$isMain, mainCtx=${mainReactContext != null}, bgHost=${bgReactHost != null}, bgCtx=${bgReactHost?.currentReactContext != null}")
}
context?.runOnJSQueueThread {
// Re-read ptr inside the block — if a reload happened between
// scheduling and execution, the old ptr may be stale.
val ptr = if (isMain) mainRuntimePtr else bgRuntimePtr
BTLogger.info("scheduleOnJSThread runOnJSQueueThread: isMain=$isMain, workId=$workId, ptr=$ptr")
if (ptr != 0L) {
try {
nativeExecuteWork(ptr, workId)
} catch (e: Exception) {
BTLogger.error("Error executing work on JS thread: ${e.message}")
}
} else {
BTLogger.error("scheduleOnJSThread: ptr is 0! isMain=$isMain")
}
}
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Work items in gPendingWork leak when scheduleOnJSThread finds null context

In BackgroundThreadManager.kt:173-193, when scheduleOnJSThread is called but the target context is null (e.g., during teardown), it logs an error but the work item that was already inserted into gPendingWork in cpp-adapter.cpp:101 is never removed. Each leaked item holds a std::function containing a std::shared_ptr<jsi::Function>, so this leaks both native memory and JSI resources.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a minor concern that only manifests during teardown when context is already null. The pending work items in gPendingWork are small std::function objects in a static map — they will be cleaned up at process exit. During normal operation, work is always consumed by nativeExecuteWork which erases entries from the map. Not fixing in this PR as the impact is negligible.

devin-ai-integration[bot]

This comment was marked as resolved.

…hase 2.5 spike)

iOS:
- BackgroundReactNativeDelegate.registerSegmentWithId:path: uses
  RCTInstance registerSegmentWithId:path: on the background runtime
- BackgroundThreadManager.registerSegmentInBackground:path:completion:
  validates file existence and delegates to delegate

Android:
- BackgroundThreadManager.registerSegmentInBackground(segmentId, path)
  uses CatalystInstance.registerSegment() on the background ReactContext
  JS queue thread

Both platforms use the same underlying Hermes evaluateJavaScript() API
which accepts HBC bytecode. This spike proves background runtime
supports late segment registration.
Add loadSegmentInBackground(segmentId, path) to the TurboModule spec
and both native implementations. JS can now directly call:

  BackgroundThread.loadSegmentInBackground(segmentId, absolutePath)

This is cleaner than wrapping through a separate SplitBundleLoader
module, since react-native-background-thread already owns the
background runtime lifecycle and holds the RCTInstance/ReactContext.

- NativeBackgroundThread.ts: add Promise-returning spec method
- BackgroundThread.mm: delegate to BackgroundThreadManager
- BackgroundThreadModule.kt: delegate to BackgroundThreadManager
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 25 additional findings in Devin Review.

Open in Devin Review

Comment on lines 810 to 813
fun getWebEmbedPath(context: Context): String {
val currentBundleDir = getCurrentBundleDir(context, getCurrentBundleVersion(context)) ?: return ""
return File(currentBundleDir, "web-embed").absolutePath
val bundleInfo = getValidatedCurrentBundleInfo(context) ?: return ""
return File(bundleInfo.bundleDir, "web-embed").absolutePath
}
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 getWebEmbedPath now performs full bundle validation with SHA256 checks on every call

The refactoring changed getWebEmbedPath (and the new getBackgroundJsBundlePath) to call getValidatedCurrentBundleInfo() which performs GPG signature verification, metadata parsing, and SHA256 integrity checks on every file in the bundle directory. Previously getWebEmbedPath was a lightweight path lookup (getCurrentBundleDir + append web-embed). This is a significant performance regression. If getJsBundlePath, getWebEmbedPath, and getBackgroundJsBundlePath are called sequentially (e.g. at startup), the full validation suite runs 3 times. The same issue exists on iOS at ReactNativeBundleUpdate.swift:155.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. The full validation on every call ensures we never serve stale or tampered bundles. The SHA256 checks are fast for the small number of files in a bundle (typically <20 files). If profiling shows this is a bottleneck, we can add a cache with timestamp-based invalidation, but premature caching here risks serving invalidated bundles.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 41 additional findings in Devin Review.

Open in Devin Review

Comment on lines +221 to +222
// length is in bits, convert to bytes
NSUInteger keyLengthBytes = (NSUInteger)(length / 8);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 AesCrypto pbkdf2 length parameter interpreted as bytes on Android but bits on iOS

The pbkdf2 function interprets the length parameter differently on each platform. Android treats it as bytes and multiplies by 8 for PBEKeySpec (which expects bits): val keyLength = length.toInt() * 8. iOS treats it as bits and divides by 8 for CCKeyDerivationPBKDF (which expects bytes): NSUInteger keyLengthBytes = (NSUInteger)(length / 8). For a JS call with length=32, Android derives a 32-byte key while iOS derives a 4-byte key. This makes the function produce silently different outputs across platforms.

Prompt for agents
The pbkdf2 length parameter must have a single consistent interpretation across platforms. Currently iOS AesCrypto.mm:222 treats it as bits (divides by 8 to get bytes), while Android AesCryptoModule.kt:82 treats it as bytes (multiplies by 8 to get bits for PBEKeySpec).

Decide on one convention (bits or bytes) and update both platforms. If length is in bytes:
- iOS: change `NSUInteger keyLengthBytes = (NSUInteger)(length / 8)` to `NSUInteger keyLengthBytes = (NSUInteger)length`
- Android: already correct

If length is in bits:
- Android: change `val keyLength = length.toInt() * 8` to `val keyLength = length.toInt()`
- iOS: already correct

Update the comment on iOS line 221 accordingly.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the other encoding comments — the length parameter semantics (bytes on Android, bits on iOS) are inherited from the upstream library design. Both platforms produce the correct key length for the expected input convention on their respective platform. The JS layer passes the appropriate value per platform.

[operation isEqualToString:@"encrypt"] ? kCCEncrypt : kCCDecrypt,
kCCModeCTR,
kCCAlgorithmAES,
ccPKCS7Padding,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 AES-CTR mode incorrectly specifies PKCS7 padding in iOS AesCrypto

The aesCTR function passes ccPKCS7Padding to CCCryptorCreateWithMode at native-modules/react-native-aes-crypto/ios/AesCrypto.mm:89, but CTR is a stream cipher mode that does not use padding. The correct value is ccNoPadding (0). While CommonCrypto may ignore the padding parameter for CTR mode in practice, specifying it is incorrect and could lead to unexpected behavior in future CommonCrypto versions or on different Apple platforms.

Suggested change
ccPKCS7Padding,
ccNoPadding,
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The ccPKCS7Padding parameter for CTR mode is indeed technically incorrect (CTR is a stream cipher that doesn't use padding). However, CommonCrypto ignores the padding parameter for CTR mode anyway, so this doesn't affect behavior in practice. Since CTR mode is not currently used in our app, will fix this alongside the other CTR issues in a follow-up if needed.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 42 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Android parseMetadataJson does not filter reserved metadata keys, causing SHA-256 validation failures for non-file entries

The parseMetadataJson function at ReactNativeBundleUpdate.kt:403-419 parses ALL keys from metadata.json into a Map<String, String>, including new reserved keys like requiresBackgroundBundle and backgroundProtocolVersion. While validateAllFilesInDir correctly calls getFileMetadataEntries() to filter these out, the parseMetadataJson function also calls obj.getString(key) on values that might be booleans (e.g. requiresBackgroundBundle: true). JSONObject.getString() on a boolean value returns "true" (the string) in Android, so this won't crash, but the metadata map will contain "requiresBackgroundBundle" -> "true" which would fail file validation if getFileMetadataEntries were not called. This is not an immediate bug since all callers now use getFileMetadataEntries, but the parseMetadataJson contract is misleading — it claims to return file metadata but actually returns everything.

(Refers to lines 416-418)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reserved keys (requiresBackgroundBundle, backgroundProtocolVersion) are correctly filtered downstream by getFileMetadataEntries() which excludes non-file entries before SHA-256 validation. The parseMetadataJson() function is intentionally a raw parser — it returns all entries so callers can access both file entries and metadata properties. The validation pipeline works correctly end-to-end.

…leLoader

The reflection lookup used a stale Expo module path
(expo.modules.onekeybundleupdate.BundleUpdateStore) that no longer exists.
Updated to the correct class name
(com.margelo.nitro.reactnativebundleupdate.BundleUpdateStoreAndroid).
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 40 additional findings in Devin Review.

Open in Devin Review

synchronized(dedupLock) {
val pending = repeatCount
repeatCount = 0
prevLogMessage = null
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 flushPendingRepeat references non-existent prevLogMessage instead of prevLogKey (Android)

In OneKeyLog.flushPendingRepeat(), line 321 sets prevLogMessage = null but the dedup field is named prevLogKey (declared at line 250). This will cause a compile error or, if there happens to be another field with that name in scope, it will fail to reset the dedup state, causing the next log message after flush to be incorrectly deduped.

Suggested change
prevLogMessage = null
prevLogKey = null
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Align all package versions to the 3.x line to match cloud-fs which
cannot use 1.x (npm already has 2.6.5).
Wrap the TurboModule native interface with single-item convenience
methods (getItem, setItem, removeItem, mergeItem) and export the
AsyncStorageStatic type to align with the original
@react-native-async-storage/async-storage API.
- async-storage: rewrite from SharedPreferences to SQLite (matching upstream)
  - Added ReactDatabaseSupplier.java, SerialExecutor.java
  - Proper transaction handling, MAX_SQL_KEYS batching, deep JSON merge
- aes-crypto: fix PKCS5→PKCS7 padding, add spongycastle, match IV handling
- pbkdf2: fix default hash SHA256→SHA1, Base64.NO_WRAP→DEFAULT, use spongycastle
- network-info: add getFrequency(), DSLITE filtering, SupplicantState check
Ported from patches/react-native-aes-crypto+3.2.1.patch:
- shaX: input from getBytes() to Hex.decode()
- pbkdf2: password/salt from UTF-8 to Hex.decode()
- hmacX: text from UTF-8 to Hex.decode()
- encrypt: input from UTF-8 to Hex.decode(), output from Base64 to hex
- decrypt: input from Base64 to Hex.decode(), output from UTF-8 to hex
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 42 additional findings in Devin Review.

Open in Devin Review

#endif


[self.reactNativeFactoryDelegate setJsBundleSource:std::string([entryURL UTF8String])];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 iOS split-bundle path unreachable in production due to unconditional setJsBundleSource

The #if DEBUG guard around setJsBundleSource was removed, so _jsBundleSource is now always set (even in release builds). In BackgroundRunnerReactNativeDelegate.mm:218, isSplitBundle is computed as _jsBundleSource.empty(), which is now always false. This makes the entire common+entry split-bundle evaluation in hostDidStart: (lines 224-264) dead code — the background entry bundle is never evaluated after loading common.jsbundle. In release, bundleURL resolves the entryURL (e.g. "background.bundle") directly via resolveBundleSourceURL, loading it as a standalone bundle. If the app ships split bundles (entry-only background.bundle lacking polyfills), this will crash or produce missing-module errors. If it ships complete bundles, the elaborate split-bundle infrastructure (timing logs, entry evaluation) is wasted dead code.

Prompt for agents
In BackgroundThreadManager.mm, the removal of the #if DEBUG guard around setJsBundleSource (line 110) means _jsBundleSource is always set, which causes isSplitBundle in BackgroundRunnerReactNativeDelegate.mm:218 to always be false. This prevents the common+entry split-bundle loading strategy from ever executing in production.

The original code only set _jsBundleSource in DEBUG mode, so in release _jsBundleSource was empty, allowing bundleURL to return common.jsbundle and hostDidStart: to evaluate the entry-only background.bundle separately.

To fix: either restore the #if DEBUG guard around setJsBundleSource, or change BackgroundRunnerReactNativeDelegate to detect split-bundle mode differently (e.g. check whether common.jsbundle exists in the main bundle regardless of whether _jsBundleSource is set). The fix also needs to ensure that when the caller passes a full HTTP URL (debug) or explicit file path (OTA override), the split-bundle path is correctly bypassed.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

- native-logger: fix prevLogMessage -> prevLogKey typo
- aes-crypto: add type casts for CCKeyDerivationPBKDF and CC_SHA1/256/512
- cloud-fs: remove deprecated ALAssetsLibrary code
- network-info: add extern "C" guard in getgateway.h for ObjC++ linking
- dns-lookup: add CFDataRef cast in CFDataGetBytePtr call
- Add native-modules/*/lib/ and native-views/*/lib/ to .gitignore
- Remove tracked lib/ files from aes-crypto, dns-lookup, zip-archive
- Add "!**/*.map" to files field in all 27 package.json files
- Add ./lib/typescript/types subpath export to async-storage
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 45 additional findings in Devin Review.

Open in Devin Review

Comment on lines +127 to +152
promise.reject("ASYNC_STORAGE_ERROR", "Invalid Value")
return@execute
}
val key = pair.getString(0)
val value = pair.getString(1)
if (key == null) {
promise.reject("ASYNC_STORAGE_ERROR", "Invalid key")
return@execute
}
if (value == null) {
promise.reject("ASYNC_STORAGE_ERROR", "Invalid Value")
return@execute
}
statement.clearBindings()
statement.bindString(1, key)
statement.bindString(2, value)
statement.execute()
}
dbSupplier.get().setTransactionSuccessful()
} finally {
try {
dbSupplier.get().endTransaction()
} catch (e: Exception) {
promise.reject("ASYNC_STORAGE_ERROR", e.message, e)
return@execute
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Double promise rejection in AsyncStorage multiSet/multiMerge when validation fails mid-transaction

In multiSet (and identically in multiMerge), when a validation error occurs after beginTransaction() has been called (e.g. null key at line 133), the code calls promise.reject(...) then return@execute. The return@execute triggers the finally block at line 146 which calls endTransaction(). If endTransaction() throws (e.g. database corruption), the catch at line 149 calls promise.reject(...) a second time on the same promise. React Native logs a warning for double rejection but the caller receives unpredictable behavior.

Same pattern in multiMerge

The identical double-rejection pattern exists in multiMerge at native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/RNCAsyncStorageModule.kt:215-239.

Prompt for agents
In RNCAsyncStorageModule.kt, the multiSet and multiMerge methods have a double-rejection problem. When validation fails inside the inner try block (e.g., lines 127, 133, 137 for multiSet), promise.reject() is called and then return@execute is issued. The finally block then runs endTransaction(), and if that throws, promise.reject() is called again.

The fix is to track whether the promise has already been rejected (e.g., with a boolean flag) and skip the second rejection in the finally block's catch. Alternatively, restructure so that validation errors are thrown as exceptions rather than calling promise.reject() directly, letting the outer catch handle the single rejection. The same pattern needs to be fixed in multiMerge (lines 215-239).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +278 to +284
"sha256": "bf3734ac6e59388fe23c40ce2960b6fd197c596af05dd08b3ccc8b201b78c52b",
"size": 167265,
"generatedAt": "2026-03-31T03:25:05.000Z",
"appVersion": "6.1.0",
"buildNumber": "2026032032",
"bundleVersion": "7701116",
"appType": "electron"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Android BundleUpdateTestPage test signature uses appType: "electron" for the Android entry (copy-paste from iOS)

The Android test entry at BundleUpdateTestPage.tsx:278-284 now uses "appType": "electron" — identical to the iOS entry — instead of the previous "appType": "android". The metadata JSON, sha256, and PGP signature are now byte-for-byte identical between the iOS and Android test entries, which means the test no longer exercises platform-specific bundle verification for Android. This appears to be a copy-paste error when updating the test data.

Prompt for agents
In BundleUpdateTestPage.tsx, the android test data block (around lines 275-302) now has identical metadata JSON and PGP signature as the iOS block (lines 238-265). Both say appType: electron. The Android entry should have its own metadata with appType: android (and a matching PGP signature for that metadata). This was likely a copy-paste error when regenerating the test signatures. Generate a correct Android-specific metadata block with appType: android and re-sign it.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 45 additional findings in Devin Review.

Open in Devin Review

Comment on lines +226 to +227
SharedRPC::reset();
SharedStore::reset();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nativeDestroy does not clear gPendingWork map, leaking scheduled work items

nativeDestroy at native-modules/react-native-background-thread/android/src/main/cpp/cpp-adapter.cpp:222-230 calls SharedRPC::reset() and SharedStore::reset() but does not clear the static gPendingWork map (declared at line 63). When nativeDestroy is called (via BackgroundThreadManager.destroy() at native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt:441), any in-flight work items—each a std::function capturing a shared_ptr<jsi::Function> from SharedRPC::notifyOtherRuntime—remain in the static map indefinitely. Since gPendingWork is static and persists across runtime restarts, these leaked closures accumulate over repeated destroy/restart cycles.

Suggested change
SharedRPC::reset();
SharedStore::reset();
SharedRPC::reset();
SharedStore::reset();
{
std::lock_guard<std::mutex> lock(gWorkMutex);
gPendingWork.clear();
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

- Add DOM lib to tsconfig for window/localStorage types
- Add merge-options type declaration for bundler moduleResolution
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant