react-native icon indicating copy to clipboard operation
react-native copied to clipboard

Bridgeless mode: async JS "stacking up" when MainActivity is in the background

Open descorp opened this issue 9 months ago • 25 comments

Description

Hi everyone, I'm encountering a really strange issue and hoping someone here might have some insight.

Here's the problem: In any new React Native app generated using @react-native-community/cli, when a secondary Activity is presented (and the main Activity is in the background), asynchronous JavaScript code seems to stop functioning correctly. It appears to "stack up" and only executes after the secondary Activity is dismissed.

For context, I'm developing a React Native package that has worked without this issue for the past two years. My local test app also works fine, and many users are using it successfully on modern React Native versions (I know of at least one on 0.76.7). This issue seems specific to freshly created apps.

I've created a minimal repo to reproduce the problem: https://github.com/descorp/RNAlertTest.

Interestingly, one user reported that turning off the "new Architecture" resolved this "thread freezing" problem for them, but I haven't been able to reproduce this fix locally. My suspicion is that something has changed in the project generation process with @react-native-community/cli compared to the older npx react-native init, but I'm not sure what it could be.

Has anyone else experienced anything similar or have any ideas on what might be causing this? Any help would be greatly appreciated!

Steps to reproduce

  1. Clone https://github.com/descorp/RNAlertTest
  2. Run yarn && yarn android
  3. Press button to open dialog
  4. Press "Send event" on dialog and observe new messages console log
  5. Press "Send event async" on dialog and observe no new messages console log
  6. Dismiss dialog
  7. Observe debug console will all "stacked" async messages

Abstract:

  1. Initiate new project
  2. Add native module that presents activity above MainActivity (if there is a faster way, use it 😅).
  3. Open app and call new activity
  4. Call any async code (e.x. await sleep(100) or fetch('www.google.com'))
  5. Observe async code executed only after MainActivity on foreground

React Native Version

0.78.2

Affected Platforms

Runtime - Android

Output of npx @react-native-community/cli info

System:
  OS: macOS 15.3.2
  CPU: (16) arm64 Apple M4 Max
  Memory: 185.73 MB / 64.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 22.11.0
    path: /usr/local/adyen/nodejs/bin/node
  Yarn: Not Found
  npm:
    version: 11.0.0-pre.0
    path: /usr/local/adyen/npm/bin/npm
  Watchman: Not Found
Managers:
  CocoaPods:
    version: 1.14.3
    path: /Users/vladimir/.local/share/gem/ruby/3.2.0/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 24.1
      - iOS 18.1
      - macOS 15.1
      - tvOS 18.1
      - visionOS 2.1
      - watchOS 11.1
  Android SDK:
    API Levels:
      - "33"
      - "34"
      - "35"
    Build Tools:
      - 30.0.3
      - 33.0.1
      - 34.0.0
      - 35.0.0
      - 35.0.0
      - 36.0.0
    System Images:
      - android-34 | Google Play ARM 64 v8a
    Android NDK: Not Found
IDEs:
  Android Studio: Not Found
  Xcode:
    version: 16.1/16B40
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 21.0.6
    path: /usr/bin/javac
  Ruby:
    version: 3.2.2
    path: /usr/local/adyen/bin/ruby
npmPackages:
  "@react-native-community/cli": Not Found
  react:
    installed: 18.2.0
    wanted: 18.2.0
  react-native:
    installed: 0.74.7
    wanted: 0.74.7
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: false

Stacktrace or Logs

LOG  AppState active
 LOG  
 LOG  Timer works before async
 LOG  Custom activity started
 LOG  AppState background
 LOG  Event sync
 LOG  Event sync
 LOG  AppState active
 LOG  Timer works after async
 LOG  Event async
 LOG  Event async

Reproducer

https://github.com/descorp/RNAlertTest

Screenshots and Videos

Image

descorp avatar Mar 27 '25 16:03 descorp

[!WARNING] Could not parse version: We could not find or parse the version number of React Native in your issue report. Please use the template, and report your version including major, minor, and patch numbers - e.g. 0.76.2.

react-native-bot avatar Mar 27 '25 16:03 react-native-bot

[!WARNING] Could not parse version: We could not find or parse the version number of React Native in your issue report. Please use the template, and report your version including major, minor, and patch numbers - e.g. 0.76.2.

react-native-bot avatar Mar 27 '25 16:03 react-native-bot

Hi @descorp thanks for the issue.

I highly doubt that the problem is related to the react-native-community/cli. The old react-native init was forwarding the initialization to react-native-community/cli under the hoods.

It is more likely that we changed something in the execution model and we "pause" the JS thread if we see that the activity is not active. I need to ask around as I don't have context about it.

Perhaps @cortinico or @rubennorte knows more about it.

Does it happens on Android only or also on iOS?

cipolleschi avatar Mar 28 '25 09:03 cipolleschi

Does it happens on Android only or also on iOS?

Only on Android. On iOS we are using the root view controller for presentation. To draw an analogy, using a Fragment instead of an Activity works as expected since MainActivity is in the foreground and async JS code not "stacking".

Before you ask, rewriting the native library too use Fragments instead of Activity is not a feasible option 😅

It is more likely that we changed something in the execution model and we "pause" the JS thread if we see that the activity is not active.

I have noticed similar behaviour on Android when app is dismisses (which make sense). Could be that at some point App's lifecycle was changed to MainActivity lifecycle f?

descorp avatar Mar 28 '25 12:03 descorp

It could be. I don't have a lot of details on the Android side, unfortunately. @cortinico can you have a look at this? I was asking internally and they told me that we always intended to pause JS execution when the Activity hosting React Native is not active, but it looks like that it was not implemented properly in the Old Arch, while it is in the New Arch... and now it's braking your library which was relying on that behavior... 😅

cipolleschi avatar Mar 31 '25 09:03 cipolleschi

Thanks @cipolleschi

that it was not implemented properly in the Old Arch, while it is in the New Arch...

What grinds my gears is the fact, that disabling New Arch does not resolve it for me locally, and yet not stoping some library consumers with latest SDK to use it..

we always intended to pause JS execution when the Activity hosting

I understand the intent. Important question - will this eventually be backward compatible or I should start looking at some "background JS runner" solution?

descorp avatar Mar 31 '25 09:03 descorp

I don't have an answer right now, unfortunately. I think we have to discuss this more thoroughly internally, to understand what will be the right execution model for this use case, and that's not a decision a can make myself. I'll keep this issue updated as soon as I learn more about this.

cipolleschi avatar Mar 31 '25 12:03 cipolleschi

Thanks!

we always intended to pause JS execution when the Activity hosting

Just to emphasise, sync JS code works perfectly on background (you can see that on my reproducer repo). Async code is the problem.

I am not a JS core expert, but my assumption is that something is off with the way how Tasks are scheduled on event loop while Activity on a background.

descorp avatar Apr 02 '25 08:04 descorp

i remember seeing something similar when there was an effort to make headlessJsTaskService to be newarch compatible. this PR ended up solving it. i dunno how practical it is to mess with the new timer manager. perhaps adding a persisting headlessJsTaskService will ensure the timer not getting paused and be a quick patch with proper clean up (RNTP works fine this way).

I've noticed setState also "stacks up" when MainAcitivty is backgrounded with new arch. I have no real solution other than pausing it all together.

lovegaoshi avatar Apr 07 '25 22:04 lovegaoshi

Thanks for the tip @lovegaoshi I also see this issue that seems related.

As far as I understand, problem is the setTimeout it is not ticking while on background because of HeadlessJsTaskContext limitation ?


Again, what grinds my gears is the fact, that setting newArchEnabled=false does not resolve it. Issue persist on 0.72 and 0.78 alike with and without newArchEnabled.

descorp avatar Apr 09 '25 14:04 descorp

rather than a limitation, I see all of this working as intended - jstimer's onHostPause is functioning as expected and paused all async execution I'm presuming, unless there are active headlessJsTasks. sync js code executes just fine. I dont see this is a new vs old arch thing either. perhaps you can find something by diffing your working test app with a RN template at the time, it shouldnt work to begin with

unfortunately i dont have any experience on this without involving headlessJsTaskService. the other pointer I have is this seemingly setState "stacking" issue only with the new arch, but since u mentioned neither arch work, i doubt this is a real lead.

lovegaoshi avatar Apr 09 '25 20:04 lovegaoshi

Okay, I've investigated further and believe I've identified the underlying causes:

Following behaviour reproduced on blank new 0.78.2 app:

  1. setTimer() do not work while on background for both "old" and "new arch". I shoot myself in a foot assuming that a simple async/await with setTimeout would be a reliable way to "reproduce" asynchronous behavior. Thanks @lovegaoshi for highlighting this area!
  2. new Promise(resolve => ... ) only work on background in a "bridge" mode (bridgelessEnabled = false or newArchEnabled=false)

This observation aligns with why the majority of users haven't experienced issues (likely using older RN versions or configurations) and why switching to the "old architecture" resolves the problem for those who do.


@cipolleschi does this behavior align with the React Native community's vision for background task execution, or should either of these points be considered for future improvement or clarification? (in other words "bug or feature?")

descorp avatar Apr 11 '25 09:04 descorp

I really have to defer this to @cortinico as he has more experience on Android than me.

cipolleschi avatar Apr 11 '25 10:04 cipolleschi

@cortinico

Just a follow-up. Is there any new information/better understanding of what is going on?

descorp avatar Apr 28 '25 12:04 descorp

@descorp not yet, but we want to prioritize the issue. Bear with us for a little more! 🙏

cipolleschi avatar Apr 28 '25 12:04 cipolleschi

Hey @descorp I've tried to reproduce your issue here:

  • https://github.com/facebook/react-native/pull/51052

But the behavior is the same between old and new architecture. Can I ask you to try it as well and confirm you're seeing the same behavior between old and new arch?

cortinico avatar May 01 '25 16:05 cortinico

Hey @cortinico

Thanks for looking into this!

But the behavior is the same between old and new architecture.

Sorry, the code you have copied to your reproducer is using setTimeout that causing troubles on it's own. You can replace it with fetch, for example:

function sleep(ms: number) {
  return new Promise(async resolve => { 
    await fetch("https://google.com");
    resolve(1)
  });
}

To be specific, the difference is not between new\old arch, but weather or not a "bridgeless" mode is enabled.

descorp avatar May 07 '25 13:05 descorp

To be specific, the difference is not between new\old arch, but weather or not a "bridgeless" mode is enabled.

Toggling newArchEnabled=true or =false effectively turns on and off bridgeless mode as well. From my test, it seems like the behavior is the same with bridgeless/newarch ON or OFF.

cortinico avatar May 07 '25 14:05 cortinico

behavior is the same

  1. can you confirm "stacking up" events till MainActivity is back on foreground ?
  2. Is this consistent if you get rid of setTimeout ?

TBH, I haven't figured how to run reproducer you have shared above. However I can reproduce "stacking" on 0.79.2 and 0.77.2 blank project(s). I can share a .patch file so you can play around with blank project.

descorp avatar May 07 '25 14:05 descorp

@descorp A patch would be great. You can also clone the react-native repository and create a PR modifying the RNTesterPlayground.js file. RNTester is a tester app we use to test react-native internally. Instructions to run it are here, although they might be a bit outdated.

cipolleschi avatar May 08 '25 09:05 cipolleschi

  • can you confirm "stacking up" events till MainActivity is back on foreground ?

Yes correct

  • Is this consistent if you get rid of setTimeout ?

I will try and let you know.

In the meantime, if you can get your environment working up to be able to play with the reproducer I shared https://github.com/facebook/react-native/pull/51052 would be better.

You can find instruction about how to do it here: https://reactnative.dev/contributing/how-to-report-a-bug#rntesterplaygroundjs

A .patch file could also be helpful but given the nature of this bug, having the reproducer inside RNTEster would be better

cortinico avatar May 08 '25 10:05 cortinico

I am attaching .patch file.

npx @react-native-community/cli@^18.0.0 init rn_reproducer --version 0.79.2 --install-pods false TestProject
cd  rn_reproducer
git apply ../reproducer_79.patch
yarn install
yarn android

reproducer_79.patch


Will look into setting up RNTester

descorp avatar May 08 '25 12:05 descorp

Hey @cortinico @cipolleschi,

Unfortunately, I wasn't able to run reproducer #51052 successfully.

More details

When using react-native-cortinico, I'm encountering multiple build errors related to hermes:

/react-native-cortinico/packages/react-native/sdks/hermes/API/hermes/../hermes/hermes.h:52:50: error: expected class name
/react-native-cortinico/packages/react-native/sdks/hermes/API/hermes/../hermes/hermes.h:54:25: error: no type named 'UUID' in namespace 'facebook::jsi'
...

fatal error: too many errors emitted, stopping now [-ferror-limit=]
C/C++: 20 errors generated.

> Task :packages:react-native:ReactAndroid:hermes-engine:buildCMakeRelease[arm64-v8a][libhermes] FAILED
Image

However, I was able to reproduce the issue in this PR.
You can see in the DevTools log that the behavior changes when load(bridgelessEnabled = false) is used:

Image

Image

To reproduce:

  • tap "OPEN ALERT"
  • tap "SEND EVENT" couple of times
  • tap "SEND ASYNC EVENT" couple of times
  • tap "DISMISS"

Please let me know if I can present it in a more digestible way.

descorp avatar May 15 '25 15:05 descorp

Hey @cortinico @cipolleschi

Just want to check on what would you recommend here:

Can we expect this to be fixed? Any workarounds we can try?

descorp avatar May 27 '25 13:05 descorp

Just want to check on what would you recommend here: Can we expect this to be fixed? Any workarounds we can try?

I don't have an ETA to share. It's in my (sadly really long) list of bugs to look into. If someone from the community can look into this and suggest a Pull Request, I can make sure this gets prioritized accordingly.

cortinico avatar May 27 '25 13:05 cortinico

Thanks @cortinico


I have spotted this article :

Starting from React Native 0.76, Turbo Native Modules have a new, type-safe API to emit events from the Native layer to the JS layer.

Any chance that switching to Turbo modules can improve "background" execution of JS?

descorp avatar Jun 06 '25 15:06 descorp

Hey @descorp I spent some time investigating this one and have some updates for you.

tl;dr: the app is behaving as expected.

  1. So I run your reproducer with the Dialog and the 2 buttons. The version of it using setTimer has the behavior you mentioned. The setTimer won't execute till the React Native Activity is back in the foreground.

That's due to the behavior in JavaTimerManager which is pausing all the timer execution when the ReactHost invokes onPause/onDestroy.

This is the expected behavior as otherwise animations, and rendering will continue even when the Activity is not on screen anymore.

Also the behavior is the same between old and new arch, so I can't find anything for us to fix here.

  1. I've also tried the variant you mentioned here: https://github.com/facebook/react-native/issues/50327#issuecomment-2858687968 using fetch instead of setTimer and it behaves correctly (i.e. things are executing when you click either of the two buttons) on both Old and New Arch.

So also here I don't know what else we're supposed to fix.

Please let me know if there is more that we should look into. I'll be closing this for now.

cortinico avatar Jun 19 '25 17:06 cortinico

Hey @cortinico

Thanks for checking! Have you tried this reproducer: https://github.com/facebook/react-native/pull/51394 ?

You should be able to see in the DevTools log that the behavior changes when load(bridgelessEnabled = false/true) is used:

  • bridgelessEnabled = false matches behavior on Old architecture.
  • bridgelessEnabled = true (default) cause "queuing" of all async calls until ReactHost back onResume.

setTimer

This is the expected behavior as otherwise animations, and rendering will continue even when the Activity is not on screen anymore.

I would like to challenge this statement. In my example the MainActivity is still visible in the background (and expected to respond to events)— it's onPause, but still on screen. I understand that it's hard to draw a precise line between these states on Android, but perhaps an option to manually tell the ReactActivity to "stay active" would be a nice addition.

Also the behavior is the same between old and new arch, so I can't find anything for us to fix here.

Fair, that part works exactly as it used to.

descorp avatar Jun 19 '25 17:06 descorp

Just pinging to keep it alive :)

descorp avatar Jul 08 '25 16:07 descorp

I would like to challenge this statement. In my example the MainActivity is still visible in the background (and expected to respond to events)— it's onPause, but still on screen. I understand that it's hard to draw a precise line between these states on Android, but perhaps an option to manually tell the ReactActivity to "stay active" would be a nice addition

To answer this one: you can use the HeadlessJsTaskContext.startTask(...) API to start a task. That will prevent the timer from pausing when your activity invokes the onPause.

I understand what you're saying, that you're activity is not on the foreground but it's still visible. When we implemented the jstimers back then, we decided to pause them when the Activity invokes the onPause callback.

In theory we could change this to be the onStop (which is what you're asking for). However the problem is that this would be a breaking change and there might be app out there still relying on this behavior. So using the HeadlessJsTaskContext API is probably your best solution here.

cortinico avatar Jul 11 '25 15:07 cortinico