Expo support
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 (https://github.com/microsoft/rnx-kit/issues/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
Related issues
- https://github.com/microsoft/react-native-windows/issues/5173
- https://github.com/microsoft/react-native-windows/issues/6624
- https://github.com/expo/expo/discussions/22273
@shirakaba ty for this.
@shirakaba Thank you for this.
With the clear upstream guidance of "just use expo" I'm actually preparing an internal presentation on the current state of expo to present to the RNW team so we can maybe fill some gaps.
I'm not sure how expo prebuild actually works under the covers, but perhaps similar to what you did with react-native autolink-windows, we now have an react-native init-windows command for generating the windows folder (and making updates to metro.config, etc) that is template-based.
Part of a potential todo list would be to create an "expo (prebuild) friendly" template (either new or if possible by fixing the existing ones). I would love to see an example of "here's a new expo project that got at least some parts of windows working (esp wrt. metro)".
With the clear upstream guidance of "just use expo" I'm actually preparing an internal presentation on the current state of expo to present to the RNW team so we can maybe fill some gaps.
Sounds exciting!
I'm not sure how expo prebuild actually works under the covers, but perhaps similar to what you did with react-native autolink-windows, we now have an
react-native init-windowscommand for generating the windows folder (and making updates to metro.config, etc) that is template-based.
Ah yes, I've used that. Could you show me where the template lives?
Part of a potential todo list would be to create an "expo (prebuild) friendly" template (either new or if possible by fixing the existing ones). I would love to see an example of "here's a new expo project that got at least some parts of windows working (esp wrt. metro)".
I'm fairly familiar with Expo Prebuild now as I've been contributing some things to it to advance support for react-native-macos (and NativeScript, but that's another story), so I'd be happy to create a strawman template for out-of-tree platforms.
There are some obstacles to out-of-tree platform support of expo prebuild and expo create (which performs a prebuild after downloading a template):
react-native-macosis lagging on v0.73, which corresponds to Expo SDK v50- The latest Expo SDK is v51. The CLI in this version is very desirable as it allows users to use GitHub URLs for prebuild templates.
- The v50 CLI corrupts any binary files stored in the template, so you wouldn't be able to safely place any
.dllfiles into the template. My PR to fix that landed in SDK v51. - Thus, whenever I have templates involving binary files, I use the v50 SDK for the runtime code, yet use the v51 CLI for any one-off invocations of the
createandprebuildcommands. - However, that same PR creates a new problem. Previously, you could just write the magic
HelloWorldplaceholder string in any file in the whole template (even a JPG) and it'd get renamed to your desired project name; but now that string-replacement only runs on an explicit list of file paths. It's a simple matter to add new paths (as I show in my not-yet-merged PR to set up the paths to be renamed on macOS projects), but it will need to be done for Windows and it can take 1-2 months for even a simple PR to get merged. - So I'm thinking, as an interim measure to unblock usage of the Expo v51 CLI, we could possibly commit the template files with our own placeholder string
ByeWorldand just run our own renaming script after any usages ofcreateorprebuild.
Support for other features of the Expo CLI (like the start command, which seems to ensure that any config plugins have run) are another story and will require further thought.
Will see if I can create that strawman template soon to advance discussions.
@shirakaba since these days the expo CLI is a dependency of the app, I assume you could fork it in the short term while waiting for PRs to land? Then I suppose you would just have to install the fork after running npx create-expo-app.
When you say expo create are you referring to create-expo-app?
When you say "like the start command, which seems to ensure that any config plugins have run", do you mean expo run (which automatically runs prebuild)?
@wodin For sure, I can fork to my heart's content – just a bit of a harder sell when it comes to putting it into other people's hands. Every step with asterisks increases friction in usage, and we don't want to make people have to commit to using a forked CLI that I might give up on maintaining one day.
When you say
expo createare you referring tocreate-expo-app?
I'm referring to create-expo, sorry, yeah.
When you say "like the start command, which seems to ensure that any config plugins have run", do you mean expo run (which automatically runs prebuild)?
Ah yep, I meant expo run:<platform> (theoretically in this case expo run:windows), sorry. And exactly, it runs prebuild implicitly if the platform folder is missing.
For some reason, the
@rnx-kit/metro-configrecommended default config didn't work out-of-the-box for me (microsoft/rnx-kit#3257) so I'd love to simplify this.
FYI, as of 1.3.16, @rnx-kit/metro-config should work out of box with Expo:
const { getDefaultConfig } = require("@expo/metro-config");
const { makeMetroConfig } = require("@rnx-kit/metro-config");
const config = getDefaultConfig(__dirname);
module.exports = makeMetroConfig(config);
Hello @shirakaba , Thank you for putting together this walkthrough ,I'm trying to follow your instruction to an existing expo project but I can't seem to be able to get this to work.
the differences between my project and yours are:
index.js (since expo doesn't have App.js, I'm using the App from node_module/expo-router/entry.js)
import { registerRootComponent } from "expo";
import { App } from "expo-router/build/qualified-entry";
registerRootComponent(App);
I got the
Appfromnode_module/expo-router/entry.jswhich is the default main in package.json of an expo application// `@expo/metro-runtime` MUST be the first import to ensure Fast Refresh works // on web. import '@expo/metro-runtime'; import { App } from 'expo-router/build/qualified-entry'; import { renderRootComponent } from 'expo-router/build/renderRootComponent'; // This file should only import and register the root. No components or exports // should be added here. renderRootComponent(App);
second change is in metro.config.js I had this line so I kept it at the end of your metro.config.js without changing anything in it
module.exports = withNativeWind(config, { input: "./src/app/global.css", configPath: "./tailwind.config.js" });
I also added to react-native.config.js the project and solution path
windows: {
sourceDir: "./windows",
solutionFile: "myproject.sln",
project: {
projectFile: "windows/myproject/myproject.vcxproj",
},
},
I'm getting this error when trying to run yarn windows
> yarn windows
yarn run v1.22.19
$ react-native run-windows
× Couldn't get app solution information. Couldn't determine Windows app config
Command failed. Re-run the command with --logging for more information.
error Command failed with exit code 3000.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
@shirakaba I am getting this error when I try to run for windows. Web and Android works good. Running Expo v51.0.37.
@acoates-ms Just in case you haven't seen the custom-prebuild-example repo, the Expo team showed me this as an example of how they develop config plugins outside of the Expo monorepo before figuring out how to upstream them later.
They show some such config plugins in the app/plugins folder, which even has both macos and windows directories to begin with.