Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,41 @@ Follow the Expo instructions, but replace the `expo` package with `@expo/metro-c
+ import { getDefaultConfig } from "@expo/metro-config";
```

### Vite based projects

Add the `reactNativeCSS` plugin to your Vite configuration:

```ts
import { defineConfig } from "vite";
import { reactNativeCSS } from "react-native-css/vite";

export default defineConfig({
plugins: [reactNativeCSS()],
});
```

For Storybook's `react-native-web-vite` framework, add it in `viteFinal`:

```ts
import { mergeConfig } from "vite";
import { reactNativeCSS } from "react-native-css/vite";

const config = {
framework: "@storybook/react-native-web-vite",
viteFinal: (config) =>
mergeConfig(config, { plugins: [reactNativeCSS()] }),
};

export default config;
```

Vite does not process CSS through the Metro transformer, so import your
Tailwind entry stylesheet directly (for example in `.storybook/preview.ts`)
and let Vite's PostCSS pipeline handle it.

### Other bundlers

`react-native-css` officially only supports Metro as the bundler, but we welcome community contributions to support other bundlers like Webpack, Vite or Turbopack.
`react-native-css` officially supports Metro and Vite, but we welcome community contributions to support other bundlers like Webpack or Turbopack.

More documentation coming soon.

Expand Down
22 changes: 20 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@
"default": "./dist/commonjs/metro/index.js"
}
},
"./vite": {
"source": "./src/vite/index.ts",
"import": {
"types": "./dist/typescript/module/src/vite/index.d.ts",
"default": "./dist/module/vite/index.js"
},
"require": {
"types": "./dist/typescript/commonjs/src/vite/index.d.ts",
"default": "./dist/commonjs/vite/index.js"
}
},
"./native": {
"source": "./src/native/index.ts",
"import": {
Expand Down Expand Up @@ -207,7 +218,13 @@
"@expo/metro-config": ">=54",
"lightningcss": ">=1.27.0",
"react": ">=19",
"react-native": ">=0.81"
"react-native": ">=0.81",
"vite": ">=5"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
},
"devDependencies": {
"@babel/core": "^7.28.0",
Expand Down Expand Up @@ -250,7 +267,8 @@
"tailwindcss": "^4.1.12",
"tailwindcss-safe-area": "^1.1.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0"
"typescript-eslint": "^8.40.0",
"vite": "^7.2.2"
},
"react-native-builder-bob": {
"source": "src",
Expand Down
100 changes: 100 additions & 0 deletions src/__tests__/vite/resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { resolve as resolvePath } from "node:path";

import { reactNativeCSS } from "../../vite";

type ResolveIdHook = (
this: { resolve: jest.Mock },
source: string,
importer: string | undefined,
options: Record<string, unknown>,
) => Promise<unknown>;

function setup() {
const plugin = reactNativeCSS();
const resolve = jest.fn().mockResolvedValue({ id: "/resolved/components" });
const resolveId = plugin.resolveId as unknown as ResolveIdHook;

const call = (source: string, importer?: string) =>
resolveId.call({ resolve }, source, importer, {});

return { plugin, resolve, call };
}

/** Inside this package, so `isFromThisModule` must skip it. */
const ownFile = resolvePath(
"/app/node_modules/react-native-css/dist/module/components/View.js",
);
const appFile = resolvePath("/app/src/Button.tsx");

describe("vite resolver", () => {
it("redirects react-native to react-native-css/components", async () => {
const { resolve, call } = setup();

await call("react-native", appFile);

expect(resolve).toHaveBeenCalledWith(
"react-native-css/components",
appFile,
expect.objectContaining({ skipSelf: true }),
);
});

it("redirects react-native-web, which bundlers alias to before plugins run", async () => {
const { resolve, call } = setup();

await call("react-native-web", appFile);

expect(resolve).toHaveBeenCalledWith(
"react-native-css/components",
appFile,
expect.objectContaining({ skipSelf: true }),
);
});

it("does not redirect imports from this module", async () => {
const { resolve, call } = setup();

// The components barrel re-exports react-native and each wrapper uses its
// base component at module scope, so redirecting these would cycle.
await expect(call("react-native", ownFile)).resolves.toBeNull();
expect(resolve).not.toHaveBeenCalled();
});

it("strips Vite's query suffix before checking the importer", async () => {
const { resolve, call } = setup();

await expect(
call("react-native", `${ownFile}?v=abc123`),
).resolves.toBeNull();
expect(resolve).not.toHaveBeenCalled();
});

it("ignores unrelated specifiers", async () => {
const { resolve, call } = setup();

for (const source of [
"react",
"react-native-svg",
"react-native-web/dist/exports/View",
"./View",
]) {
await expect(call(source, appFile)).resolves.toBeNull();
}

expect(resolve).not.toHaveBeenCalled();
});

it("registers the same mapping for dependency pre-bundling", () => {
const { plugin } = setup();

const config = (
plugin.config as unknown as () => {
optimizeDeps: { esbuildOptions: { plugins: { name: string }[] } };
}
)();

expect(config.optimizeDeps.esbuildOptions.plugins).toEqual([
expect.objectContaining({ name: "react-native-css" }),
]);
});
});
122 changes: 122 additions & 0 deletions src/vite/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { sep } from "node:path";

import type { Plugin } from "vite";

/**
* The specifier `vite-plugin-rnw` and most React Native Web setups alias
* `react-native` to. The alias is applied before user plugins run, so both
* names have to be matched or the plugin silently never fires.
*/
const REACT_NATIVE_SPECIFIER = /^react-native(-web)?$/;

const COMPONENTS = "react-native-css/components";

const ESBUILD_PLUGIN_NAME = "react-native-css";

const THIS_MODULE_DIR = `${sep}react-native-css${sep}`;

/**
* Serves the same purpose as `isFromThisModule` in the Metro resolvers and the
* babel plugin: `react-native-css/components` re-exports `react-native`, and
* each wrapper uses its base component at module scope, so redirecting our own
* imports would create an initialization cycle.
*
* Those copies anchor 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 and the check
* silently never matches. Vite resolves through whichever condition the
* consumer configured, so this matches on the package directory instead, which
* holds for both layouts.
*
* Vite also appends query suffixes to ids (`?v=`, `?import`), so strip those
* before comparing.
*/
function isFromThisModule(importer: string | undefined): boolean {
if (!importer) {
return false;
}

const filename = importer.split("?")[0] ?? importer;

return filename.includes(THIS_MODULE_DIR);
}

/**
* Vite plugin that enables `className` under Vite-based bundlers, the same way
* `withReactNativeCSS` does for Metro.
*
* ```ts
* // vite.config.ts
* import { reactNativeCSS } from "react-native-css/vite";
*
* export default defineConfig({
* plugins: [reactNativeCSS()],
* });
* ```
*
* It applies the 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 approach (rewriting `react-native-web`'s internal module
* paths) is deliberately not used here. Rollup resolvers do not run during
* dependency pre-bundling, so it would require excluding `react-native-web`
* from `optimizeDeps` and then 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 #380.
*/
export function reactNativeCSS(): Plugin {
return {
name: "react-native-css",
enforce: "pre",

config() {
return {
optimizeDeps: {
/**
* Pre-bundled dependencies are resolved by esbuild, which does not
* run Rollup resolvers, so the same mapping is registered there.
*/
esbuildOptions: {
plugins: [
{
name: ESBUILD_PLUGIN_NAME,
setup(build) {
build.onResolve(
{ filter: REACT_NATIVE_SPECIFIER },
async (args) => {
if (isFromThisModule(args.importer)) {
return undefined;
}

const resolved = await build.resolve(COMPONENTS, {
kind: args.kind,
resolveDir: args.resolveDir,
});

return resolved.errors.length > 0 ? undefined : resolved;
},
);
},
},
],
},
},
};
},

async resolveId(source, importer, options) {
if (!REACT_NATIVE_SPECIFIER.test(source)) {
return null;
}

if (isFromThisModule(importer)) {
return null;
}

return this.resolve(COMPONENTS, importer, { ...options, skipSelf: true });
},
};
}

export default reactNativeCSS;
Loading