Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expo support #13534

Open
shirakaba opened this issue Aug 3, 2024 · 6 comments
Open

Expo support #13534

shirakaba opened this issue Aug 3, 2024 · 6 comments
Assignees
Labels
Area: Developer Experience Developer First Experience Issues that are going to be hit by a new developer as they first try out RNW enhancement External Developer Tooling Issue caused by the tool chain, not by RNW itself
Milestone

Comments

@shirakaba
Copy link

shirakaba commented Aug 3, 2024

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

Related issues

@microsoft-github-policy-service microsoft-github-policy-service bot added the Needs: Triage 🔍 New issue that needs to be reviewed by the issue management team (label applied by bot) label Aug 3, 2024
@kziemski
Copy link

kziemski commented Aug 3, 2024

@shirakaba ty for this.

@jonthysell
Copy link
Contributor

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

@jonthysell jonthysell self-assigned this Aug 5, 2024
@jonthysell jonthysell added Developer First Experience Issues that are going to be hit by a new developer as they first try out RNW Area: Developer Experience External Developer Tooling Issue caused by the tool chain, not by RNW itself and removed Needs: Triage 🔍 New issue that needs to be reviewed by the issue management team (label applied by bot) labels Aug 5, 2024
@jonthysell jonthysell added this to the Next milestone Aug 5, 2024
@shirakaba
Copy link
Author

shirakaba commented Aug 5, 2024

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

  • react-native-macos is 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 .dll files 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 create and prebuild commands.
  • However, that same PR creates a new problem. Previously, you could just write the magic HelloWorld placeholder 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 ByeWorld and just run our own renaming script after any usages of create or prebuild.

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
Copy link

wodin commented Aug 6, 2024

@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
Copy link
Author

shirakaba commented Aug 6, 2024

@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
Copy link
Member

tido64 commented Aug 12, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area: Developer Experience Developer First Experience Issues that are going to be hit by a new developer as they first try out RNW enhancement External Developer Tooling Issue caused by the tool chain, not by RNW itself
Projects
None yet
Development

No branches or pull requests

5 participants