Skip to content

fix(metro): pin uniwind to a single copy in withUniwindConfig#564

Closed
ottob wants to merge 1 commit into
uni-stack:mainfrom
ottob:fix/pin-uniwind-single-copy
Closed

fix(metro): pin uniwind to a single copy in withUniwindConfig#564
ottob wants to merge 1 commit into
uni-stack:mainfrom
ottob:fix/pin-uniwind-single-copy

Conversation

@ottob

@ottob ottob commented Jun 3, 2026

Copy link
Copy Markdown

Problem

In monorepos using bun or pnpm, uniwind can be installed as multiple physical copies (different peer-hash variants in bun's store, or duplicate copies under pnpm). When more than one copy ends up reachable by Metro, there are two failure modes:

  1. Hermes crash at startupRangeError: Maximum call stack size exceeded (native stack depth), a get NativeModules recursion. uniwind's metro plugin rewrites import 'react-native'uniwind/components. The isInternal guard (keyed to require.resolve('uniwind/package.json')) is meant to let uniwind's own require('react-native') reach real RN — but when Metro resolves a uniwind file to a different copy than the one that seeded the guard, isInternal is false for code that genuinely is internal, so the shim redirects its own require('react-native') back into uniwind/components and recurses forever.

  2. Silently broken styling — when it doesn't crash outright, the two copies split uniwind's theme/CSS runtime across instances: components render from one copy while the theme initialized in the other. Result is no styling plus [colorKit.RGB] … "invalid" is not a valid color or brush warnings.

This is install-order dependent (the crash happens only when an app's canonical copy differs from the copy Metro's hierarchical lookup falls back to), which makes it hard to reproduce on demand.

Tracked in #353. Minimal deterministic repro: https://github.com/ottob/uniwind-353-repro (bun install && bun run diagnose shows the two copies and predicts the crash).

Fix

Pin every uniwind specifier to a single physical copy by resolving it from one stable origin (the Metro projectRoot). The key detail: wrap the resolver that's passed into nativeResolver/webResolver, not just the outer resolveRequest — the platform resolvers perform the internal react-nativeuniwind/components redirect through that inner resolver, and that's exactly where the recursion forms.

const baseResolver = config.resolver?.resolveRequest ?? context.resolveRequest
const pinnedOrigin = resolve(config.projectRoot ?? process.cwd(), 'index.js')
const resolver: CustomResolver = (ctx, name, plat) =>
  name.split('/')[0] === 'uniwind' && ctx.originModulePath !== pinnedOrigin
    ? baseResolver({ ...ctx, originModulePath: pinnedOrigin }, name, plat)
    : baseResolver(ctx, name, plat)

This collapses duplicate copies to one instance, fixing both the recursion and the split-runtime styling bug. Non-uniwind specifiers (including react-native) are untouched, so the existing react-native → components shim for app code behaves exactly as before.

Notes / scope for discussion

  • Pins only bare uniwind / uniwind/* specifiers; everything else is passed through unchanged.
  • config.projectRoot from getDefaultConfig is relative ("."), so path.resolve (not join) is used to produce an absolute origin.
  • Alternative considered: hardening only the isInternal guard to recognize any …/uniwind/(dist|src)/… origin. That stops the crash but not the split-runtime styling bug, because the duplication is in how Metro resolves import { Text } from 'uniwind' per-origin — only single-copy resolution fixes both. Hence pinning here.

Verification

  • check:typescript, lint, dprint fmt, and build all pass.
  • Validated end-to-end in a real bun monorepo (Expo SDK 56, RN 0.85, new arch) that previously crashed with the get NativeModules recursion: with this change the app boots and styling renders correctly, with no per-app metro.config.js workaround.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed Metro bundler's CSS source file resolution by properly extending recognized source extensions.
    • Improved module resolution consistency by ensuring Uniwind package imports are correctly pinned to their intended physical locations.

In monorepos (bun/pnpm) uniwind can be installed as multiple peer-hash
copies. Mixing them has two failure modes:

1. Hermes crashes at startup with "Maximum call stack size exceeded
   (native stack depth)" — a get NativeModules recursion. The
   react-native shim redirects require('react-native') to
   uniwind/components; when that resolves to a different copy than the
   one seeding the isInternal guard, the shim redirects into itself
   across copies and never terminates.
2. When it doesn't crash, the two copies split uniwind's theme/CSS
   runtime across instances: components render from one copy while the
   theme initialized in the other, so styling silently breaks and
   colorKit reports "invalid" colors.

Wrap the resolver passed into the platform resolvers so every 'uniwind'
specifier resolves from a single origin (the project root). Wrapping the
inner resolver (not just resolveRequest) is required so the internal
react-native -> uniwind/components redirects are pinned too. This
collapses duplicate copies to one instance, fixing both failure modes.

Refs uni-stack#353
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The Metro adapter is updated to resolve CSS sources as part of the module resolution pipeline and to pin all uniwind package requests to a single physical origin, preventing duplicate module instances in monorepo setups.

Changes

Metro CSS and module resolution

Layer / File(s) Summary
Import setup and dependencies
packages/uniwind/src/bundler/adapters/metro/metro.ts
Node path.resolve import is added and imports are reorganized with explicit type-only markers.
Metro resolver configuration with CSS source and origin pinning
packages/uniwind/src/bundler/adapters/metro/metro.ts
Metro's sourceExts is extended to include css, assetExts is filtered to exclude css, and resolver.resolveRequest is overridden to pin all uniwind/... module requests to a single originModulePath derived from config.projectRoot or process.cwd().

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • uni-stack/uniwind#483: Both PRs adjust Metro's handling of CSS—main PR updates Metro's resolver/config to treat .css as source (not asset) and the retrieved PR changes the Metro transformer's isCss detection that drives CSS-specific transforming.
  • uni-stack/uniwind#506: Both PRs change Metro resolver behavior to avoid/handle uniwind/... module origin/package-root resolution issues by pinning or computing the uniwind physical root differently.
  • uni-stack/uniwind#314: Both PRs modify Metro's module resolution logic to force react-native/uniwind/... requests to route through a pinned uniwind/components physical origin via resolver overrides.

Suggested reviewers

  • Brentlok

Poem

🐰 A bundler's path now resolves with grace,

CSS flows through Metro's proper place,

Pinned origins keep the modules sane,

No monorepo chaos to sustain! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: pinning uniwind to a single copy in the Metro adapter's withUniwindConfig function, which directly addresses the PR's core objective of resolving monorepo duplication issues.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/uniwind/src/bundler/adapters/metro/metro.ts`:
- Around line 30-31: withUniwindConfig is currently spreading
config.resolver?.sourceExts and filtering assetExts which can drop Metro
defaults when resolver fields are missing; update withUniwindConfig to either
(a) obtain Metro defaults (e.g., call getDefaultConfig()/mergeConfig()) and
merge those defaults with config.resolver.sourceExts and
config.resolver.assetExts before appending 'css' and filtering, or (b) add a
runtime assertion that config.resolver.sourceExts and config.resolver.assetExts
are present and throw a clear error if they are not; reference the
withUniwindConfig function and the resolver fields (config.resolver.sourceExts,
config.resolver.assetExts) when implementing the merge or the fail-fast
assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 88d06987-41f4-4349-9f24-c3ec9e69a0a4

📥 Commits

Reviewing files that changed from the base of the PR and between ec328ef and 3737691.

📒 Files selected for processing (1)
  • packages/uniwind/src/bundler/adapters/metro/metro.ts

Comment thread packages/uniwind/src/bundler/adapters/metro/metro.ts
@Brentlok

Brentlok commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

With this changes I'm getting a different error inside your repro

simulator_screenshot_7A9C8786-3376-4406-BECF-37D809135E46

I've just:
Run bun install
Updated code inside node_modules/uniwind/dist/index.cjs
Run bun run b -- --clear

Comment on lines +1 to +11
import { resolve } from 'node:path'

import { UniwindBundlerConfig } from '@/bundler/config'
import type { UniwindConfig } from '@/bundler/types'
import { Platform } from '@/common/consts'
import type { MetroConfig } from 'metro-config'

import { cacheStore, patchMetroGraphToSupportUncachedModules } from './patches'
import { nativeResolver, webResolver } from './resolvers'

import type { UniwindConfig } from '@/bundler/types'
import type { MetroConfig } from 'metro-config'
import type { CustomResolver } from 'metro-resolver'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not really common in this repo to separate imports

Comment on lines -25 to +31
sourceExts: [
...config.resolver?.sourceExts ?? [],
'css',
],
assetExts: config.resolver?.assetExts?.filter(
ext => ext !== 'css',
),
sourceExts: [...(config.resolver?.sourceExts ?? []), 'css'],
assetExts: config.resolver?.assetExts?.filter((ext) => ext !== 'css'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is reformated?

Comment on lines +34 to +43
// Pin every `uniwind` specifier to a single physical copy. In monorepos
// (bun/pnpm) uniwind can be installed as multiple peer-hash copies; mixing
// them both crashes Hermes ("Maximum call stack size exceeded" via
// get NativeModules recursion, when the react-native shim redirects across
// copies) and splits uniwind's theme runtime across instances (no styling,
// colorKit "invalid" color errors). Resolving every `uniwind` request from
// a single origin (the project root) collapses them to one instance.
// Wrapping `resolver` — not just this resolveRequest — ensures the platform
// resolvers' internal `react-native` -> `uniwind/components` redirects are
// pinned too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment seems to be too long

@Brentlok

Brentlok commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

fixed in #570

@Brentlok Brentlok closed this Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants