Skip to content

Pro 1.7 + public uniwind in a pnpm monorepo: Metro cannot resolve uniwind/index, then uniwind/components recurses #682

Closed

Description

@antoinerousseau

What happened?

In a pnpm monorepo, one Expo app depends on Uniwind Pro ("uniwind": "npm:uniwind-pro@^1.7.0") while another app depends on public Uniwind ("uniwind": "^1.12.0"). Both packages publish as "name": "uniwind". With publicHoistPattern: "*", the public copy is hoisted to the workspace root.

That produces two Metro failures:

1. Bundling: missing main / index

Unable to resolve "uniwind" from "src/app/_layout.tsx"
The package `…/node_modules/uniwind/package.json` was successfully found. However, this package itself specifies a
`main` module field that could not be resolved (`…/node_modules/uniwind/index`).

Pro installs as a CLI stub with no exports / main until postinstall. Even after the payload is installed, package.json has an exports map (react-native → ./src/index.ts) but no top-level main or react-native field. If Metro skips exports (common when the file map cannot see the pnpm store path), it defaults main to index, which does not exist.

Related: #679 (closed as invalid — not filed with the template). Same class of failure as react/metro#1424.

2. Runtime: uniwind/components rewrite hits the public copy

withUniwindConfig rewrites react-native → uniwind/components using the importing file as originModulePath. Modules outside the Pro app (expo-router, reanimated, …) then resolve uniwind from the hoisted public copy. That copy’s own react-native imports are rewritten again → infinite recursion:

Maximum call stack size exceeded (native stack depth)
"main" has not been registered

This is the same class of bug as #353, fixed for OSS in #570 (pin originModulePath when resolving uniwind / uniwind/*). Pro 1.7 still ships the pre-#570 plugin, so the pin is missing.

require("uniwind/metro") from the Pro app can also walk past the installer stub and load the public plugin.

Steps to Reproduce

  1. pnpm workspace with two Expo apps.
  2. App A: "uniwind": "npm:uniwind-pro@^1.7.0".
  3. App B: "uniwind": "^1.12.0" (public).
  4. publicHoistPattern: "*" (or otherwise hoist public uniwind to the workspace root).
  5. From App A, expo start / expo export --platform ios.
  6. Bundling fails with the uniwind/index error above. If the Pro payload is present but the plugin is the public 1.12 copy (or Pro 1.7 without the origin pin), the device then crashes on launch with a native stack overflow.

Workaround that unblocked us: resolve Pro by filesystem path (<app>/node_modules/uniwind), load withUniwindConfig from that copy’s exports["./metro"], map uniwind / uniwind/* to that copy’s exports targets on disk, and pin originModulePath to App A’s package.json.

Snack or Repository Link (Optional)

Private monorepo — cannot share a public repro. Happy to extract a minimal pnpm workspace if useful.

Uniwind version

uniwind-pro 1.7.0 (app A) + public uniwind 1.12.0 (app B, hoisted)

React Native Version

0.86.3 (Expo SDK 57)

Platforms

iOS

Expo

Yes

Additional information 〰

Activity

  1. antoinerousseau commented on Sep 21, 2026

    @antoinerousseau
    ContributorAuthor

    Workaround

    We pinned Metro to the app’s own node_modules/uniwind (the Pro copy) instead of letting Node/require("uniwind/metro") walk up to a hoisted public uniwind. Drop this next to metro.config.js and wrap your Expo config with it.

    metro.uniwind-pro.js:

    const { execFileSync } = require("node:child_process");
    const fs = require("node:fs");
    const path = require("node:path");
    
    function readPackage(uniwindRoot) {
      return JSON.parse(
        fs.readFileSync(path.join(uniwindRoot, "package.json"), "utf8"),
      );
    }
    
    function ensureUniwindPro(projectRoot) {
      const uniwindDir = path.resolve(projectRoot, "node_modules/uniwind");
      if (!fs.existsSync(uniwindDir)) {
        throw new Error(
          `Uniwind Pro is not linked at ${uniwindDir}. Run your package manager from the workspace root.`,
        );
      }
    
      const uniwindRoot = fs.realpathSync(uniwindDir);
    
      // Installer stub has no `exports` until postinstall (`npx uniwind-pro` / UNIWIND_AUTH_TOKEN).
      if (!readPackage(uniwindRoot).exports) {
        execFileSync(
          process.execPath,
          [path.join(uniwindRoot, "pre/postinstall/index.js")],
          { cwd: uniwindRoot, stdio: "inherit" },
        );
      }
    
      const uniwindPackage = readPackage(uniwindRoot);
      if (!uniwindPackage.exports) {
        throw new Error(
          `Uniwind Pro is not installed in ${uniwindRoot}. Run \`npx uniwind-pro\` or set UNIWIND_AUTH_TOKEN.`,
        );
      }
    
      return { uniwindRoot, uniwindPackage };
    }
    
    function pickExportTarget(target, condition) {
      while (target && typeof target === "object") {
        target =
          target[condition] ??
          target["react-native"] ??
          target.default ??
          target.import;
      }
      return typeof target === "string" ? target : null;
    }
    
    function resolveExportPath(uniwindRoot, uniwindPackage, subpath, condition) {
      const target = pickExportTarget(uniwindPackage.exports[subpath], condition);
      if (target == null) {
        throw new Error(
          `Uniwind Pro has no "${condition}" export for ${subpath} in ${uniwindRoot}.`,
        );
      }
      return path.join(uniwindRoot, target);
    }
    
    function existingFile(filePath) {
      const withoutExt = filePath.replace(/\.(tsx|ts|jsx|js|cjs|mjs)$/, "");
      for (const candidate of [
        filePath,
        `${withoutExt}.tsx`,
        `${withoutExt}.ts`,
        `${withoutExt}.js`,
        path.join(filePath, "index.ts"),
        path.join(filePath, "index.tsx"),
        path.join(filePath, "index.js"),
      ]) {
        try {
          if (fs.statSync(candidate).isFile()) {
            return fs.realpathSync(candidate);
          }
        } catch {
          // try next
        }
      }
      return null;
    }
    
    function resolveFromDisk(uniwindRoot, uniwindPackage, moduleName, platform) {
      if (moduleName !== "uniwind" && !moduleName.startsWith("uniwind/")) {
        return null;
      }
    
      const condition = platform === "web" ? "browser" : "react-native";
      const subpath =
        moduleName === "uniwind" ? "." : `./${moduleName.slice("uniwind/".length)}`;
      const exportsField = uniwindPackage.exports;
    
      let target = pickExportTarget(exportsField[subpath], condition);
      if (target == null) {
        for (const [key, value] of Object.entries(exportsField)) {
          if (!key.includes("*")) continue;
          const [prefix, suffix = ""] = key.split("*");
          if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
          const matched = subpath.slice(
            prefix.length,
            suffix ? subpath.length - suffix.length : undefined,
          );
          const picked = pickExportTarget(value, condition);
          if (picked) {
            target = picked.replaceAll("*", matched);
            break;
          }
        }
      }
    
      if (target == null) return null;
      const filePath = existingFile(path.join(uniwindRoot, target));
      if (filePath == null) return null;
      return { type: "sourceFile", filePath };
    }
    
    function withUniwindResolve(
      resolveRequest,
      { uniwindRoot, uniwindPackage, uniwindOrigin },
    ) {
      return (context, moduleName, platform) => {
        const fromDisk = resolveFromDisk(
          uniwindRoot,
          uniwindPackage,
          moduleName,
          platform,
        );
        if (fromDisk) return fromDisk;
    
        const resolve = resolveRequest ?? context.resolveRequest;
        if (moduleName === "uniwind" || moduleName.startsWith("uniwind/")) {
          return resolve(
            { ...context, originModulePath: uniwindOrigin },
            moduleName,
            platform,
          );
        }
        return resolve(context, moduleName, platform);
      };
    }
    
    /**
     * Pin Metro to this app's uniwind-pro copy so a hoisted public `uniwind`
     * (same package name) cannot be loaded instead.
     *
     * - Loads `withUniwindConfig` from this copy's `exports["./metro"]`
     * - Maps `uniwind` / `uniwind/*` to that copy's `exports` targets on disk
     *   (avoids Metro's missing `main` → `index` fallback)
     * - Pins `originModulePath` as a fallback so `uniwind/components` redirects
     *   from files outside this app never hit the public copy (#353 / #570)
     */
    function withPinnedUniwind(config, uniwindOptions = {}) {
      const projectRoot =
        uniwindOptions.projectRoot ??
        config.projectRoot ??
        path.dirname(__filename);
      const { uniwindRoot, uniwindPackage } = ensureUniwindPro(projectRoot);
      const uniwindOrigin = path.join(projectRoot, "package.json");
      const pin = { uniwindRoot, uniwindPackage, uniwindOrigin };
    
      const { withUniwindConfig } = require(
        resolveExportPath(uniwindRoot, uniwindPackage, "./metro", "require"),
      );
    
      config.watchFolders = [
        ...new Set([...(config.watchFolders ?? []), uniwindRoot]),
      ];
    
      config.resolver.resolveRequest = withUniwindResolve(
        config.resolver.resolveRequest,
        pin,
      );
    
      const { projectRoot: _projectRoot, ...pluginOptions } = uniwindOptions;
      const uniwindConfig = withUniwindConfig(config, pluginOptions);
      uniwindConfig.resolver.resolveRequest = withUniwindResolve(
        uniwindConfig.resolver.resolveRequest,
        pin,
      );
    
      return uniwindConfig;
    }
    
    module.exports = { withPinnedUniwind };

    metro.config.js:

    const { getDefaultConfig } = require("expo/metro-config");
    const { withPinnedUniwind } = require("./metro.uniwind-pro");
    
    const config = getDefaultConfig(__dirname);
    
    module.exports = withPinnedUniwind(config, {
      cssEntryFile: "./global.css", // relative to the app root
      projectRoot: __dirname,       // required if this helper is not next to metro.config.js
    });

    Restart Metro with a cache reset after adding it (expo start -c).

  2. antoinerousseau commented on Sep 21, 2026

    @antoinerousseau
    ContributorAuthor

    Opened an OSS PR for the missing main / react-native fallbacks: #683

    That covers the Metro …/uniwind/index error when exports is skipped. The origin pin for uniwind/components is already on OSS main via #570 — Pro 1.7 still needs that backport on the Pro payload (not in this repo).

  3. Brentlok commented on Sep 24, 2026

    @Brentlok
    Contributor

    The origin pin for uniwind/components is already on OSS main via #570 — Pro 1.7 still needs that backport on the Pro payload (not in this repo).

    The origin pin is applied for both oss and pro versions. I wasn't able to reproduce the issue from your instructions alone, please extract minimal reproduction repo.

  4. added
    bugSomething isn't working
    and removed on Sep 24, 2026
  5. jpudysz commented on Sep 28, 2026

    @jpudysz
    Collaborator

    I was able to reproduce this.

    Image

    Expo enables autolinking module resolution by default in monorepos, and it resolves native packages by name from inside the package's own folder. pnpm stores the aliased package in a folder named uniwind-pro, so looking up uniwind from there walks up to the hoisted public copy. The Pro app then bundles the public uniwind instead of Pro, and its react-native imports get rewritten back to itself. That's the Maximum call stack size exceeded and "main" has not been registered crash on launch.

    I have a fix that will be published with next pro version, until then you can:

    • Set "experiments": { "autolinkingModuleResolution": false } in app.json
    • Keep the public uniwind from being hoisted to the workspace root
  6. github-actions commented on Oct 1, 2026

    @github-actions
    Contributor

    New release is published: https://docs.uniwind.dev/pro/changelog

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions