microsoft / react-native-windows

A framework for building native Windows apps with React.
https://microsoft.github.io/react-native-windows/
Other
16.24k stars 1.13k forks source link

Expo support #13534

Open shirakaba opened 1 month ago

shirakaba commented 1 month ago

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

Related issues

kziemski commented 1 month ago

@shirakaba ty for this.

jonthysell commented 1 month ago

@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)".

shirakaba commented 1 month ago

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-windows command 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):

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.

wodin commented 1 month ago

@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)?

shirakaba commented 1 month ago

@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 create are you referring to create-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.

tido64 commented 1 month ago

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.

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);