Debugging Flipper Desktop App
Background
While upgrading React Native project at work, one problem occurs to me is that I’m not able to use the Hermes debugger plugin on Flipper (Desktop App). Before the upgrade, my team has been using Chrome V8 for Android. This is where the exploration begins.
Explore Flow
Below are my exploration flow
Is it due to React Native version and Flipper version mismatch? ⇒ ❌
- Things are expected
Is it possible Flipper has a bug in the current version? ⇒ ❌
Look through the repo Github and did not find a relevant issue
Setting up a new pure React Native project with the specific version seems to work well with the Hermes debugger plugin
Is it due to some special package or setting on the current project? ⇒ ❌
- Comparing package-lock file between the pure React Native project, related packages are using the same version
Is it possible Metro server had worked differently? ⇒ ❌
While digging through the
react-native-cli
, did not find suspicious thingsWhile launching the Metro sever on the pure React Native project, the Hermes debugger was showing
Since the above doesn’t seem to get me any further, I decide to explore on Flipper Desktop App more. But at least the problem has been nailed down as to Why Flipper couldn’t connect to the existing project’s Metro server?
Flipper (Desktop App)
How Flipper does connect?
Run from Source
To run Flipper Desktop App from the source
git clone <https://github.com/facebook/flipper.git>
cd flipper/desktop
yarn
yarn start
Debugging
Let’s start from figure out where this screen comes from
First, this plugin is located at plugins/public/hermesdebuggerrn
.
// /plugins/public/hermesdebuggerrn/package.json
{
"$schema": "<https://fbflipper.com/schemas/plugin-package/v2.json>",
"name": "flipper-plugin-hermesdebuggerrn",
"id": "Hermesdebuggerrn",
"pluginType": "device",
"supportedDevices": [
{
"os": "Metro",
"archived": false
}
] ...
}
Second, let’s look at the rendering part of the plugin
// plugins/public/hermesdebuggerrn/index.tsx
...
renderContent() {
const {error, selectedTarget, targets} = this.state;
console.log('Mark >>>>>>>>>>>>>>> ', error, selectedTarget, targets);
if (selectedTarget) {
...
return ( <ChromeDevTools ... />);
} else if (targets != null && targets.length === 0) {
return <LaunchScreen />;
} else if (targets != null && targets.length > 0) {
return <SelectScreen targets={targets} onSelect={this.handleSelect} />;
} else if (error != null) {
return <ErrorScreen error={error} />;
} else {
return null;
}
}
...
After adding the debug log, it doesn’t seem to show. So the issue should be coming from another place. By searching the substring of the error message, we were able to find the following.
// flipper-ui-core/src/utils/pluginUtils.tsx
export function computePluginLists(connections, plugins, device, metroDevice, client){
...
if (device) {
// find all device plugins that aren't part of the current device / metro
for (const p of plugins.devicePlugins.values()) {
if (!device.supportsPlugin(p) && !metroDevice?.supportsPlugin(p)) {
unavailablePlugins.push([
p.details,
`Device plugin '${getPluginTitle(
p.details,
)}' is not supported by the selected device '${device.title}' (${
device.os
})`,]);
}}...
}
...
}
So the origin of the error message has been found, let’s figure out how it got into this condition.
// flipper-ui-core/src/utils/pluginUtils.tsx
console.log('Device >>>> ', device, metroDevice);
if (!device.supportsPlugin(p) && !metroDevice?.supportsPlugin(p)) {
unavailablePlugins.push([...])
...
}
The reason that Hermes debugger is being pushed into the unavailablePlugins is that metroDevice
was null.
Wonderful, we’ve nailed down the problem of metro Devices not being unavailable. Next, we just need to figure out why. Let’s find the caller for computePluginLists
, to see what has been passed in for the metroDevice
.
// flipper-ui-core/src/selectors/connections.tsx
export const getPluginLists = createSelector(
({
connections: {
enabledDevicePlugins,
enabledPlugins,
selectedAppPluginListRevision, // used only to invalidate cache
},
}) => ({
enabledDevicePlugins,
enabledPlugins,
selectedAppPluginListRevision,
}),
({
plugins: {
clientPlugins,
...
},
}) => ({
clientPlugins,
...
}),
getActiveDevice,
getMetroDevice,
getActiveClient,
computePluginLists,
);
To learn more about the createSelector
Exploring more on getMetroDevice
// flipper-ui-core/src/selectors/connections.tsx
export const getMetroDevice = createSelector(getDevices, (devices) => {
console.log('Mark >>>>>>>>', devices);
return (
devices.find((device) => **device.os === 'Metro' && !device.isArchived) ??
null
);
});
As we see from the log, there doesn’t exist any BaseDevice which device.os === 'Metro'
.
More about the Metro Device part
Next, we need to have a look at the state
// flipper-ui-core/src/selectors/connections.tsx
const getDevices = (state: State) => state.connections.devices;
export const getMetroDevice = createSelector(**getDevices**, (devices) => {
return (
devices.find((device) => device.os === 'Metro' && !device.isArchived) ??
null
);
});
Let’s explore the reducer of connections
// flipper-ui-core/src/reducers/connections.tsx
export default (state: State = INITAL_STATE, action: Actions): State => {
switch (action.type) {
...
case 'REGISTER_DEVICE': {
const {payload} = action;
const newDevices = state.devices.slice();
...
return {
...state,
devices: newDevices,
selectedDevice: selectNewDevice ? payload : state.selectedDevice,
selectedAppId,
};
}
}
...
}
Next, let’s try to find where the Actions are being called
// flipper-ui-core/src/dispatcher/flipperServer.tsx
export function handleDeviceConnected(
server: FlipperServer,
store: Store,
logger: Logger,
deviceInfo: DeviceDescription,
) {
...
console.log('Device info >>>> ', server, deviceInfo);
const device = new BaseDevice(server, deviceInfo);
...
store.dispatch({
type: 'REGISTER_DEVICE',
payload: device,
});
}
Logs collect above
The caller of handleDeviceConnected
would be connectFlipperServerToStore
// flipper-ui-core/src/dispatcher/flipperServer.tsx
export function connectFlipperServerToStore(
server: FlipperServer,
store: Store,
logger: Logger,
) {
...
server.on('device-connected', (deviceInfo) => {
**handleDeviceConnected**(server, store, logger, deviceInfo);
});
...
waitFor(store, (state) => state.plugins.initialized)
.then(() => server.exec('device-list'))
.then((devices) => {
// register all devices
devices.forEach((device) => {
**handleDeviceConnected**(server, store, logger, device);
});
})
...
}
We’ll need to find the server
The caller of connectFlipperServerToStore
is the init
in startFlipperDesktop.tsx
// flipper-ui-core/src/startFlipperDesktop.tsx
function init(flipperServer: FlipperServer) {
const settings = getRenderHostInstance().serverConfig.settings;
const store = getStore();
const logger = initLogger(store);
...
connectFlipperServerToStore(flipperServer, store, logger);
...
}
export async function startFlipperDesktop(flipperServer: FlipperServer) {
getRenderHostInstance(); // renderHost instance should be set at this point!
init(flipperServer);
}
To proceed, we will need to look at callers of startFlipperDesktop
// app/src/init.tsx
async function start() {
...
const electronIpcClient = new ElectronIpcClientRenderer();
const flipperServer: FlipperServer = await getFlipperServer**(
logger,
electronIpcClient,
);
const flipperServerConfig = await flipperServer.exec('get-config');
await initializeElectron(
flipperServer,
flipperServerConfig,
electronIpcClient,
);
...
require('flipper-ui-core').startFlipperDesktop(flipperServer);
await flipperServer.connect();
...
}
start().catch((e) => { ... });
Following, we should look into getFlipperServer
// app/src/init.tsx
async function getFlipperServer(
logger: Logger,
electronIpcClient: ElectronIpcClientRenderer,
): Promise<FlipperServer> {
...
const getEmbeddedServer = async () => {
const server = new FlipperServerImpl(
{
environmentInfo,
env: parseEnvironmentVariables(env),
gatekeepers: gatekeepers,
paths: {
appPath,
homePath: await electronIpcClient.send('getPath', 'home'),
execPath,
staticPath,
tempPath: await electronIpcClient.send('getPath', 'temp'),
desktopPath: await electronIpcClient.send('getPath', 'desktop'),
},
launcherSettings: await loadLauncherSettings(),
processConfig: loadProcessConfig(env),
settings,
validWebSocketOrigins:
constants.VALID_WEB_SOCKET_REQUEST_ORIGIN_PREFIXES,
},
logger,
keytar,
);
return server;
};
...
return getEmbeddedServer();
}
Dig deeper, we should look at the implementation of FlipperServerImpl
In the app/src/init.tsx called flipperServer.connect();
// flipper-server-core/src/FlipperServerImpl.tsx
export class FlipperServerImpl implements FlipperServer {
...
async connect() {
...
try {
await this.createFolders();
await this.server.init();
await this.pluginManager.start();
await this.startDeviceListeners();
this.setServerState('started');
} catch (e) {...}
}
...
}
More on the this.startDeviceListeners()
part
// flipper-server-core/src/FlipperServerImpl.tsx
import metroDevice from './devices/metro/metroDeviceManager';
async startDeviceListeners() {
const asyncDeviceListenersPromises: Array<Promise<void>> = [];
if (this.config.settings.enableAndroid) {
...
}
if (this.config.settings.enableIOS) {
...
}
const asyncDeviceListeners = await Promise.all(
asyncDeviceListenersPromises,
);
this.disposers.push(
...asyncDeviceListeners,
metroDevice(this),
desktopDevice(this),
);
}
Deeper into metroDevice
// flipper-server-core/src/devices/metro/metroDeviceManager.tsx
export default (flipperServer: FlipperServerImpl) => {
let timeoutHandle: NodeJS.Timeout;
let ws: WebSocket | undefined;
let unregistered = false;
async function tryConnectToMetro() {
if (ws) {
return;
}
if (await **isMetroRunning**()) {
...
}
}
}
More in isMetroRunning
// flipper-server-core/src/devices/metro/metroDeviceManager.tsx
const METRO_MESSAGE = ['React Native packager is running', 'Metro is running'];
async function isMetroRunning(): Promise<boolean> {
console.log('Metro >>>>>> is Running');
return new Promise((resolve) => {
http
.get(METRO_URL, (resp) => {
let data = '';
resp
.on('data', (chunk) => {
data += chunk;
console.log('Metro >>>>>> data', data);
})
.on('end', () => {
const isMetro = METRO_MESSAGE.some((msg) => data.includes(msg));
console.log('Metro >>>>>> isMetro', isMetro);
resolve(isMetro);
});
})
.on('error', (err: any) => {
if (err.code !== 'ECONNREFUSED' && err.code !== 'ECONNRESET') {
console.error('Could not connect to METRO ' + err);
}
resolve(false);
});
});
}
Logs from about will get the below
So the reason why isMetro is being false is that the data part doesn’t contain any of the substrings.
// flipper-server-core/src/devices/metro/metroDeviceManager.tsx
const METRO_HOST = 'localhost';
const METRO_PORT = parseEnvironmentVariableAsNumber('METRO_SERVER_PORT', 8081);
const METRO_URL = `http://${METRO_HOST}:${METRO_PORT}`;
const METRO_MESSAGE = ['React Native packager is running', 'Metro is running'];
const isMetro = METRO_MESSAGE.some((msg) => data.includes(msg));
And since the current localhost:8081 will get the below
<!DOCTYPE html>
<html style="height:100%">
<head>
<title>OLIO</title>
</head>
<body style="height:100%">
<div id="root" style="display:flex;height:100%"></div>
<script type="text/javascript" src="/bundle.web.js"></script>
</body>
</html>
For some historical reason, we had an index.html as above in the root directory. Since it doesn’t contain any substring in METRO_MESSAGE. In this case isMetro
being false.
How to Fix it
To apply a quick fix to this would just add a comment to the index.html
<!DOCTYPE html>
<html style="height:100%">
<head>
<title>OLIO</title>
</head>
<body style="height:100%">
<!-- React Native packager is running. -->
<div id="root" style="display:flex;height:100%"></div>
<script type="text/javascript" src="/bundle.web.js"></script>
</body>
</html>
In the final step, add the comment (<!-- React Native packager is running. -->
) ⇒ ✅
Conclusion
It’s quite a journey to explore the Flipper source code and found out that things are caused by something that you never had to imagine. It's common to see that Flipper plugin doesn't work and with the limited logs it provides, it could be tricky to figure out what the root cause was. As in this post, we explore how you could build Flipper from source and debug around. Hope this post built up some confidence while playing around with Flipper.
Subscribe to my newsletter
Read articles from Hsiangyu Hu directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Hsiangyu Hu
Hsiangyu Hu
Building Great Apps with React Native