Skip to content

Metro Guide

kmelmon edited this page Jun 17, 2020 · 32 revisions
  1. Overview
  2. The Basics
    a. Metro Overview
    b. Working With Bundles
    c. Configuring Metro
    d. Debugging the JS
  3. Going in Deeper
    a. Debugging Metro
    b. Out-of-tree Platforms
    c. Resolver Internals
    d. Transformer Internals

Overview

In this article we will cover basics of how the Metro bundler works, how we have customized Metro for react-native-windows, and some internal details to help you find your way around.

This article is written mostly for developers contributing to react-native-windows who want more knowledge of Metro internals.

Also see: https://github.com/microsoft/react-native-windows/wiki/Metro-Troubleshooting-Guide

The Basics

Metro Overview

Metro is a collection of tightly integrated npm packages, installed along with react-native. See: https://facebook.github.io/metro/. Although the docs are very light on details, I suggest reading them over if you plan on doing any development in the RNW repo. Start here: https://facebook.github.io/metro/docs/concepts

Metro is a node.js application and is invoked by the react-native CLI.

Metro produces javascript "bundles". What is a bundle? Simply put, a bundle is just a large file containing the javascript code required to run the javascript portion of your react-native application, and assets needed by the javascript (eg images).

Working With Bundles

Metro and apps work with bundles in several ways:

1) Working with Metro/bundles during app development
A bundle can be served up by Metro at runtime to the client app via an http request and the client code will pick up this bundle and run the javascript automatically. This is the normal scenario during app development. This creates a nice separation between the developer box and the test machine, which might be a mobile device. Metro runs an http server listening on localhost:8081 by default, but this can be configured for more advanced scenarios such as remote debugging in a VM (see https://github.com/microsoft/react-native-windows/wiki/VS-Remote-Debugging).

Serving up bundles is also part of how fast refresh does its magic. When the javascript changes, Metro can produce a "patch" to the bundle and sends the patch to the app via another http request. The client code is capable of applying this patch to its current bundle and reloading, including what changed, giving you that "fast refresh" experience - your app can keep going with changes, without a full re-launch.

Of course, for Metro to serve up bundles, you need to first start the Metro server. This can be done a couple ways:
npx react-native run-windows: This starts Metro and also builds/launches your app
yarn start: This just starts Metro. You'll need to manually launch the app to connect.

Note that Metro is actually launched by the react-native CLI, which is a different npm package maintained by the react-native community. See: https://github.com/react-native-community/cli.
The start command has parameters that allow you to customize the server behavior. For more details see: https://github.com/react-native-community/cli/blob/master/docs/commands.md#start

When the client wants a bundle in this mode it makes an http request with a "bundle URL", which is just an http request with query parameters. A typical bundle URL looks something like this:
http://localhost:8081/index.bundle?platform=windows&dev=true&hot=false&inlineSourceMap=true
Unfortunately the query parameters don't seem to be officially documented. Here's what the parameters mean:
platform: the native platform to build a bundle for (eg android/ios/windows)
dev: if true, turns on "dev" mode in the javascript code (adds debugging stuff to the JS)
hot: if true, turns on hot reload (?) which we don't actually use in react-native-windows (we use fast refresh)
inlineSourceMap: if true, creates a source map inline with the bundle. A source map is like a pdb file, it tells the debugger where in the original source file a javascript instruction in the bundle corresponds to.
minify: if true, minifies the javascript
For more details, see source code here: https://github.com/facebook/metro/blob/7814c2840c49c16788041284fd65df25aa997d8c/packages/metro/src/Server.js#L671

Handy debugging trick: You can enter a bundle URL into your browser to test fetching a bundle, if it's successful you'll see the bundle loaded into your browser as plain-text.

2) Creating offline bundles for a release build
The bundle can be packaged directly into your application as a resource (aka "offline bundle"). In this case Metro produces a bundle as part of building your application, and the bundle is loaded directly by the client code, ie there is no http server. This is the normal scenario for a release build. To produce a bundle, you invoke the CLI with the "bundle" command, which then invokes Metro to produce the bundle. A basic bundle command looks something like this:
npx react-native bundle --platform windows --entry-file index.js --bundle-output windows\myAwesomeApp\Bundle\index.windows.bundle --assets-dest windows\myAwesomeApp\Bundle
For more details on bundle parameters, see: https://github.com/react-native-community/cli/blob/master/docs/commands.md#bundle

Configuring Metro

Metro is highly configurable. For docs on the options, see: https://facebook.github.io/metro/docs/configuration

Metro has a default configuration, unfortunately it's not documented, but see: https://github.com/react-native-community/cli/blob/db6dc54479479c9f656bd6d777174223da9901f4/packages/cli/src/tools/loadMetroConfig.ts#L57

Apps can provide a custom metro config that typically adds to the default configuration. For react-native-windows aps created by the CLI we provide a metro.config.js, see: https://github.com/microsoft/react-native-windows/blob/19c35585d90df557ef6d4947daaee0a15cecc7be/vnext/local-cli/generator-windows/templates/metro.config.js#L7

It's worth calling out one of the most commonly used Resolver options, blacklistRE. This option tells the Metro Resolver which files to ignore by putting them on a "blacklist". Once blacklisted, the files are not seen at all by Metro, which can resolve a number of common bundling issues (see https://github.com/microsoft/react-native-windows/wiki/Metro-Troubleshooting-Guide). The property is an array of regular expressions to allow for things like the * wildcard character. See more on how the Resolver works in Going in Deeper section below.

Debugging the JS

Since react-native apps are largely javascript apps, developers often need to debug the javascript. There are actually a lot of debugging tools out there for react-native, but this article will cover two basic ways of debugging just the javascript:

1) Web Debugging
With web debugging, the javascript bundle is not actually running in the client process. Instead it's running in another process (either a web browser, or a tool like VS Code). In order to use web debugging, you need to first start Metro and launch your app in debug mode (see section above called "Working with Metro/bundles during app development"). From there we'll cover several options for debugging:

Debugging using Chrome
With this option, you use the Chrome debugging tools just like you would if you were debugging a web page. When Metro launches, you should see Chrome launch a new tab that navigates to this URL:
http://localhost:8081/debugger-ui/
If you don't see this launch automatically, you can launch it yourself and manually enter that URL. From there you can hit F12 and use the Chrome debugging tools.

Debugging using VS Code
With this option, you can do your debugging directly in VS Code, which is very convenient, especially if you're writing your JS in VS Code. To get started, you'll need to install the react-native tools for VS Code. See:
https://marketplace.visualstudio.com/items?itemName=msjsdiag.vscode-react-native
From there, follow this guide: https://github.com/microsoft/react-native-windows/wiki/VS-Code-Debugging

2) Direct Debugging
With this option, you'll using an offline bundle, so your javascript is running "directly" in the client process, and the javascript is being debugged as well. There are multiple reasons why you may need to do this: If you have a native module that uses JSI, or has synchronous API calls from JS to native, you'll need to use this option. For more details on how to setup direct debugging, see: https://github.com/microsoft/react-native-windows/wiki/VS-Code-Direct-Debugging

Also, be aware there are options in the developer menu that can turn these debugging modes on/off. Hit Ctrl + Shift + D from within your app, or hit 'd' from the Metro prompt to bring up the developer menu.

Going in Deeper

Debugging Metro

To debug the Metro code, I suggest using VS Code. Follow the setup instructions here:
https://github.com/microsoft/react-native-windows/wiki/Metro-Troubleshooting-Guide#debugging-metro-errors

Out-of-tree Platforms

Out-of-tree platforms require us to customize the metro configuration. To understand this, you must first understand an important detail of out-of-tree platforms: platform-specific javascript overrides.

What is an override and how does it work?
react-native-windows is built on top of react-native and shares most of the react-native javascript code. However, some of this code was written with only android/ios in mind and won't work on windows without some changes. There are also cases where windows has functionality that hasn't made it back upstream yet. This is where platform overrides come in. It's expected that the out-of-tree platform may have to override portions of the javascript, this is done simply by making a platform-specific override file for a given javascript file. Metro will use the override in place of the original file when creating bundles. The overridden file has a naming convention of foo.platform.js, where platform is the name of the out-of-tree platform (eg windows). For example, we override Alert.js with Alert.windows.js.

Now for the fun part - getting Metro to pick up the platform override when creating a bundle. There are two parts to this:

  1. When a bundle is being requested, the platform is supplied as a parameter to Metro. This is how Metro knows what overrides to use. Using Alert.js as an example, if the app uses the Alert module, and a bundle is requested with platform = windows, Metro will bundle Alert.windows.js instead of Alert.js.
  2. The override file must be located in the same directory as the file it is overriding. This requirement makes life simpler for the resolver. However this requirement has a very important implication for react-native-windows. The windows overrides don't get published to the react-native npm registry, as they aren't part of react-native. Thus they wont' be present in node_modules/react-native when an app installs react-native. So how do we get windows overrides installed to the same directory? The answer is we publish a copy of all of the react-native javascript along with react-native-windows. This requires some special steps during the build to copy all of the appropriate JS to a single directory before publishing. This is done by copyRNLibraries.js, see: https://github.com/microsoft/react-native-windows/blob/ae37f8e7518f808116ce906a7f7b32f00412612d/vnext/Scripts/copyRNLibraries.js#L79

To give you an idea of the full ramifications of this, here's a picture of the directory structure for a sample "myAwesomeAppp' generated by the CLI, with react-native-windows installed:

myAwesomeApp

index.js
App.js
node_modules

react-native

(all of react-native, including JS, ios/android native code)

react-native-windows

(all of react-native (JS only), plus windows overrides)

The important thing to notice is that all of the react-native javascript is actually installed to two locations. When you build a bundle for windows, Metro will pick up all of react-native from within node_modules\react-native-windows (along with the windows overrides), NOT from within node_modules\react-native. However, if you build a bundle for ios/android, Metro will fallback to the default behavior of picking up react-native from node_modules\react-native. You might be wondering how this magic works! If so read on.

Out-of-tree platform redirection magic
Because of the custom install configuration described above, we changed the CLI to magically redirect files that normally live in node_modules\react-native over to node_modules\react-native-windows. Note that this change will work for other out-of-tree platforms as well. The jist of the change: Metro has a central function for resolving every file in a bundle, and this can be overridden. We introduced a custom function to take over the resolving. When resolving files for a given out-of-tree platform, anything that begins with 'react-native' is redirected to that out-of-tree platform. See: https://github.com/react-native-community/cli/pull/1115

Resolver Internals

The Metro resolver has complex rules, and is where most bundling errors happen. This section covers some of the internal details of the resolver.

What files are being resolved?
It may not be apparent how Metro determines what files to include in a bundle. The short answer is Metro has smarts to pull in only the javascript and assets used by your app code. From the Metro docs:
"Metro needs to build a graph of all the modules that are required from the entry point. To find which file is required from another file Metro uses a resolver. In reality this stage happens in parallel with the transformation stage."

The entry point of your app is specified by the --entry-point parameter to the bundle command, it's typically an index.js file at the root of your app directory. From here, Metro crawls the dependency graph by doing a transitive closure over all the 'require' and 'import' statements it finds.

Assets are given some special treatment by Metro as these aren't javascript files and so go through different processing as a bundle is created. The default metro config has a list of known file extensions to treat as assets. See: https://github.com/facebook/metro/blob/7674ca076e03483a17a37c31481807e2b67dea29/packages/metro-config/src/defaults/defaults.js#L15
In addition, you can add your own set of file extensions by starting metro with the -assetExts param, or by supplying an assetsExts as a resolver parameter in your metro.config.js.

You may find the use of 'require' statements in react-native code confusing. Indeed! Metro supports both CommonJS as well as ES6 style imports.

Note that requires must use relative paths, or package-relative paths, Haste-style requires are no longer supported. For example:
require('foobar.js') => Haste style, not allowed
require('./foobar.js') => relative path, allowed
require('SomePackage/foobar.js') => package-relative path, allowed

Haste map
Although Haste-style imports are no longer supported, Metro still uses some (possibly most?) of the original Haste module implementation. A central piece of this is the "haste map" - a map of all the files that are visible to Metro for bundling. For a nice description of what this is, see: https://github.com/facebook/jest/blob/a35b9cc686e542064d6622dd1886c13a509465f5/packages/jest-haste-map/src/index.ts#L136
The jist of it is this: Metro does a crawl of the directories you point it at (mostly just your app directory and everything under it, minus what has been blacklisted by the blacklistRE prop), and builds a map of all the files it finds. As files are being resolved, they are looked up in the haste map for efficiency. Fortunately it is persisted to disk, in your %temp% directory, in a file that begins with haste-map-metro. It's just a text file, and is sometimes useful to look at, particularly for debugging bundler errors - you can search the map for a given module by file name.

Debugging the resolver
To debug the resolver, first setup your debugger for Debugging Metro. If you're debugging a react-native-windows app, all file resolve requests will go through our custom resolver. You can set a breakpoint here:
node_modules@react-native-community\cli\build\tools\metroPlatformResolver.js
Also see:
https://github.com/react-native-community/cli/blob/db6dc54479479c9f656bd6d777174223da9901f4/packages/cli/src/tools/metroPlatformResolver.ts#L18

You can also set a breakpoint in Metro's resolver function here:
https://github.com/facebook/metro/blob/7814c2840c49c16788041284fd65df25aa997d8c/packages/metro-resolver/src/resolve.js#L35

Transformer internals

The transformer is responsible for transpiling JS into another form. Some examples of transpiling:

  • It is responsible for rewriting require's as a function lookup.
  • It also does something special to transform require's of assets (eg images).

The act of transformation largely involves running babel transforms. For a super quick overview of babel, see:
https://github.com/babel/babel/blob/master/packages/README.md

Which babel transforms are run are determined by the config. For react-native, a preset config of transformers are used, see:
https://www.npmjs.com/package/metro-react-native-babel-preset

One of the more important transforms we use is babel-plugin-transform-modules-commonjs, which converts ES6 exports to CommonJS format. See:
https://babeljs.io/docs/en/babel-plugin-transform-modules-commonjs