Lazy Bundle Loading in React Native 🔥

A deep dive into Metro internals and the React Native iOS codebase 🐬

This post is a more detailed explanation of a tweet I did earlier this week:

It involves things that I needed to do to get this experiment to work, mainly:

  • Figuring out how to split react native bundle per screen with Metro
  • Tracking the code path that loads and executes the javascript bundle inside JavascriptCore and replicating it
  • Hooking it up inside a react native app to lazily load the javascript bundle for a screen before navigating to it

Code Splitting using Metro

Metro doesn’t have an out of the box way to split your bundle based on dynamic imports like webpack has using the CommonsChunkPlugin. There is a webpack based bundler for react native called Haul which could help but for the purposes of this experiment I stuck with Metro.

The next option was to write two minimal screens and try to split the unified javascript bundle manually. So I created two variations of my index.js:

LEFT: entry file with Screen A — RIGHT: entry file with Screen A and Screen B

I used these two variations to generate two different javascript bundles using the following react-native CLI command:

react-native bundle \
--platform ios \
--entry-file index.js \
--bundle-output ./ios/main.jsbundle

Next, I diff’ed them in a file diff tool, like DiffMerge.

The first diff looked like this:

LEFT: without ScreenB — RIGHT: with ScreenB

This is how the transpiled portion of the index.js file looks like. It has 3 distinct parts:

  • First part of the diff refers to the extra ScreenB import. Note that _dependencyMap is the array of module ID’s received by this function. So: 
    _dependencyMap[0] = react-native
    _dependencyMap[1] = ScreenA
    _dependencyMap[2]
    = ScreenB
  • Second part refers to the AppRegistry call to register ScreenB with react native
  • Third part refers to Metro providing the ScreenB module ID, 354, to the function so that it can consume it

Each of __d(…) functions refers to a file/module used within your react native app. The second argument of this, 0(zero) in this case, refers to the ID given to our index.js file by Metro.

The second part of the diff looks like this:

LEFT: without ScreenB — RIGHT: with ScreenB

This shows the transpiled ScreenB.js module included in the final bundle with a module ID of 354.

So to create our separate bundle for ScreenB we have to do two things:

1. Update main.jsbundle

  • Removed ScreenB import and registration
  • Removed the module ID for ScreenB, 354, from the dependency map passed to __d(…) in main.jsbundle
  • Removed the transpiled ScreenB.js module as shown in the second part of the diff
Updated main.jsbundle

2. Move code to new file — ScreenB.jsbundle

  • I created a new __d(…) function call which is how a module is registered. I needed to give it a unique ID. I chose 355, as 354 was the last module ID used in main.jsbundle
  • ScreenB is the second element in the dependencyMap array now, instead of third, so I changed the require to use _dependencyMap[1] instead of _dependencyMap[2]
Partial ScreenB.jsbundle

Now we have an “almost” complete ScreenB.jsbundle. It registers the two modules with Metro, but now we want it to “consume” it as well. This can be done by checking the relevant code in main.jsbundle to understand what’s happening:

In main.jsbundle, the actual starting point is the require(0) at the end of the file. This triggers the application start by executing the code for module ID 0(zero), which is our react native application entry file, index.js.

Since we moved ScreenB registration to a separate module, we need to trigger its execution so that it gets registered with react native and is available when we try to load ScreenB from the native side. We do this by adding a require(355) at the end of ScreenB.jsbundle.

Final ScreenB.jsbundle

Now, our code splitting task is complete 🌟


Lazily load and execute a partial bundle inside react native

Every react native app starts with the creation of an RCTBridge instance. In this, react native loads the javascript, either from the local packager or a pre-built bundle, and executes this inside JavascriptCore.

Here’s the chain of events that happens when we call initWithBundleURL:

  1. An instance of class RCTBridge is created and _bundleURL is set to the one we provide it — [Code]
  2. The setUp method of this object is called which initialises RCTCxxBridge (aka batched bridge)— [Code]
  3. The start method of the batched bridge is called — [Code]
  4. This is where the actual bundle loading and execution takes place — [Code]

So to get out custom loading setup working, we need to have two things in place:

1. Setup the bridge to load a custom bundle

Here we need to set the _bundleURL to point to the partial bundle file we have: ScreenB.jsbundle. So we create a custom method inside RCTBridge.m and expose it in RCTBridge.h. This is the only method we need to call from our userland code. Everything is else will be hidden inside the react native code.

Expose a method loadCustomBundle which accepts a string
Implementation of loadCustomBundle which accepts the name of the bundle

Don’t worry about the lazyStart method that is being invoke inside loadCustomBundle. It is still unimplemented and will be done in the next step 🙂

2. Load and execute the new bundle

Here we replicate the logic for the loading and executing javascript from the start method in RCTCxxBridge.mm and copy it to a new method so that we can call it from the loadCustomBundle method. The start method initialises a lot of things and towards the end it will do three things:

  1. Initialise the bridge object which makes the native to JS communication possible — [Code]
  2. Load the javascript bundle based on the bundle URL — [Code]
  3. Since both the above steps are async, it needs to wait for them to complete. This is done by using dispatch_group_notify which is called only when both the async calls complete. After that, it executes the loaded javascript bundle using the bridge object created in step 1 — [Code]

Based on this, our new method, lazyStart, will look like this:

lazyStart method which loads and executes a custom bundle

In this, we don’t need to initialise the bridge object since that is already done when the first bundle was loaded. So we just need to do two things here:

  1. Load the new javascript bundle via loadSource. This will internally use the bundleURL that we set in loadCustomBundle method in the previous step.
  2. Once it is loaded, in its callback block, we execute the javascript bundle. Since we don’t have have two async calls, we don’t need to use dispatch_group_notify.

Next, we need to expose this method inside RCTCxxBridge.mm so that we can invoke it from the loadCustomBundle method in RCTBridge.m. We can do this by adding it to the interface in RCTBridge+Private.h.

Expose lazyStart in the RCTBridge interface for the RCTCxxBridge category

An overview of the changes can be seen in this github diff: https://github.com/karanjthakkar/react-native/compare/v0.56.0...rn_lazy_bundle_loading 🤓


Hooking up lazy loading code with user land code

Now that we have a partial bundle and the code to load it ready, we need two things to wrap it all up.

1. Add ScreenB.jsbundle to bundle resources in XCode

The default react native project generated by the react-native-cli comes with main.jsbundle added to bundle resources. These are resources that XCode bundles with your app. Since ScreenB.jsbundle is a non-standard file that we created, we need to add it to this configuration.

2. Use the react native bridge instance to trigger lazy load

By default, the react native project created by react-native-cli internally creates an RCTBridge instance when we call initWithBundleURL on the the RCTRootView:

In our case, the loadCustomBundle method is available on the RCTBridge. So we need an instance of that class. To do that, we need to break down the above implementation into two parts like so:

Create a bridge instance first and then use that to create an RCTRootView

This way we have the bridge instance and now you can use it to load your custom bundle wherever you want:

Load a bundle by the name ScreenB i.e. ScreenB.jsbundle

You can save the bridge to a static class property and use it as a singleton wherever you want. Or you can use any dependency injection technique of your choice.

A more elaborate and complete example of the concepts covered in this blog is available on Github.

That’s it folks! 👋🏻 If you found this interesting/useful, please 💚 it. If you would like to know more, please leave a comment or find me on twitter, @geekykaran.