diff --git a/example/babel.config.js b/example/babel.config.js
index 3d64221..f92d429 100644
--- a/example/babel.config.js
+++ b/example/babel.config.js
@@ -9,8 +9,8 @@ module.exports = function (api) {
return getConfig(
{
- presets: ["babel-preset-expo", `module:${root}/dist/commonjs/babel`],
- // presets: ["babel-preset-expo"],
+ // presets: ["babel-preset-expo", `module:${root}/dist/commonjs/babel`],
+ presets: ["babel-preset-expo"],
},
{ root, pkg },
);
diff --git a/example/global.css b/example/global.css
index 03c53de..9cbd4d6 100644
--- a/example/global.css
+++ b/example/global.css
@@ -5,5 +5,5 @@
}
.text-red-500 {
- color: red;
+ color: red
}
\ No newline at end of file
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 5a7c89b..509aa4b 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -3,9 +3,10 @@ import { Text, View } from "react-native";
import "../global.css";
export default function App() {
+ console.log("App component rendered");
return (
- Hello World
+ Hello World
);
}
diff --git a/src/__tests__/babel/react-native-web.test.ts b/src/__tests__/babel/react-native-web.test.ts
index 612d529..5d54a4d 100644
--- a/src/__tests__/babel/react-native-web.test.ts
+++ b/src/__tests__/babel/react-native-web.test.ts
@@ -14,27 +14,81 @@ describe("react-native-web", () => {
plugins: ["@babel/plugin-syntax-jsx"],
},
tests: appendTitles([
+ /* import tests */
{
code: `import 'react-native-web';`,
output: `import "react-native-css/components";`,
},
+ {
+ code: `import ReactNativeWeb from 'react-native-web';`,
+ output: `import ReactNativeWeb from "react-native-css/components";`,
+ },
{
code: `import { View } from 'react-native-web';`,
output: `import { View } from "react-native-css/components/View";`,
},
{
- code: `import View from 'react-native-web/dist/commonjs/exports/View';`,
+ code: `import View from 'react-native-web/dist/cjs/View';`,
output: `import { View } from "react-native-css/components/View";`,
},
{
- code: `import View from 'react-native-web/dist/module/exports/View';`,
+ code: `import View from 'react-native-web/dist/modules/View';`,
output: `import { View } from "react-native-css/components/View";`,
},
{
code: `import View from '../View';`,
output: `import { View } from "react-native-css/components/View";`,
babelOptions: {
- filename: "react-native-web/dist/module/exports/ScrollView/index.js",
+ filename: "react-native-web/dist/modules/ScrollView/index.js",
+ },
+ },
+
+ /* require() tests */
+ {
+ code: `const { Text } = require('react-native-web');`,
+ output: `const { Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const Text = require('react-native-web/dist/modules/Text');`,
+ output: `const { Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const _Text = require('react-native-web/dist/modules/Text');`,
+ output: `const { Text: _Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const Text = require('react-native-web/dist/cjs/Text');`,
+ output: `const { Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const _Text = require('react-native-web/dist/cjs/Text');`,
+ output: `const { Text: _Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const Text = require('react-native-web/dist/exports/Text');`,
+ output: `const { Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const _Text = require('react-native-web/dist/exports/Text');`,
+ output: `const { Text: _Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const _Text = _interopRequireDefault(require('react-native-web/dist/Text'));`,
+ output: `const { Text: _Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const _Text = _interopRequireDefault(require('react-native-web/dist/modules/Text'));`,
+ output: `const { Text: _Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const _Text = _interopRequireDefault(require('react-native-web/dist/cjs/Text'));`,
+ output: `const { Text: _Text } = require("react-native-css/components/Text");`,
+ },
+ {
+ code: `const View = _interopRequireDefault(require('../View'));`,
+ output: `const { View } = require("react-native-css/components/View");`,
+ babelOptions: {
+ filename: "react-native-web/dist/modules/ScrollView/index.js",
},
},
]),
diff --git a/src/__tests__/babel/smoke.test.ts b/src/__tests__/babel/smoke.test.ts
index 2913491..ad5c202 100644
--- a/src/__tests__/babel/smoke.test.ts
+++ b/src/__tests__/babel/smoke.test.ts
@@ -15,28 +15,10 @@ describe("plugin smoke tests", () => {
filename: "/someFile.js",
},
tests: appendTitles([
- {
- code: `import 'react-native';`,
- output: `import "react-native-css/components";`,
- },
{
code: `import '../global.css';`,
output: `import "../global.css";`,
},
- {
- code: `import { View } from 'react-native';`,
- output: `import { View } from "react-native";`,
- babelOptions: {
- filename: "/node_modules/react-native-css/components/View.js",
- },
- },
- {
- code: `import { View } from 'react-native';`,
- output: `import { View } from "react-native";`,
- babelOptions: {
- filename: "react-native-css/src/components/View.js",
- },
- },
{
code: `import * as NativeComponentRegistry from '../../NativeComponent/NativeComponentRegistry'`,
output: `import * as NativeComponentRegistry from "../../NativeComponent/NativeComponentRegistry";`,
diff --git a/src/babel/allowedModules.ts b/src/babel/allowedModules.ts
new file mode 100644
index 0000000..212c2c7
--- /dev/null
+++ b/src/babel/allowedModules.ts
@@ -0,0 +1,24 @@
+import { readdirSync } from "fs";
+import { join, parse } from "path";
+
+function getFilesWithoutExtension(dirPath: string) {
+ // Read all files and directories inside dirPath synchronously
+ const entries = readdirSync(dirPath, { withFileTypes: true });
+
+ // Filter only files (ignore directories)
+ const files = entries.filter(
+ (entry) =>
+ entry.isFile() &&
+ /\.[jt]sx?$/.exec(entry.name) &&
+ !/index\.[jt]sx?$/.exec(entry.name),
+ );
+
+ // For each file, get the filename without extension
+ const filesWithoutExt = files.map((file) => parse(file.name).name);
+
+ return filesWithoutExt;
+}
+
+export const allowedModules = new Set(
+ getFilesWithoutExtension(join(__dirname, "../components")),
+);
diff --git a/src/babel/helpers.ts b/src/babel/helpers.ts
index e9ea674..c66f630 100644
--- a/src/babel/helpers.ts
+++ b/src/babel/helpers.ts
@@ -1,11 +1,6 @@
-/* eslint-disable @typescript-eslint/no-non-null-assertion */
-import { readdirSync } from "fs";
-import { dirname, join, normalize, parse, resolve, sep } from "path";
-import { type NodePath } from "@babel/traverse";
+import tBabelTypes, { type CallExpression } from "@babel/types";
-import t from "@babel/types";
-
-export type BabelTypes = typeof t;
+export type BabelTypes = typeof tBabelTypes;
export interface PluginOpts {
target?: string;
@@ -18,165 +13,28 @@ export interface PluginState {
filename: string;
}
-export const allowedModules = new Set(
- getFilesWithoutExtension(join(__dirname, "../components")),
-);
-
-function getFilesWithoutExtension(dirPath: string) {
- // Read all files and directories inside dirPath synchronously
- const entries = readdirSync(dirPath, { withFileTypes: true });
-
- // Filter only files (ignore directories)
- const files = entries.filter(
- (entry) =>
- entry.isFile() &&
- /\.[jt]sx?$/.exec(entry.name) &&
- !/index\.[jt]sx?$/.exec(entry.name),
- );
-
- // For each file, get the filename without extension
- const filesWithoutExt = files.map((file) => parse(file.name).name);
-
- return filesWithoutExt;
-}
-
-export function isInsideModule(filename: string, module: string): boolean {
- const normalized = normalize(filename);
-
- // Match exact module name
- if (normalized === module) {
- return true;
- }
-
- // Absolute posix paths
- if (normalized.startsWith(`${module}/`)) {
- return true;
+export function getInteropRequireDefaultSource(
+ init: CallExpression,
+ t: BabelTypes,
+) {
+ if (!t.isIdentifier(init.callee, { name: "_interopRequireDefault" })) {
+ return;
}
- // Check for our local development structure
- if (normalized.includes(`${sep}${module}${sep}src${sep}`)) {
- // Ignore the test files
- return !normalized.includes("__tests__");
- }
+ const interopArg = init.arguments.at(0);
- // Match classic node_modules
- if (normalized.includes(`${sep}node_modules${sep}${module}${sep}`)) {
- return true;
- }
-
- // Match Yarn PnP .zip-style paths (e.g., .zip/node_modules/${module}/)
if (
- normalized.includes(`${sep}.zip${sep}node_modules${sep}${module}${sep}`)
+ !t.isCallExpression(interopArg) ||
+ !t.isIdentifier(interopArg.callee, { name: "require" })
) {
- return true;
- }
-
- // Match Yarn .yarn/cache/${module}/*
- if (normalized.includes(`${sep}.yarn${sep}cache${sep}${module}${sep}`)) {
- return true;
+ return;
}
- return false;
-}
-
-export function isPackageImport(
- path: NodePath,
-) {
- const source = path.node.source?.value;
- if (!source) {
- return false;
- }
-
- return source === "react-native" || source === "react-native-web";
-}
-
-export function shouldTransformImport(
- path: NodePath,
- filename: string,
-) {
- let source = path.node.source?.value;
-
- if (!source) {
- return false;
- }
-
- if (source.startsWith(".")) {
- source = resolve(dirname(filename), source);
- }
-
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- return isReactNativeSource(source) || isReactNativeWebSource(source);
-}
-
-function isReactNativeSource(source: string) {
- if (source === "react-native") return true;
-
- const components = source.split(
- `react-native${sep}Libraries${sep}Components${sep}`,
- );
-
- if (components.length > 1) {
- const component = components[1]?.split(sep)[0];
- return component && allowedModules.has(component);
- }
-
- return false;
-}
-
-function isReactNativeWebSource(source: string) {
- if (source === "react-native-web") return true;
-
- let components = source.split(
- `react-native-web${sep}dist${sep}commonjs${sep}exports${sep}`,
- );
- if (components.length > 1) {
- const component = components[1]?.split(sep)[0];
- return component && allowedModules.has(component);
- }
-
- components = source.split(
- `react-native-web${sep}dist${sep}module${sep}exports${sep}`,
- );
- if (components.length > 1) {
- const component = components[1]?.split(sep)[0];
- return component && allowedModules.has(component);
- }
-
- return false;
-}
-
-export function shouldTransformRequire(
- t: BabelTypes,
- node: t.VariableDeclaration,
- basePath: string,
-) {
- const { declarations } = node;
-
- const declaration = declarations[0];
-
- if (declarations.length > 1 || !declaration) {
- return false;
- }
- const { id, init } = declaration;
-
- let source =
- (t.isObjectPattern(id) || t.isIdentifier(id)) &&
- t.isCallExpression(init) &&
- t.isIdentifier(init.callee) &&
- init.callee.name === "require" &&
- init.arguments.length === 1 &&
- "value" in init.arguments[0]! &&
- typeof init.arguments[0].value === "string" &&
- init.arguments[0].value;
-
- if (!source) {
- return false;
- }
+ const requireArg = interopArg.arguments.at(0);
- if (source.startsWith(".")) {
- source = resolve(basePath, source);
+ if (!t.isStringLiteral(requireArg)) {
+ return;
}
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- return isReactNativeSource(source) || isReactNativeWebSource(source);
+ return requireArg.value;
}
diff --git a/src/babel/import-plugin.ts b/src/babel/import-plugin.ts
index f73aebe..9e70df3 100644
--- a/src/babel/import-plugin.ts
+++ b/src/babel/import-plugin.ts
@@ -1,14 +1,22 @@
import { type PluginObj } from "@babel/core";
+import { resolve } from "path";
import {
type BabelTypes,
type PluginState,
- allowedModules,
- shouldTransformImport,
- isInsideModule,
- isPackageImport,
- shouldTransformRequire,
+ getInteropRequireDefaultSource,
} from "./helpers";
+import {
+ handleReactNativeWebIdentifierRequire,
+ handleReactNativeWebImport,
+ handleReactNativeWebObjectPatternRequire,
+} from "./react-native-web";
+import type { Statement } from "@babel/types";
+import {
+ handleReactNativeIdentifierRequire,
+ handleReactNativeImport,
+ handleReactNativeObjectPatternRequire,
+} from "./react-native";
export default function ({
types: t,
@@ -17,166 +25,150 @@ export default function ({
}): PluginObj {
const processed = new WeakSet();
- function importDeclaration(...args: Parameters) {
- const declaration = t.importDeclaration(...args);
- processed.add(declaration);
- return declaration;
- }
+ const thisModuleDist = resolve(__dirname, "../../../dist");
+ const thisModuleSrc = resolve(__dirname, "../../../src");
- function requireDeclaration(
- ...args: Parameters
- ) {
- const declaration = t.variableDeclaration(...args);
- processed.add(declaration);
- return declaration;
+ function isFromThisModule(filename: string): boolean {
+ return (
+ filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc)
+ );
}
return {
name: "Rewrite react-native to react-native-css",
visitor: {
ImportDeclaration(path, state): void {
- if (processed.has(path) || processed.has(path.node)) {
+ if (
+ processed.has(path) ||
+ processed.has(path.node) ||
+ isFromThisModule(state.filename)
+ ) {
return;
}
- const { specifiers, source } = path.node;
+ const statements =
+ handleReactNativeImport(path.node, t, state.filename) ??
+ handleReactNativeWebImport(path.node, t, state.filename);
+
+ if (!statements) {
+ return;
+ }
+
+ for (const statement of statements) {
+ processed.add(statement);
+ }
+ path.replaceWithMultiple(statements);
+ },
+ VariableDeclaration(path, state): void {
if (
- isInsideModule(state.filename, "react-native-css") ||
- !shouldTransformImport(path, state.filename)
+ processed.has(path) ||
+ processed.has(path.node) ||
+ isFromThisModule(state.filename)
) {
return;
}
- if (specifiers.length === 0) {
- path.replaceWith(
- importDeclaration(
- [],
- t.stringLiteral("react-native-css/components"),
- ),
- );
- path.scope.registerDeclaration(path);
+ const firstDeclaration = path.node.declarations.at(0);
+
+ // We only handle single variable declarations for now.
+ if (path.node.declarations.length > 1) {
return;
}
- const imports = specifiers.map((specifier) => {
- if (t.isImportDefaultSpecifier(specifier)) {
- const name = specifier.local.name;
-
- if (isPackageImport(path)) {
- return importDeclaration(
- [t.importDefaultSpecifier(specifier.local)],
- t.stringLiteral("react-native-css/components"),
- );
- } else if (allowedModules.has(name)) {
- return importDeclaration(
- [t.importSpecifier(specifier.local, specifier.local)],
- t.stringLiteral(`react-native-css/components/${name}`),
- );
- } else {
- return importDeclaration([specifier], source);
- }
- } else if (t.isImportNamespaceSpecifier(specifier)) {
- return importDeclaration(
- [specifier],
- t.stringLiteral("react-native-css/components"),
- );
- } else {
- const localName = t.isStringLiteral(specifier.imported)
- ? specifier.imported.value
- : specifier.imported.name;
-
- return allowedModules.has(localName)
- ? importDeclaration(
- [t.importSpecifier(specifier.local, specifier.imported)],
- t.stringLiteral(`react-native-css/components/${localName}`),
- )
- : importDeclaration([specifier], source);
- }
- });
+ // Skip declarations that are not initialized. eg `let x;`
+ if (!firstDeclaration?.init) {
+ return;
+ }
- path.replaceInline(imports).map((importPath) => {
- path.scope.registerDeclaration(importPath);
- });
- },
- VariableDeclaration(path, state): void {
- if (processed.has(path) || processed.has(path.node)) {
+ const { id, init } = firstDeclaration;
+
+ // We only handle `const = ()`
+ if (!t.isCallExpression(init)) {
return;
}
- if (
- isInsideModule(state.filename, "react-native-css") ||
- !shouldTransformRequire(t, path.node, state.filename)
- ) {
+ // We only handle `const id = () OR const { } = ()`
+ if (!(t.isIdentifier(id) || t.isObjectPattern(id))) {
return;
}
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const { id, init } = path.node.declarations[0]!;
-
- if (!t.isObjectPattern(id) || !init) {
- path.replaceWith(
- requireDeclaration(path.node.kind, [
- t.variableDeclarator(
- id,
- t.callExpression(t.identifier("require"), [
- t.stringLiteral(`react-native-css/components`),
- ]),
- ),
- ]),
- );
+ const initArg = init.arguments.at(0);
+
+ if (!initArg) {
return;
}
- const imports = id.properties.map((identifier) => {
- if (t.isRestElement(identifier)) {
- return requireDeclaration(path.node.kind, [
- t.variableDeclarator(identifier, init),
- ]);
- }
+ let statements: Statement[] | undefined;
- if (!t.isIdentifier(identifier.key)) {
- return requireDeclaration(path.node.kind, [
- t.variableDeclarator(t.objectPattern([identifier]), init),
- ]);
+ // `const = require();`
+ if (
+ t.isIdentifier(id) &&
+ t.isIdentifier(init.callee, { name: "require" }) &&
+ t.isStringLiteral(initArg)
+ ) {
+ statements =
+ handleReactNativeIdentifierRequire(
+ path,
+ t,
+ id.name,
+ initArg.value,
+ state.filename,
+ ) ??
+ handleReactNativeWebIdentifierRequire(
+ path,
+ t,
+ id.name,
+ initArg.value,
+ state.filename,
+ );
+ } else if (
+ t.isObjectPattern(id) &&
+ t.isIdentifier(init.callee, { name: "require" }) &&
+ t.isStringLiteral(initArg)
+ ) {
+ statements =
+ handleReactNativeObjectPatternRequire(
+ path,
+ t,
+ id,
+ initArg.value,
+ state.filename,
+ ) ??
+ handleReactNativeWebObjectPatternRequire(
+ path,
+ t,
+ id,
+ initArg.value,
+ state.filename,
+ );
+ } else if (
+ t.isIdentifier(id) &&
+ t.isIdentifier(init.callee, { name: "_interopRequireDefault" })
+ ) {
+ // `const = _interopRequireDefault(require());`
+ const source = getInteropRequireDefaultSource(init, t);
+ if (!source) {
+ return;
}
+ statements = handleReactNativeWebIdentifierRequire(
+ path,
+ t,
+ id.name,
+ source,
+ state.filename,
+ );
+ }
- const name = identifier.key.name;
-
- if (!allowedModules.has(name)) {
- return requireDeclaration(path.node.kind, [
- t.variableDeclarator(
- t.objectPattern([
- t.objectProperty(
- t.identifier(name),
- t.identifier(name),
- false,
- true,
- ),
- ]),
- init,
- ),
- ]);
- }
+ if (!statements) {
+ return;
+ }
+
+ for (const statement of statements) {
+ processed.add(statement);
+ }
- return requireDeclaration(path.node.kind, [
- t.variableDeclarator(
- t.objectPattern([
- t.objectProperty(
- t.identifier(name),
- t.identifier(name),
- false,
- true,
- ),
- ]),
- t.callExpression(t.identifier("require"), [
- t.stringLiteral(`react-native-css/components/${name}`),
- ]),
- ),
- ]);
- });
-
- path.replaceWithMultiple(imports);
+ path.replaceWithMultiple(statements);
},
},
};
diff --git a/src/babel/react-native-web.ts b/src/babel/react-native-web.ts
new file mode 100644
index 0000000..0029d83
--- /dev/null
+++ b/src/babel/react-native-web.ts
@@ -0,0 +1,225 @@
+import { type NodePath } from "@babel/traverse";
+
+import tBabelTypes, {
+ type ImportDeclaration,
+ type ObjectPattern,
+ type Statement,
+ type VariableDeclaration,
+} from "@babel/types";
+import { allowedModules } from "./allowedModules";
+import { resolve } from "path";
+
+type BabelTypes = typeof tBabelTypes;
+
+function parseReactNativeWebSource(source: string, filename: string) {
+ if (source.startsWith(".")) {
+ source = resolve(filename, source);
+
+ const internalPath = source.split("react-native-web/dist")[1];
+ if (!internalPath) {
+ return;
+ }
+
+ // Strip the absolute system filepath
+ source = `react-native-web/${internalPath}`;
+ }
+
+ if (source === "react-native-web") {
+ return { source: "react-native-css/components" };
+ } else if (source.startsWith("react-native-web/")) {
+ const name = source.split("/").at(-1);
+ if (!name || !allowedModules.has(name)) {
+ return;
+ }
+
+ return {
+ source: `react-native-css/components/${name}`,
+ name,
+ };
+ }
+
+ return;
+}
+
+export function handleReactNativeWebImport(
+ declaration: ImportDeclaration,
+ t: BabelTypes,
+ filename: string,
+): Statement[] | undefined {
+ const { specifiers, source } = declaration;
+
+ const rnwSource = parseReactNativeWebSource(source.value, filename);
+ if (!rnwSource) {
+ return;
+ }
+
+ if (specifiers.length === 0) {
+ return [
+ t.importDeclaration([], t.stringLiteral("react-native-css/components")),
+ ];
+ }
+
+ const statements: Statement[] = [];
+
+ for (const specifier of specifiers) {
+ if (t.isImportDefaultSpecifier(specifier)) {
+ const { source: newSource, name } = rnwSource;
+
+ if (!name) {
+ statements.push(
+ t.importDeclaration(
+ [t.importDefaultSpecifier(specifier.local)],
+ t.stringLiteral(newSource),
+ ),
+ );
+ } else {
+ statements.push(
+ t.importDeclaration(
+ [t.importSpecifier(specifier.local, specifier.local)],
+ t.stringLiteral(`react-native-css/components/${name}`),
+ ),
+ );
+ }
+ } else if (t.isImportNamespaceSpecifier(specifier)) {
+ statements.push(
+ t.importDeclaration(
+ [specifier],
+ t.stringLiteral("react-native-css/components"),
+ ),
+ );
+ } else {
+ const localName = t.isStringLiteral(specifier.imported)
+ ? specifier.imported.value
+ : specifier.imported.name;
+
+ if (!allowedModules.has(localName)) {
+ statements.push(t.importDeclaration([specifier], source));
+ } else {
+ statements.push(
+ t.importDeclaration(
+ [t.importSpecifier(specifier.local, specifier.imported)],
+ t.stringLiteral(`react-native-css/components/${localName}`),
+ ),
+ );
+ }
+ }
+ }
+
+ return statements;
+}
+
+export function handleReactNativeWebIdentifierRequire(
+ path: NodePath,
+ t: BabelTypes,
+ id: string,
+ source: string,
+ filename: string,
+) {
+ const parsedSource = parseReactNativeWebSource(source, filename);
+
+ if (!parsedSource) {
+ return;
+ }
+
+ const { source: newSource, name } = parsedSource;
+
+ if (name) {
+ return [
+ t.variableDeclaration(path.node.kind, [
+ t.variableDeclarator(
+ t.objectPattern([
+ name === id
+ ? t.objectProperty(
+ t.identifier(name),
+ t.identifier(id),
+ false,
+ true,
+ )
+ : t.objectProperty(t.identifier(name), t.identifier(id)),
+ ]),
+ t.callExpression(t.identifier("require"), [
+ t.stringLiteral(newSource),
+ ]),
+ ),
+ ]),
+ ];
+ } else {
+ return [
+ t.variableDeclaration(path.node.kind, [
+ t.variableDeclarator(
+ t.identifier(id),
+ t.callExpression(t.identifier("require"), [
+ t.stringLiteral(newSource),
+ ]),
+ ),
+ ]),
+ ];
+ }
+}
+
+export function handleReactNativeWebObjectPatternRequire(
+ path: NodePath,
+ t: BabelTypes,
+ id: ObjectPattern,
+ source: string,
+ filename: string,
+) {
+ const parsedSource = parseReactNativeWebSource(source, filename);
+
+ // Don't handle `const { Text } = require('react-native-web/dist/cjs/Text');` - we only handle package exports
+ if (!parsedSource || parsedSource.name) {
+ return;
+ }
+
+ const statements: Statement[] = [];
+
+ for (const identifier of id.properties) {
+ if (t.isRestElement(identifier)) {
+ // Bail out on `const { ...rest } = require('react-native-web');`
+ // We need to exit as we do not handle `const { Text, ...rest } = require('react-native-web');`
+ return;
+ } else if (
+ !(t.isIdentifier(identifier.value) && t.isIdentifier(identifier.key))
+ ) {
+ // Bail out on anything that isn't `const { : } = require('react-native-web');`
+ return;
+ } else {
+ const name = identifier.key.name;
+
+ if (!allowedModules.has(name)) {
+ statements.push(
+ t.variableDeclaration(path.node.kind, [
+ t.variableDeclarator(
+ t.objectPattern([identifier]),
+ t.callExpression(t.identifier("require"), [
+ t.stringLiteral(source),
+ ]),
+ ),
+ ]),
+ );
+ } else {
+ const newSourceWithName = `react-native-css/components/${name}`;
+
+ statements.push(
+ t.variableDeclaration(path.node.kind, [
+ t.variableDeclarator(
+ t.objectPattern([
+ t.objectProperty(
+ t.identifier(name),
+ t.identifier(identifier.value.name),
+ false,
+ true,
+ ),
+ ]),
+ t.callExpression(t.identifier("require"), [
+ t.stringLiteral(newSourceWithName),
+ ]),
+ ),
+ ]),
+ );
+ }
+ }
+ }
+
+ return statements;
+}
diff --git a/src/babel/react-native.ts b/src/babel/react-native.ts
new file mode 100644
index 0000000..8313935
--- /dev/null
+++ b/src/babel/react-native.ts
@@ -0,0 +1,225 @@
+import { type NodePath } from "@babel/traverse";
+
+import tBabelTypes, {
+ type ImportDeclaration,
+ type ObjectPattern,
+ type Statement,
+ type VariableDeclaration,
+} from "@babel/types";
+import { allowedModules } from "./allowedModules";
+import { dirname, resolve } from "path";
+
+type BabelTypes = typeof tBabelTypes;
+
+function parseReactNativeSource(source: string, filename: string) {
+ if (source.startsWith(".")) {
+ source = resolve(dirname(filename), source);
+
+ const internalPath = source.split("react-native/Libraries/Components/")[1];
+ if (!internalPath) {
+ return;
+ }
+
+ // Strip the absolute system filepath
+ source = `react-native/Libraries/Components/${internalPath}`;
+ }
+
+ if (source === "react-native") {
+ return { source: "react-native-css/components" };
+ } else if (source.startsWith("react-native/")) {
+ const name = source.split("/").at(-1);
+ if (!name || !allowedModules.has(name)) {
+ return;
+ }
+
+ return {
+ source: `react-native-css/components/${name}`,
+ name,
+ };
+ }
+
+ return;
+}
+
+export function handleReactNativeImport(
+ declaration: ImportDeclaration,
+ t: BabelTypes,
+ filename: string,
+): Statement[] | undefined {
+ const { specifiers, source } = declaration;
+
+ const rnwSource = parseReactNativeSource(source.value, filename);
+ if (!rnwSource) {
+ return;
+ }
+
+ if (specifiers.length === 0) {
+ return [
+ t.importDeclaration([], t.stringLiteral("react-native-css/components")),
+ ];
+ }
+
+ const statements: Statement[] = [];
+
+ for (const specifier of specifiers) {
+ if (t.isImportDefaultSpecifier(specifier)) {
+ const { source: newSource, name } = rnwSource;
+
+ if (!name) {
+ statements.push(
+ t.importDeclaration(
+ [t.importDefaultSpecifier(specifier.local)],
+ t.stringLiteral(newSource),
+ ),
+ );
+ } else {
+ statements.push(
+ t.importDeclaration(
+ [t.importSpecifier(specifier.local, specifier.local)],
+ t.stringLiteral(`react-native-css/components/${name}`),
+ ),
+ );
+ }
+ } else if (t.isImportNamespaceSpecifier(specifier)) {
+ statements.push(
+ t.importDeclaration(
+ [specifier],
+ t.stringLiteral("react-native-css/components"),
+ ),
+ );
+ } else {
+ const localName = t.isStringLiteral(specifier.imported)
+ ? specifier.imported.value
+ : specifier.imported.name;
+
+ if (!allowedModules.has(localName)) {
+ statements.push(t.importDeclaration([specifier], source));
+ } else {
+ statements.push(
+ t.importDeclaration(
+ [t.importSpecifier(specifier.local, specifier.imported)],
+ t.stringLiteral(`react-native-css/components/${localName}`),
+ ),
+ );
+ }
+ }
+ }
+
+ return statements;
+}
+
+export function handleReactNativeIdentifierRequire(
+ path: NodePath,
+ t: BabelTypes,
+ id: string,
+ source: string,
+ filename: string,
+) {
+ const parsedSource = parseReactNativeSource(source, filename);
+
+ if (!parsedSource) {
+ return;
+ }
+
+ const { source: newSource, name } = parsedSource;
+
+ if (name) {
+ return [
+ t.variableDeclaration(path.node.kind, [
+ t.variableDeclarator(
+ t.objectPattern([
+ name === id
+ ? t.objectProperty(
+ t.identifier(name),
+ t.identifier(id),
+ false,
+ true,
+ )
+ : t.objectProperty(t.identifier(name), t.identifier(id)),
+ ]),
+ t.callExpression(t.identifier("require"), [
+ t.stringLiteral(newSource),
+ ]),
+ ),
+ ]),
+ ];
+ } else {
+ return [
+ t.variableDeclaration(path.node.kind, [
+ t.variableDeclarator(
+ t.identifier(id),
+ t.callExpression(t.identifier("require"), [
+ t.stringLiteral(newSource),
+ ]),
+ ),
+ ]),
+ ];
+ }
+}
+
+export function handleReactNativeObjectPatternRequire(
+ path: NodePath,
+ t: BabelTypes,
+ id: ObjectPattern,
+ source: string,
+ filename: string,
+) {
+ const parsedSource = parseReactNativeSource(source, filename);
+
+ // Don't handle `const { Text } = require('react-native/Libraries/Text/Text');` - we only handle package exports
+ if (!parsedSource || parsedSource.name) {
+ return;
+ }
+
+ const statements: Statement[] = [];
+
+ for (const identifier of id.properties) {
+ if (t.isRestElement(identifier)) {
+ // Bail out on `const { ...rest } = require('react-native');`
+ // We need to exit as we do not handle `const { Text, ...rest } = require('react-native');`
+ return;
+ } else if (
+ !(t.isIdentifier(identifier.value) && t.isIdentifier(identifier.key))
+ ) {
+ // Bail out on anything that isn't `const { : } = require('react-native');`
+ return;
+ } else {
+ const name = identifier.key.name;
+
+ if (!allowedModules.has(name)) {
+ statements.push(
+ t.variableDeclaration(path.node.kind, [
+ t.variableDeclarator(
+ t.objectPattern([identifier]),
+ t.callExpression(t.identifier("require"), [
+ t.stringLiteral(source),
+ ]),
+ ),
+ ]),
+ );
+ } else {
+ const newSourceWithName = `react-native-css/components/${name}`;
+
+ statements.push(
+ t.variableDeclaration(path.node.kind, [
+ t.variableDeclarator(
+ t.objectPattern([
+ t.objectProperty(
+ t.identifier(name),
+ t.identifier(identifier.value.name),
+ false,
+ true,
+ ),
+ ]),
+ t.callExpression(t.identifier("require"), [
+ t.stringLiteral(newSourceWithName),
+ ]),
+ ),
+ ]),
+ );
+ }
+ }
+ }
+
+ return statements;
+}
diff --git a/src/components/ActivityIndicator.tsx b/src/components/ActivityIndicator.tsx
deleted file mode 100644
index ca39bd4..0000000
--- a/src/components/ActivityIndicator.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import {
- ActivityIndicator as RNActivityIndicator,
- type ActivityIndicatorProps,
-} from "react-native";
-
-import { useCssElement, type StyledConfiguration } from "../runtime";
-
-const mapping: StyledConfiguration = {
- className: {
- target: "style",
- nativeStyleMapping: {
- color: "color",
- },
- },
-};
-
-export function ActivityIndicator(props: ActivityIndicatorProps) {
- return useCssElement(RNActivityIndicator, props, mapping);
-}
diff --git a/src/components/SafeAreaProvider/index.tsx b/src/components/SafeAreaProvider/index.tsx
deleted file mode 100644
index 5b398f8..0000000
--- a/src/components/SafeAreaProvider/index.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-/* eslint-disable */
-import {
- useContext,
- useMemo,
- type ComponentProps,
- type PropsWithChildren,
-} from "react";
-
-import {
- SafeAreaProvider as RNSafeAreaProvider,
- useSafeAreaInsets,
-} from "react-native-safe-area-context";
-
-import {
- VariableContext,
- type VariableContextValue,
-} from "../../runtime/native/reactivity";
-
-export function SafeAreaProvider({
- children,
- ...props
-}: ComponentProps) {
- return (
-
- {children}
-
- );
-}
-
-function SafeAreaProviderEnv({ children }: PropsWithChildren) {
- const insets = useSafeAreaInsets();
- const parentVarContext = useContext(VariableContext);
-
- const value = useMemo(
- () => ({
- ...parentVarContext,
- "--react-native-css-safe-area-inset-bottom": insets.bottom,
- "--react-native-css-safe-area-inset-left": insets.left,
- "--react-native-css-safe-area-inset-right": insets.right,
- "--react-native-css-safe-area-inset-top": insets.top,
- }),
- [parentVarContext, insets],
- );
-
- return (
-
- {children}
-
- );
-}
diff --git a/src/components/SafeAreaProvider/index.web.tsx b/src/components/SafeAreaProvider/index.web.tsx
deleted file mode 100644
index f7419df..0000000
--- a/src/components/SafeAreaProvider/index.web.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { SafeAreaProvider } from "react-native-safe-area-context";
diff --git a/src/components/Text.tsx b/src/components/Text.tsx
index 3496b31..1a672d8 100644
--- a/src/components/Text.tsx
+++ b/src/components/Text.tsx
@@ -13,3 +13,5 @@ const mapping = {
export function Text(props: StyledProps) {
return useCssElement(RNText, props, mapping);
}
+
+export default Text;
diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx
deleted file mode 100644
index 54afe9f..0000000
--- a/src/components/TextInput.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { TextInput as RNTextInput, type TextInputProps } from "react-native";
-
-import {
- useCssElement,
- type StyledConfiguration,
- type StyledProps,
-} from "../runtime";
-
-const mapping = {
- className: {
- target: "style",
- nativeStyleMapping: {
- selectionColor: "selectionColor",
- },
- },
-} satisfies StyledConfiguration;
-
-export function TextInput(props: StyledProps) {
- return useCssElement(RNTextInput, props, mapping);
-}
diff --git a/src/components/View.tsx b/src/components/View.tsx
index bacfc50..e11718a 100644
--- a/src/components/View.tsx
+++ b/src/components/View.tsx
@@ -13,3 +13,5 @@ const mapping = {
export function View(props: StyledProps) {
return useCssElement(RNView, props, mapping);
}
+
+export default View;
diff --git a/src/index.ts b/src/index.ts
index e3ce6fd..e559619 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1 +1,3 @@
export * from "./runtime";
+
+export * from "./poison.pill";
diff --git a/src/metro/index.ts b/src/metro/index.ts
index ddd0989..63b7de1 100644
--- a/src/metro/index.ts
+++ b/src/metro/index.ts
@@ -8,13 +8,15 @@ import type { MetroConfig } from "metro-config";
import { compile } from "../compiler/compiler";
import { setupTypeScript } from "./typescript";
import { getInjectionCode } from "./injection-code";
+import { nativeResolver, webResolver } from "./resolver";
export interface WithReactNativeCSSOptions {
- browserslist?: string | null;
- browserslistEnv?: string | null;
- libName?: string;
+ /* Specify the path to the TypeScript environment file. Defaults types-env.d.ts */
typescriptEnvPath?: string;
+ /* Disable generation of the types-env.d.ts file. Defaults false */
disableTypeScriptGeneration?: boolean;
+ /** Add className to all React Native primitives. Defaults false */
+ globalClassNamePolyfill?: boolean;
}
export function withReactNativeCSS<
@@ -26,23 +28,53 @@ export function withReactNativeCSS<
}) as T;
}
- if (Number(versions.node.split(".")[0]) < 18) {
- throw new Error("react-native-css only supports NodeJS >18");
+ if (Number(versions.node.split(".")[0]) < 20) {
+ throw new Error("react-native-css only supports NodeJS >20");
}
- if (options?.disableTypeScriptGeneration !== true) {
- setupTypeScript(options?.typescriptEnvPath, options?.libName);
+ const {
+ disableTypeScriptGeneration,
+ typescriptEnvPath,
+ globalClassNamePolyfill = false,
+ } = options || {};
+
+ if (disableTypeScriptGeneration !== true) {
+ setupTypeScript(typescriptEnvPath);
}
const originalMiddleware = config.server?.enhanceMiddleware;
+ const originalResolver = config.resolver?.resolveRequest;
+
+ const poisonPillPath = "./poison.pill";
return {
...config,
transformerPath: require.resolve("./metro-transformer"),
- transformer: {
- ...config.transformer,
- browserslist: options?.browserslist,
- browserslistEnv: options?.browserslistEnv,
+ resolver: {
+ ...config.resolver,
+ sourceExts: [...(config?.resolver?.sourceExts || []), "css"],
+ resolveRequest: (context, moduleName, platform) => {
+ if (moduleName === poisonPillPath) {
+ return { type: "empty" };
+ }
+
+ // Don't hijack the resolution of react-native imports
+ if (!globalClassNamePolyfill) {
+ const parentResolver = originalResolver ?? context.resolveRequest;
+ return parentResolver(context, moduleName, platform);
+ }
+
+ const parentResolver = originalResolver ?? context.resolveRequest;
+ const resolver = platform === "web" ? webResolver : nativeResolver;
+ const resolved = resolver(
+ parentResolver,
+ context,
+ moduleName,
+ platform,
+ );
+
+ return resolved;
+ },
},
server: {
...config.server,
diff --git a/src/metro/resolver.ts b/src/metro/resolver.ts
new file mode 100644
index 0000000..fc17f8a
--- /dev/null
+++ b/src/metro/resolver.ts
@@ -0,0 +1,88 @@
+import type {
+ Resolution,
+ CustomResolutionContext,
+ CustomResolver,
+} from "metro-resolver";
+import { basename, resolve, sep } from "node:path";
+import { allowedModules } from "../babel/allowedModules";
+
+const thisModuleDist = resolve(__dirname, "../../../dist");
+const thisModuleSrc = resolve(__dirname, "../../../src");
+
+function isFromThisModule(filename: string): boolean {
+ return (
+ filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc)
+ );
+}
+
+export function nativeResolver(
+ resolver: CustomResolver,
+ context: CustomResolutionContext,
+ moduleName: string,
+ platform: string | null,
+): Resolution {
+ const resolution = resolver(context, moduleName, platform);
+
+ if (
+ // Don't include our internal files
+ isFromThisModule(context.originModulePath) ||
+ // Only operate on source files
+ resolution.type !== "sourceFile" ||
+ // Skip the React Native barrel file to prevent infinite recursion
+ context.originModulePath.endsWith("react-native/index.js") ||
+ // Skip anything that isn't importing a React Native component
+ !(
+ moduleName.startsWith("react-native") ||
+ moduleName.startsWith("react-native/") ||
+ resolution.filePath.includes("react-native/Libraries/Components/View/")
+ )
+ ) {
+ return resolution;
+ }
+
+ if (moduleName === "react-native") {
+ return resolver(context, `react-native-css/components`, platform);
+ }
+
+ // We only care about `react-native/Library/Components/.js` components
+ const segments = resolution.filePath.split(sep);
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const module = basename(segments.at(-1)!).split(".")[0];
+
+ if (!module || !allowedModules.has(module)) {
+ return resolution;
+ }
+
+ return resolver(context, `react-native-css/components/${module}`, platform);
+}
+
+export function webResolver(
+ resolver: CustomResolver,
+ context: CustomResolutionContext,
+ moduleName: string,
+ platform: string | null,
+): Resolution {
+ const resolution = resolver(context, moduleName, platform);
+
+ if (
+ // Don't include our internal files
+ isFromThisModule(context.originModulePath) ||
+ // Only operate on source files
+ resolution.type !== "sourceFile" ||
+ // Skip anything that isn't importing from `react-native-web`
+ !resolution.filePath.includes(`${sep}react-native-web${sep}`)
+ ) {
+ return resolution;
+ }
+
+ // We only care about `react-native-web////index.js` components
+ const segments = resolution.filePath.split(sep);
+ const isIndex = segments.at(-1)?.startsWith("index.");
+ const module = segments.at(-2);
+
+ if (!isIndex || !module || !allowedModules.has(module)) {
+ return resolution;
+ }
+
+ return resolver(context, `react-native-css/components/${module}`, platform);
+}
diff --git a/src/poison.pill.ts b/src/poison.pill.ts
new file mode 100644
index 0000000..c5cf459
--- /dev/null
+++ b/src/poison.pill.ts
@@ -0,0 +1,32 @@
+throw new Error(`react-native-css has encountered a setup error.
+
+┌─────-─┐
+| Metro |
+└─────-─┘
+
+Either your metro.config.js is missing the 'withReactNativeCSS' wrapper OR
+the resolver.resolveRequest function of your config is being overridden, and not calling the parent resolver.
+
+When using 3rd party libraries, please use withReactNativeCSS as the innermost function when composing Metro configs.
+
+\`\`\`ts
+const config = getDefaultConfig(__dirname);
+module.exports = with3rdPartyPlugin(
+ withReactNativeCSS(config)
+)
+\`\`\`
+
+┌─────------─┐
+| NativeWind |
+└─────------─┘
+
+If you are using NativeWind with the 'withNativeWind' function, follow the Metro instructions above, but use 'withNativeWind' instead of 'withReactNativeCSS'.
+
+┌─────----------─┐
+| Other bundlers |
+└─────----------─┘
+
+If you are using another bundler (Vite, Webpack, etc), or non-Metro framework (Next.js, Remix, etc), please ensure you have included 'react-native-css/babel' as a babel preset.
+`);
+
+export {};
diff --git a/src/runtime/native/__tests__/env.test.ios.tsx b/src/runtime/native/__tests__/env.test.ios.tsx
index 6d0a7a3..8c090b8 100644
--- a/src/runtime/native/__tests__/env.test.ios.tsx
+++ b/src/runtime/native/__tests__/env.test.ios.tsx
@@ -1,4 +1,4 @@
-import { SafeAreaProvider } from "react-native-css/components/SafeAreaProvider";
+// import { SafeAreaProvider } from "react-native-css/components/SafeAreaProvider";
import { View } from "react-native-css/components/View";
import { registerCSS, render, screen, testID } from "react-native-css/jest";
@@ -10,6 +10,9 @@ test("safe-area-inset-*", () => {
margin-right: env(safe-area-inset-right);
}`);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const SafeAreaProvider = View as any;
+
render(