What happened?
The metro plugin's internal-origin guard compares raw path strings from two different resolution worlds, so under pnpm's symlinked layout the same physical uniwind file can be classified as external — and uniwind then rewrites its own internal react-native imports to uniwind/components, producing the self-import that ends in the get NativeModules recursion previously reported in #352 / #353.
At uniwind@1.10.0 (latest, verified today, dist/metro/index.mjs):
cachedInternalBasePath = dirname(require.resolve("uniwind/package.json"));
...
const isInternal = cachedInternalBasePath !== "" && context.originModulePath.startsWith(cachedInternalBasePath);
require.resolve follows CJS semantics and returns the canonical path: <root>/node_modules/.pnpm/uniwind@…/node_modules/uniwind.
- Metro's
context.originModulePath can carry the symlink form: <project>/node_modules/uniwind/dist/… (which path form Metro reports depends on resolver internals and layout — in pnpm workspaces we observed the symlink form under the Expo dev server).
Same file, different string → startsWith is false → isInternal is false → react-native from inside uniwind/components is rewritten to uniwind/components again.
#570 (released in 1.9.0) pins uniwind/* requests to a single copy, which fixes the multiple-physical-copies variant from #353 — but it does not canonicalize either side of the isInternal comparison, so the single-copy symlink-vs-realpath mismatch is still live in 1.10.0.
We originally hit this at 1.7.0 in a pnpm workspace (Expo dev server; expo export/EAS unaffected) and have been running the realpath fix below as a pnpm patch since — it resolved the breakage with no side effects.
Steps to Reproduce
Runnable demonstration against the shipped 1.10.0 dist through the public withUniwindConfig API (no simulator needed). In an empty dir:
printf '{"name":"repro","private":true}' > package.json
pnpm add uniwind@1.10.0 metro metro-cache tailwindcss
demo.js:
const fs = require("fs");
const path = require("path");
const { withUniwindConfig } = require("uniwind/metro");
fs.writeFileSync(path.join(__dirname, "global.css"), "@import 'tailwindcss';\n");
const calls = [];
const stubResolver = (context, moduleName, platform) => {
calls.push(moduleName);
if (moduleName === "react-native") {
return { type: "sourceFile", filePath: path.join(__dirname, "node_modules/react-native/index.js") };
}
return { type: "sourceFile", filePath: `/stub/${moduleName}.js` };
};
const config = withUniwindConfig(
{
projectRoot: __dirname,
transformer: {},
resolver: { sourceExts: ["ts", "tsx", "js", "jsx", "css"], resolveRequest: stubResolver },
},
{ cssEntryFile: "./global.css", dtsFile: "./uniwind-types.d.ts" }
);
const resolveRequest = config.resolver.resolveRequest;
const internalRealBase = path.dirname(require.resolve("uniwind/package.json"));
const componentsRel = "dist/module/components/index.js";
const symlinkForm = path.join(__dirname, "node_modules/uniwind", componentsRel);
const realForm = path.join(internalRealBase, componentsRel);
console.log("same physical file:", fs.realpathSync(symlinkForm) === realForm);
for (const [label, origin] of [
["real-path origin", realForm],
["symlink-form origin", symlinkForm],
]) {
calls.length = 0;
const result = resolveRequest(
{ originModulePath: origin, resolveRequest: stubResolver },
"react-native",
"ios"
);
console.log(`${label}: chain=${JSON.stringify(calls)} -> ${result.filePath}`);
}
Output (node demo.js, uniwind 1.10.0, pnpm 11):
same physical file: true
real-path origin: chain=["react-native"] -> <repro>/node_modules/react-native/index.js
symlink-form origin: chain=["react-native","uniwind/components"] -> /stub/uniwind/components.js
The symlink-form origin — uniwind's own dist/module/components/index.js — has its react-native import remapped into uniwind/components itself. In a real bundle that is the components-module-importing-itself cycle behind the get NativeModules stack overflow.
Expected behavior
isInternal should be true for uniwind's own files regardless of which path form Metro hands the resolver.
Suggested fix
Canonicalize both sides once (cached) and fall back safely:
--- a/dist/metro/index.mjs (mirror in index.cjs; source: the metro resolvers module)
+++ b/dist/metro/index.mjs
@@
-import 'fs';
+import { realpathSync } from 'fs';
@@
let cachedInternalBasePath = null;
+let cachedInternalRealBasePath = null;
+const getInternalBasePath = () => {
+ if (cachedInternalBasePath === null) {
+ try {
+ cachedInternalBasePath = dirname(require.resolve("uniwind/package.json"));
+ cachedInternalRealBasePath = realpathSync(cachedInternalBasePath);
+ } catch {
+ cachedInternalBasePath = "";
+ cachedInternalRealBasePath = "";
+ }
+ }
+ return cachedInternalBasePath;
+};
+const isUniwindInternalOrigin = (originModulePath) => {
+ const internalBasePath = getInternalBasePath();
+ if (internalBasePath === "") {
+ return false;
+ }
+ if (originModulePath.startsWith(internalBasePath)) {
+ return true;
+ }
+ try {
+ const realOriginPath = realpathSync(originModulePath);
+ return cachedInternalRealBasePath !== "" && realOriginPath.startsWith(cachedInternalRealBasePath);
+ } catch {
+ return false;
+ }
+};
with nativeResolver/webResolver switched to const isInternal = isUniwindInternalOrigin(context.originModulePath); (replacing both inline cachedInternalBasePath blocks). The realpathSync of the origin only runs when the fast prefix check fails, so the hot path is unchanged.
Uniwind version
1.10.0 (first observed at 1.7.0)
React Native Version
0.85.x (Expo SDK 56 dev server; pnpm workspace, default isolated/symlinked node_modules)
What happened?
The metro plugin's internal-origin guard compares raw path strings from two different resolution worlds, so under pnpm's symlinked layout the same physical uniwind file can be classified as external — and uniwind then rewrites its own internal
react-nativeimports touniwind/components, producing the self-import that ends in theget NativeModulesrecursion previously reported in #352 / #353.At
uniwind@1.10.0(latest, verified today,dist/metro/index.mjs):require.resolvefollows CJS semantics and returns the canonical path:<root>/node_modules/.pnpm/uniwind@…/node_modules/uniwind.context.originModulePathcan carry the symlink form:<project>/node_modules/uniwind/dist/…(which path form Metro reports depends on resolver internals and layout — in pnpm workspaces we observed the symlink form under the Expo dev server).Same file, different string →
startsWithis false →isInternalis false →react-nativefrom insideuniwind/componentsis rewritten touniwind/componentsagain.#570 (released in 1.9.0) pins
uniwind/*requests to a single copy, which fixes the multiple-physical-copies variant from #353 — but it does not canonicalize either side of theisInternalcomparison, so the single-copy symlink-vs-realpath mismatch is still live in 1.10.0.We originally hit this at 1.7.0 in a pnpm workspace (Expo dev server;
expo export/EAS unaffected) and have been running the realpath fix below as a pnpm patch since — it resolved the breakage with no side effects.Steps to Reproduce
Runnable demonstration against the shipped 1.10.0 dist through the public
withUniwindConfigAPI (no simulator needed). In an empty dir:demo.js:Output (
node demo.js, uniwind 1.10.0, pnpm 11):The symlink-form origin — uniwind's own
dist/module/components/index.js— has itsreact-nativeimport remapped intouniwind/componentsitself. In a real bundle that is the components-module-importing-itself cycle behind theget NativeModulesstack overflow.Expected behavior
isInternalshould be true for uniwind's own files regardless of which path form Metro hands the resolver.Suggested fix
Canonicalize both sides once (cached) and fall back safely:
with
nativeResolver/webResolverswitched toconst isInternal = isUniwindInternalOrigin(context.originModulePath);(replacing both inlinecachedInternalBasePathblocks). TherealpathSyncof the origin only runs when the fast prefix check fails, so the hot path is unchanged.Uniwind version
1.10.0 (first observed at 1.7.0)
React Native Version
0.85.x (Expo SDK 56 dev server; pnpm workspace, default isolated/symlinked node_modules)