Description
Summary
Being an out-of-tree platform, Expo doesn't officially support React Native Windows yet, so l'd like to track what's missing and document the workarounds I'm using for now. FYI @Saadnajmi @tido64 @acoates-ms @EvanBacon.
Motivation
Meta now officially recommend using React Native via a framework such as Expo. A pain-point to adopting out-of-tree platforms (with or without Expo) is setting up all the boilerplate, and Expo has an excellent template system for taming all of that, furthermore enabling easy updates simply by bumping the version of the SDK and running what they call a "prebuild" again to regenerate project files.
Basic Example
No response
Open Questions
I'll knowledge-share how I got react-native-windows
working alongside react-native-macos
and react-native
(all v73) on Expo SDK 50. It lacks config plugins and prebuild, but you can at least use the same Expo CLI to start and bundle apps.
Sorry for the lack of concrete details in some places, as I'm working on a closed-source project, so there's a limit to what I can share; but I'm happy to point to prior art. Will try to help get it all upstreamed.
package.json
{
"name": "example-xplat-app",
"version": "1.0.0",
// This can be changed to src/index.js if you want to move your entrypoint under src/
"main": "index.js",
"dependencies": {
// Use the Expo SDK that goes with the given React Native minor
"expo": "~50.0.18",
"react": "18.2.0",
// Align the platforms on the same minor version
"react-native": "~0.73.9",
"react-native-macos": "~0.73.30",
"react-native-windows": "~0.73.17",
"typescript": "^5.5.3"
},
"devDependencies": {
"@babel/core": "^7.22.11",
"@rnx-kit/metro-config": "^1.3.15",
"@types/react": "~18.3.3"
},
"scripts": {
"dev": "expo start --dev-client",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
// For macOS, I'm running the app directly from Xcode for now, but
// `react-native run-macos` might work; just haven't tried.
"windows": "react-native run-windows --logging --arch arm64"
}
}
Although we're not launching the Windows app using the Expo CLI (i.e. expo start --windows
, which doesn't exist), we are nonetheless starting a common packager with expo start
, calling Expo's registerRootComponent
as an entrypoint for our app, and using the Expo Babel preset.
babel.config.js
We use babel-preset-expo
instead of module:@react-native/babel-preset
. I was seeing errors about bundling Expo SDK modules without it.
module.exports = (api) => {
api.cache(true);
return {
presets: ["babel-preset-expo"]
};
};
metro.config.js
I merged an older metro.config.js from RNTA with this metro.config.js from Expo Orbit, repeating what they did to handle react-native-macos
to handle react-native-windows
.
const { getDefaultConfig } = require("expo/metro-config");
const exclusionList = require("metro-config/src/defaults/exclusionList");
const { FileStore } = require("metro-cache");
const path = require("node:path");
const fs = require("node:fs");
const projectRoot = __dirname;
// If you have a monorepo, the workspace root may be above the project root.
const workspaceRoot = path.resolve(projectRoot, "../..");
const rnwPath = fs.realpathSync(
path.resolve(require.resolve("react-native-windows/package.json"), ".."),
);
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
const {
resolver: { sourceExts, assetExts },
} = config;
module.exports = {
...config,
watchFolders: [workspaceRoot],
resolver: {
...config.resolver,
blockList: exclusionList([
// This stops "react-native run-windows" from causing the metro server to crash if its already running
new RegExp(
`${path.resolve(__dirname, "windows").replace(/[/\\]/g, "/")}.*`,
),
// This prevents "react-native run-windows" from hitting: EBUSY: resource busy or locked, open msbuild.ProjectImports.zip or other files produced by msbuild
new RegExp(`${rnwPath}/build/.*`),
new RegExp(`${rnwPath}/target/.*`),
/.*\.ProjectImports\.zip/,
]),
disableHierarchicalLookup: true,
nodeModulesPaths: [
path.resolve(projectRoot, "node_modules"),
...(workspaceRoot === projectRoot
? []
: [path.resolve(workspaceRoot, "node_modules")]),
],
resolveRequest: (context, moduleName, platform) => {
if (
platform === "windows" &&
(moduleName === "react-native" ||
moduleName.startsWith("react-native/"))
) {
const newModuleName = moduleName.replace(
"react-native",
"react-native-windows",
);
return context.resolveRequest(context, newModuleName, platform);
}
if (
platform === "macos" &&
(moduleName === "react-native" ||
moduleName.startsWith("react-native/"))
) {
const newModuleName = moduleName.replace(
"react-native",
"react-native-macos",
);
return context.resolveRequest(context, newModuleName, platform);
}
return context.resolveRequest(context, moduleName, platform);
},
},
transformer: {
...config.transformer,
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
// This fixes the 'missing-asset-registry-path` error (see https://github.com/microsoft/react-native-windows/issues/11437)
assetRegistryPath: "react-native/Libraries/Image/AssetRegistry",
},
serializer: {
...config.serializer,
getModulesRunBeforeMainModule() {
return [
require.resolve("react-native/Libraries/Core/InitializeCore"),
require.resolve("react-native-macos/Libraries/Core/InitializeCore"),
require.resolve("react-native-windows/Libraries/Core/InitializeCore"),
...config.serializer.getModulesRunBeforeMainModule(),
];
},
},
};
For some reason, the @rnx-kit/metro-config
recommended default config didn't work out-of-the-box for me (microsoft/rnx-kit#3257) so I'd love to simplify this.
react-native.config.js
You can omit the windows
key from react-native.config.js
if you want to avoid the Expo CLI trying to autolink and instead take autolinking into your own hands (a trick I learned from here) with react-native autolink-windows
.
I ended up doing this for one reason or another (it's all a bit of a blur). I assume Expo CLI doesn't implement autolinking for Windows, anyway.
/** @type import("@react-native-community/cli-types").Config */
module.exports = {
project: {
ios: {
sourceDir: "./ios",
},
macos: {
sourceDir: "./macos",
},
windows: {
sourceDir: "./windows",
},
},
dependency: {
platforms: {
ios: {},
android: {},
macos: null,
// Omit the "windows" key here to avoid the Expo CLI attempting to autolink Windows.
},
},
};
index.js
Expo projects do the following:
import { registerRootComponent } from "expo";
import { App } from "./App";
registerRootComponent(App);
This does a little more than just calling AppRegistry.registerComponent()
. From the implementation, you can see that it imports a file for side-effects, Expo.fx
:
import '../Expo.fx';
import { AppRegistry, Platform } from 'react-native';
export default function registerRootComponent(component) {
let qualifiedComponent = component;
if (process.env.NODE_ENV !== 'production') {
const { withDevTools } = require('./withDevTools');
qualifiedComponent = withDevTools(component);
}
AppRegistry.registerComponent('main', () => qualifiedComponent);
if (Platform.OS === 'web') {
// Use two if statements for better dead code elimination.
if (
// Skip querying the DOM if we're in a Node.js environment.
typeof document !== 'undefined') {
const rootTag = document.getElementById('root');
if (process.env.NODE_ENV !== 'production') {
if (!rootTag) {
throw new Error('Required HTML element with id "root" was not found in the document HTML.');
}
}
AppRegistry.runApplication('main', {
rootTag,
hydrate: process.env.EXPO_PUBLIC_USE_STATIC === '1',
});
}
}
}
//# sourceMappingURL=registerRootComponent.js.map
Expo.fx
accesses expo-asset
and expo-font
(which expect to find native classes, e.g. requireNativeModule('ExpoFontLoader')
) without any platform guards for Windows. At runtime, those native modules are missing and thus things break downstream that prevent startup.
Note that, even if a Windows implementation of expo-font
and expo-asset
were implemented, the React Native Community CLI would fail to autolink it in this case because it only autolinks top-level dependencies, while these are subdependencies of the expo
npm package. The Expo CLI autolinks even subdependencies.
It also hard-codes the appKey as "main" when calling AppRegistry.runApplication
, so if you've configured your app.json
to use an explicit name other than "main"
, then the app will fail to start up.
Prior art
- Expo Orbit (more concerned with macOS)
- Example of using Expo with out-of-tree platforms
- Example of using Expo in a monorepo
- My react-native-macos expo template
- RNTA