Skip to content

feat(vite): add a Vite resolver so className works outside Metro - #406

Open
p-larson wants to merge 2 commits into
nativewind:mainfrom
p-larson:feat/vite-resolver
Open

feat(vite): add a Vite resolver so className works outside Metro#406
p-larson wants to merge 2 commits into
nativewind:mainfrom
p-larson:feat/vite-resolver

Conversation

@p-larson

@p-larson p-larson commented Aug 6, 2026

Copy link
Copy Markdown

Closes #405 — opened there first per CONTRIBUTING's guidance on API changes. Happy to rework or close this if you would rather take a different approach.

Problem

Under Vite nothing performs the react-nativereact-native-css/components swap, so every className is dropped. The page still renders — same DOM, same text, no error and no warning — it is just unstyled.

NativeWind 4 exported a JSX runtime, so the documented Other Bundlers recipe worked anywhere. On v5 its third requirement cannot be satisfied: nativewind@5.0.0-preview.4 exports only ., ./babel, ./metro, ./types, ./theme, and setting jsxImportSource: "nativewind" fails with Missing "./jsx-dev-runtime" specifier in "nativewind" package.

Approach

Adds a react-native-css/vite export applying the mapping nativeResolver already uses:

if (moduleName === "react-native") {
  return resolver(context, `react-native-css/components`, platform);
}

react-native-css/components is already a barrel re-exporting react-native with the wrappers layered on top, so no new module is introduced and the component list cannot drift as wrappers are added.

Why not port webResolver

Rewriting react-native-web/dist/exports/<X>/index.jscomponents/<X> was tried first and abandoned:

  1. Rollup resolvers do not run during dependency pre-bundling. Making the rewrite fire requires excluding react-native-web from optimizeDeps, which exposes its CommonJS dependencies to the browser as raw CJS — inline-style-prefixer, fbjs, styleq and others, each needing its own optimizeDeps.include entry, down to deep paths like inline-style-prefixer/lib/plugins/crossFade.js.
  2. It rewrites react-native-web's own internal imports, producing the initialization cycle reported in babel import-plugin still rewrites react-native-web internals, causing a circular-import crash on web (incomplete fix of #196 / #202) #380.

Following nativeResolver avoids both: react-native-web stays pre-bundled and its internals are never touched.

Implementation notes

  • Imports originating inside react-native-css are skipped, mirroring isFromThisModule. The barrel re-exports react-native and each wrapper uses its base component at module scope (copyComponentProperties(RNView, …) runs during evaluation), so redirecting them recreates the babel import-plugin still rewrites react-native-web internals, causing a circular-import crash on web (incomplete fix of #196 / #202) #380 cycle.
  • Both react-native and react-native-web are matched, because vite-plugin-rnw applies that alias before user plugins run. Matching only react-native silently no-ops.
  • The mapping is registered twice — as a Vite plugin for source files, and as an esbuild plugin in optimizeDeps.esbuildOptions.plugins for pre-bundled dependencies, since esbuild does not run Rollup resolvers.
  • vite is an optional peer dependency, so Metro-only users are unaffected.

Testing

  • 5 new tests in src/__tests__/vite/resolver.test.ts covering both specifiers, the isFromThisModule guard, unrelated specifiers, and the optimizeDeps registration.
  • yarn lint, yarn typecheck, and yarn test (1056 passing, 56 suites) all green.
  • Packed this branch and ran it end-to-end through the real react-native-css/vite export against a reproduction repo: https://github.com/p-larson/nativewind-vite-storybook-repro

That repo has two apps, byte-identical except for this plugin, and asserts computed styles rather than "did it render":

[broken]  className in DOM: false   computed size: null x null      PASS (expected unstyled)
[fixed]   className in DOM: true    computed size: 320px x 160px    PASS (expected styled)
broken fixed
unstyled styled

Docs

README's "Other bundlers" section is replaced with a "Vite based projects" section covering plain Vite and Storybook's react-native-web-vite.

NativeWind 5 removed the JSX runtime export that made className work under
any bundler, so on Vite the react-native -> react-native-css/components swap
never happens and every className is silently dropped. Stories and pages
still render, just unstyled, which makes it easy to miss.

This adds a `react-native-css/vite` export applying the same mapping
`nativeResolver` already uses: resolve `react-native` to
`react-native-css/components`, which re-exports react-native with the
className-aware wrappers layered on top.

`webResolver`'s path rewriting is deliberately not ported. Rollup resolvers
do not run during dependency pre-bundling, so it would require excluding
react-native-web from optimizeDeps and re-adding each of its CommonJS
dependencies by hand. It also rewrites react-native-web's own internal
imports, which is the circular-import crash in nativewind#380.

Two details the implementation depends on:

- Imports originating inside react-native-css are skipped, mirroring
  `isFromThisModule`. The barrel re-exports react-native and the wrappers use
  their base component at module scope, so redirecting them would cycle.
- Both `react-native` and `react-native-web` are matched, because
  vite-plugin-rnw applies that alias before user plugins run.

The mapping is registered as a Vite plugin for source files and as an esbuild
plugin in optimizeDeps for pre-bundled dependencies, since esbuild does not
run Rollup resolvers.

vite is added as an optional peer dependency so Metro-only users are
unaffected.
@p-larson

p-larson commented Aug 6, 2026

Copy link
Copy Markdown
Author

Following up on the obvious question — why a resolver rather than the existing react-native-css/babel plugin, which already does this rewrite bundler-agnostically. I evaluated it rather than assuming, and added apps/babel to the reproduction.

It does work for app source. It is the weaker layer for one structural reason, plus two packaging bugs that are worth fixing regardless of this PR.

It does not reach code inside dependencies

@vitejs/plugin-react only runs babel over files matching its include filter, which excludes node_modules. Adding a published package that forwards className and measuring both approaches on the same story:

app source code inside a dependency
Resolver (this PR) 320px x 160px 160px x 80px
babel/import-plugin 320px x 160px class never reaches the DOM

Widening the filter to cover node_modules is possible, but that is exactly what re-introduces #380 — once babel rewrites react-native-web's own internal imports, the cycle appears. The resolver never rewrites anything inside react-native-web, so it avoids that by construction rather than by exclusion list.

Resolution also happens once per specifier instead of an AST pass per module.

Two packaging bugs found while testing

These are independent of this PR — happy to split them into their own issue, or fix them here if you prefer.

1. The ESM build of the babel preset calls require. Vite resolves the import condition to dist/module/babel/index.js, which fails immediately:

Vite Pre-transform error: [BABEL] require is not defined
(While processing: react-native-css/dist/module/babel/index.js)

Source is src/babel/index.ts:

export default function () {
  return {
    plugins: [
      require("./import-plugin").default,
      "react-native-worklets/plugin",
    ],
  };
}

Under Metro this resolves to the CommonJS build so it never surfaces, but the ESM build is unusable by any ESM-based babel config loader.

2. import-plugin has no public export. The only babel entry is the preset, which also hard-requires react-native-worklets/plugin — not needed on web. Loading just the import-plugin means going through require.resolve("react-native-css/package.json") and joining a dist/commonjs/... path by hand, since the exports map rejects the deep path with ERR_PACKAGE_PATH_NOT_EXPORTED.

If you would rather solve Vite through babel than a resolver, fixing those two would be the prerequisite — though the dependency-coverage gap above would remain.

The Metro resolvers and babel plugin anchor isFromThisModule on
resolve(__dirname, "../../../dist"), which only lands on the package when
running from the built output. From src -- the source export condition --
it resolves outside the package, so the guard never matches.

Vite resolves through whichever condition the consumer configured, so match
on the package directory instead. Adds a case for Vite's query suffixes.
@p-larson

p-larson commented Aug 6, 2026

Copy link
Copy Markdown
Author

One refinement pushed, and it surfaces a latent issue in the existing code worth flagging separately.

I initially copied isFromThisModule verbatim from src/metro/resolver.ts:

const thisModuleDist = resolve(__dirname, "../../../dist");

That depth is correct for the built output (dist/commonjs/metro/resolver.js → package root), but not when the module is loaded from src via the source export condition:

built  dist/commonjs/metro  -> thisModuleDist = <pkg>/dist
source src/metro            -> thisModuleDist = /tmp/dist      # outside the package

So from src the guard silently never matches. My tests caught it because jest runs against src. Since Vite resolves through whichever condition the consumer configured, the Vite plugin now anchors on the package directory instead, which holds for both layouts, and there is a test for Vite's ?v= query suffixes.

The same appears to apply to src/metro/resolver.ts and src/babel/import-plugin.ts under the source condition. I have deliberately left those alone to keep this PR to one change — happy to open a separate issue or PR if it is worth fixing.

Full suite still green: 1057 passing, 56 suites, plus yarn lint and yarn typecheck.

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.

className is silently dropped under Vite: no bundler integration exists outside Metro

1 participant