Context
React Native was released with iOS support in 2015 using WebKit’s JavaScriptCore as the JavaScript runtime. JavaScriptCore is one of the libraries that ships with iOS, so using it had no additional overhead on iOS. WebKit is open source, so when React Native for Android was released, it shipped with a custom fork of JavaScriptCore. This resulted in larger bundle sizes on Android, due to the inclusion of JSC in every React Native app. And while the intent was to have React Native use the same runtime on both iOS and Android, in practice, the JSC versions differed as the version that ships with iOS is pinned to each iOS release, while the version that ships with React Native Android apps is pinned to each React Native release.
Hermes is a JavaScript engine optimized for React Native, developed at Meta. It is used in the Facebook apps for iOS and Android. It was released publicly in 2019 with opt-in support for React Native apps on Android. In 2021, opt-in support for iOS was announced, but JavaScriptCore remained as the default runtime for all React Native apps in open source.
Over the past year, the React Native and Hermes teams have been working together towards making Hermes the default engine. This past summer we announced the upcoming release of React Native 0.70 with Hermes as the default engine for React Native. Now that React Native 0.70 has been released, and with additional improvements shipping soon with React Native 0.71, the Hermes / React Native integration projects will be wrapping up, and I figured this would be a good time to go over what has been accomplished. The following post was written around the time at which my tenure at Meta ended.
What we did in 2022
H1 2022: Integrating Hermes into the React Native Release Process
Under the new architecture, React Native uses JSI as a lightweight API layer to communicate with the JS runtime. If Hermes is built with a different version of JSI than React Native, this can lead to bugs or crashes.
A copy of JSI is automatically synced from xplat/jsi
to xplat/hermes/jsi
and xplat/js/react-native-github/ReactCommon/jsi
. When we build a React Native app with Hermes in the fbsource
monorepo, we can be certain that JSI is in sync across Hermes and React Native. Things are different in open source, however, as Hermes is hosted on a separate git repository from React Native, and each project has its own release process.
When opt-in support for Hermes was added to React Native, the version of Hermes that was used by React Native was managed like any other third-party dependency from npm or CocoaPods. Before creating a new React Native release, we would need to release a new version of Hermes, and bump the Hermes dependency in React Native. This required close coordination in order to minimize the delta in JSI between Hermes and React Native.
If we were going to make Hermes the default engine, we needed to make sure there was no room for introducing disparities in JSI between Hermes and React Native. We would need to integrate Hermes into the React Native release process.
Milestone 1 – Goal: Prototype build from source on Android and iOS
The project plan was formulated as part of the first milestone. React Native would no longer consume Hermes as a third-party dependency. React Native would stop relying on the Hermes release process, and instead we would build Hermes from source as part of creating a React Native release. This would allow us to build Hermes with the same version of JSI that is synced to the React Native open source repository.
We developed the initial implementations for building an arbitrary copy of Hermes from source with Gradle on Android and with CocoaPods on iOS. With the prototype working, we declared the Milestone 1 completed and set out to finish the integration in Milestone 2.
Milestone 2 – Goal: Hermes is built from source, on either Android or iOS, in an open source React Native release
With the prototypes ready, we started integrating Hermes into the React Native release process. First off, when React Native itself is built, we download the source code for Hermes from its open source git repository, and then we build Hermes from source but using the same copy of JSI from the React Native open source git repository instead of the copy of JSI from the Hermes repository.
Then, before creating a React Native release, we create a git tag on the Hermes repository, and store a reference to this tag in the React Native release itself. With this, we can make sure that we pin the version of Hermes that is used for each React Native release.
This process was integrated into React Native’s Circle CI. Now, for every commit that syncs to React Native in open source, we are able to test that Hermes can be built, and that all of the open source tests are green when React Native uses that specific version of Hermes. This allows us to catch any regressions right away, when before these may have remained undetected until the Hermes dependency was bumped prior to creating a React Native release.
With support for building Hermes from source now available on both Android and iOS, we considered Milestone 2 to be completed successfully. These changes eventually made it into an open source release when React Native 0.69 was released in June, marking the first version of React Native to ship with “Bundled Hermes” on both Android and iOS.
Milestone 3 – Goal: Hermes is publicly announced as the default engine on either Android or iOS
One of the blockers towards making Hermes the default on iOS was lack of support for the Internationalization APIs (Intl
). Most of the missing Intl
functions were added to iOS by the end of this milestone.
Another blocker to making Hermes the default was extremely long build times on iOS because we needed to build it four times: once for each of the supported Apple platforms (iOS device, iOS Simulator, macOS, and MacCatalyst). Before, all four would be built on Circle CI as part of the Hermes release process, and these prebuilt artifacts would be downloaded by CocoaPods from the Hermes release on GitHub. So to resolve this issue, we now also generate prebuilt artifacts on React Native’s Circle CI, which are uploaded as part of the GitHub Release.
With this, people using Hermes on a published React Native release would not need to re-build it from source. We made the switch to make Hermes the default engine on both Android and iOS; the next public release of React Native, 0.70, would ship with Hermes as the default. Although the public announcement happened a week after Milestone 3 ended, we still considered this a success as the changes on the React Native side were ready on time and the timing of the announcement was partly determined by our Comms review process with Apple.
H2 2022: Hermes iOS Build Integration
With Hermes now the default for all React Native apps, we set out to determine the remaining work that needed to be done to provide a great experience when using Hermes on iOS. We landed on these issues:
- When Hermes is enabled, source maps are not output, which leads to increased bundle sizes.
- Integrating Hermes into the Xcode build pipeline would allow us to build Hermes for the targeted platform, mitigating the long build times on iOS when prebuilts are not available.
- The Hermes debugger is always included, which lead to increased build app sizes.
- The prebuilt Hermes artifacts do not contain debug symbols for iOS, which prevents symbolication of Hermes stack traces.
- Although we now use the same source code when JSI is built by Hermes and by React Native, we still had two separate JSI static libraries, causing a violation of the C++ One Definition Rule.
Milestone 4 Goal: Output source maps
Fixing the source maps was allotted to Milestone 4. Source maps are now output when Hermes is built, so this short Milestone was considered a success.
Milestone 5 Removing the Hermes debugger, integrating with Xcode, solving ODR, and testing on Sandcastle
Most of the rest of the remaining work was allotted to Milestone 5, with support for symbolication of stack traces allotted to Milestone 6.
Building Hermes is now done as part of a Xcode build phase. This allows us to observe the target build parameters. Instead of pre-building Hermes for all supported platforms, we now only build Hermes for the current target, leading to massive savings in total build time when prebuilt artifacts are not available. The only artifact that is still built during pod install
is hermesc
, since it needs to be built for macOS regardless of which platform the React Native app will target.
The ODR violation with JSI was resolved by splitting JavaScriptCore out of React-jsi
and into its own React-jsc
Pod, then updating the CocoaPods configuration for React-jsi
so that React Native does not build its own copy of JSI when Hermes is enabled.
To avoid shipping the Hermes debugger in release builds, we created separate Hermes prebuilts for debug and for release. Now, only the debug Hermes builds ship with the Hermes debugger.
Finally, we added Hermes variants to our open source tests on Sandcastle to catch regressions at diff and land time – not just when these get synced to open source.
All of this work was completed successfully in Milestone 5.
Milestone 6 Debug Symbols
Although we are only one week into Milestone 6, we have already completed the remaining work. Symbolication of Hermes stack traces on iOS is now possible, with debug symbols now forming part of the release artifacts for a React Native release. The dSYMs are provided in a separate tarball, and we will document the process for getting these in the React Native docs.
We expect these changes to be cherry-picked into the ongoing 0.71 release candidate.
Summary
In 2022, we finally transitioned Hermes from being opt-in, to now becoming the default engine for all React Native apps.
Great thanks to Nicola, Dmitry, Riccardo, Neil, John, Michael, and everyone on the Hermes team who helped us get to this point, and thanks to Kevin, Aleksandar, Seth, and Yuzhi for supporting us.