@mgcrea/react-native-tailwind 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,6 +89,42 @@ export type PluginOptions = {
89
89
  darkSuffix?: string;
90
90
  lightSuffix?: string;
91
91
  };
92
+
93
+ /**
94
+ * Configuration for color scheme hook import (dark:/light: modifiers)
95
+ *
96
+ * Allows using custom color scheme hooks from theme providers instead of
97
+ * React Native's built-in useColorScheme.
98
+ *
99
+ * @example
100
+ * // Use custom hook from theme provider
101
+ * {
102
+ * importFrom: '@/hooks/useColorScheme',
103
+ * importName: 'useColorScheme'
104
+ * }
105
+ *
106
+ * @example
107
+ * // Use React Navigation theme
108
+ * {
109
+ * importFrom: '@react-navigation/native',
110
+ * importName: 'useTheme' // You'd wrap this to return ColorSchemeName
111
+ * }
112
+ *
113
+ * @default { importFrom: 'react-native', importName: 'useColorScheme' }
114
+ */
115
+ colorScheme?: {
116
+ /**
117
+ * Module to import the color scheme hook from
118
+ * @default 'react-native'
119
+ */
120
+ importFrom?: string;
121
+
122
+ /**
123
+ * Name of the hook to import
124
+ * @default 'useColorScheme'
125
+ */
126
+ importName?: string;
127
+ };
92
128
  };
93
129
 
94
130
  type PluginState = PluginPass & {
@@ -100,6 +136,9 @@ type PluginState = PluginPass & {
100
136
  hasColorSchemeImport: boolean;
101
137
  needsColorSchemeImport: boolean;
102
138
  colorSchemeVariableName: string;
139
+ colorSchemeImportSource: string; // Where to import the hook from (e.g., 'react-native')
140
+ colorSchemeHookName: string; // Name of the hook to import (e.g., 'useColorScheme')
141
+ colorSchemeLocalIdentifier?: string; // Local identifier if hook is already imported with an alias
103
142
  customTheme: CustomTheme;
104
143
  schemeModifierConfig: SchemeModifierConfig;
105
144
  supportedAttributes: Set<string>;
@@ -210,6 +249,10 @@ export default function reactNativeTailwindBabelPlugin(
210
249
  lightSuffix: options?.schemeModifier?.lightSuffix ?? "-light",
211
250
  };
212
251
 
252
+ // Color scheme hook configuration from plugin options
253
+ const colorSchemeImportSource = options?.colorScheme?.importFrom ?? "react-native";
254
+ const colorSchemeHookName = options?.colorScheme?.importName ?? "useColorScheme";
255
+
213
256
  return {
214
257
  name: "react-native-tailwind",
215
258
 
@@ -225,6 +268,8 @@ export default function reactNativeTailwindBabelPlugin(
225
268
  state.hasColorSchemeImport = false;
226
269
  state.needsColorSchemeImport = false;
227
270
  state.colorSchemeVariableName = "_twColorScheme";
271
+ state.colorSchemeImportSource = colorSchemeImportSource;
272
+ state.colorSchemeHookName = colorSchemeHookName;
228
273
  state.supportedAttributes = exactMatches;
229
274
  state.attributePatterns = patterns;
230
275
  state.stylesIdentifier = stylesIdentifier;
@@ -260,15 +305,21 @@ export default function reactNativeTailwindBabelPlugin(
260
305
  addPlatformImport(path, t);
261
306
  }
262
307
 
263
- // Add useColorScheme import if color scheme modifiers were used and not already present
308
+ // Add color scheme hook import if color scheme modifiers were used and not already present
264
309
  if (state.needsColorSchemeImport && !state.hasColorSchemeImport) {
265
- addColorSchemeImport(path, t);
310
+ addColorSchemeImport(path, state.colorSchemeImportSource, state.colorSchemeHookName, t);
266
311
  }
267
312
 
268
- // Inject useColorScheme hook in function components that need it
313
+ // Inject color scheme hook in function components that need it
269
314
  if (state.needsColorSchemeImport) {
270
315
  for (const functionPath of state.functionComponentsNeedingColorScheme) {
271
- injectColorSchemeHook(functionPath, state.colorSchemeVariableName, t);
316
+ injectColorSchemeHook(
317
+ functionPath,
318
+ state.colorSchemeVariableName,
319
+ state.colorSchemeHookName,
320
+ state.colorSchemeLocalIdentifier,
321
+ t,
322
+ );
272
323
  }
273
324
  }
274
325
 
@@ -300,13 +351,6 @@ export default function reactNativeTailwindBabelPlugin(
300
351
  return false;
301
352
  });
302
353
 
303
- const hasUseColorScheme = specifiers.some((spec) => {
304
- if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported)) {
305
- return spec.imported.name === "useColorScheme";
306
- }
307
- return false;
308
- });
309
-
310
354
  // Only track if imports exist - don't mutate yet
311
355
  // Actual import injection happens in Program.exit only if needed
312
356
  if (hasStyleSheet) {
@@ -317,14 +361,28 @@ export default function reactNativeTailwindBabelPlugin(
317
361
  state.hasPlatformImport = true;
318
362
  }
319
363
 
320
- if (hasUseColorScheme) {
321
- state.hasColorSchemeImport = true;
322
- }
323
-
324
364
  // Store reference to the react-native import for later modification if needed
325
365
  state.reactNativeImportPath = path;
326
366
  }
327
367
 
368
+ // Track color scheme hook import from the configured source
369
+ // (default: react-native, but can be custom like @/hooks/useColorScheme)
370
+ if (node.source.value === state.colorSchemeImportSource) {
371
+ const specifiers = node.specifiers;
372
+
373
+ for (const spec of specifiers) {
374
+ if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported)) {
375
+ if (spec.imported.name === state.colorSchemeHookName) {
376
+ state.hasColorSchemeImport = true;
377
+ // Track the local identifier (handles aliased imports)
378
+ // e.g., import { useTheme as navTheme } → local name is 'navTheme'
379
+ state.colorSchemeLocalIdentifier = spec.local.name;
380
+ break;
381
+ }
382
+ }
383
+ }
384
+ }
385
+
328
386
  // Track tw/twStyle imports from main package (for compile-time transformation)
329
387
  if (node.source.value === "@mgcrea/react-native-tailwind") {
330
388
  const specifiers = node.specifiers;
@@ -15,16 +15,18 @@ export declare function addPlatformImport(path: NodePath<BabelTypes.Program>, t:
15
15
  /**
16
16
  * Add useColorScheme import to the file or merge with existing react-native import
17
17
  */
18
- export declare function addColorSchemeImport(path: NodePath<BabelTypes.Program>, t: typeof BabelTypes): void;
18
+ export declare function addColorSchemeImport(path: NodePath<BabelTypes.Program>, importSource: string, hookName: string, t: typeof BabelTypes): void;
19
19
  /**
20
- * Inject useColorScheme hook call at the top of a function component
20
+ * Inject color scheme hook call at the top of a function component
21
21
  *
22
22
  * @param functionPath - Path to the function component
23
23
  * @param colorSchemeVariableName - Name for the color scheme variable
24
+ * @param hookName - Name of the hook to call (e.g., 'useColorScheme')
25
+ * @param localIdentifier - Local identifier if hook is already imported with an alias
24
26
  * @param t - Babel types
25
27
  * @returns true if hook was injected, false if already exists
26
28
  */
27
- export declare function injectColorSchemeHook(functionPath: NodePath<BabelTypes.Function>, colorSchemeVariableName: string, t: typeof BabelTypes): boolean;
29
+ export declare function injectColorSchemeHook(functionPath: NodePath<BabelTypes.Function>, colorSchemeVariableName: string, hookName: string, localIdentifier: string | undefined, t: typeof BabelTypes): boolean;
28
30
  /**
29
31
  * Inject StyleSheet.create with all collected styles at the top of the file
30
32
  * This ensures the styles object is defined before any code that references it
@@ -67,54 +67,64 @@ export function addPlatformImport(path: NodePath<BabelTypes.Program>, t: typeof
67
67
  /**
68
68
  * Add useColorScheme import to the file or merge with existing react-native import
69
69
  */
70
- export function addColorSchemeImport(path: NodePath<BabelTypes.Program>, t: typeof BabelTypes): void {
71
- // Check if there's already a react-native import
70
+ export function addColorSchemeImport(
71
+ path: NodePath<BabelTypes.Program>,
72
+ importSource: string,
73
+ hookName: string,
74
+ t: typeof BabelTypes,
75
+ ): void {
76
+ // Check if there's already an import from the specified source
72
77
  const body = path.node.body;
73
- let reactNativeImport: BabelTypes.ImportDeclaration | null = null;
78
+ let existingValueImport: BabelTypes.ImportDeclaration | null = null;
74
79
 
75
80
  for (const statement of body) {
76
- if (t.isImportDeclaration(statement) && statement.source.value === "react-native") {
77
- reactNativeImport = statement;
78
- break;
81
+ if (t.isImportDeclaration(statement) && statement.source.value === importSource) {
82
+ // Only consider value imports (not type-only imports which get erased)
83
+ if (statement.importKind !== "type") {
84
+ existingValueImport = statement;
85
+ break; // Found a value import, we can stop
86
+ }
79
87
  }
80
88
  }
81
89
 
82
- if (reactNativeImport) {
83
- // Check if useColorScheme is already imported
84
- const hasUseColorScheme = reactNativeImport.specifiers.some(
90
+ // If we found a value import (not type-only), merge with it
91
+ if (existingValueImport) {
92
+ // Check if the hook is already imported
93
+ const hasHook = existingValueImport.specifiers.some(
85
94
  (spec) =>
86
- t.isImportSpecifier(spec) &&
87
- spec.imported.type === "Identifier" &&
88
- spec.imported.name === "useColorScheme",
95
+ t.isImportSpecifier(spec) && spec.imported.type === "Identifier" && spec.imported.name === hookName,
89
96
  );
90
97
 
91
- if (!hasUseColorScheme) {
92
- // Add useColorScheme to existing react-native import
93
- reactNativeImport.specifiers.push(
94
- t.importSpecifier(t.identifier("useColorScheme"), t.identifier("useColorScheme")),
95
- );
98
+ if (!hasHook) {
99
+ // Add hook to existing value import
100
+ existingValueImport.specifiers.push(t.importSpecifier(t.identifier(hookName), t.identifier(hookName)));
96
101
  }
97
102
  } else {
98
- // Create new react-native import with useColorScheme
103
+ // No value import exists - create a new one
104
+ // (Don't merge with type-only imports as they get erased by Babel/TypeScript)
99
105
  const importDeclaration = t.importDeclaration(
100
- [t.importSpecifier(t.identifier("useColorScheme"), t.identifier("useColorScheme"))],
101
- t.stringLiteral("react-native"),
106
+ [t.importSpecifier(t.identifier(hookName), t.identifier(hookName))],
107
+ t.stringLiteral(importSource),
102
108
  );
103
109
  path.unshiftContainer("body", importDeclaration);
104
110
  }
105
111
  }
106
112
 
107
113
  /**
108
- * Inject useColorScheme hook call at the top of a function component
114
+ * Inject color scheme hook call at the top of a function component
109
115
  *
110
116
  * @param functionPath - Path to the function component
111
117
  * @param colorSchemeVariableName - Name for the color scheme variable
118
+ * @param hookName - Name of the hook to call (e.g., 'useColorScheme')
119
+ * @param localIdentifier - Local identifier if hook is already imported with an alias
112
120
  * @param t - Babel types
113
121
  * @returns true if hook was injected, false if already exists
114
122
  */
115
123
  export function injectColorSchemeHook(
116
124
  functionPath: NodePath<BabelTypes.Function>,
117
125
  colorSchemeVariableName: string,
126
+ hookName: string,
127
+ localIdentifier: string | undefined,
118
128
  t: typeof BabelTypes,
119
129
  ): boolean {
120
130
  let body = functionPath.node.body;
@@ -151,11 +161,16 @@ export function injectColorSchemeHook(
151
161
  return false; // Already injected
152
162
  }
153
163
 
154
- // Create: const _twColorScheme = useColorScheme();
164
+ // Use the local identifier if hook was already imported with an alias,
165
+ // otherwise use the configured hook name
166
+ // e.g., import { useTheme as navTheme } → call navTheme()
167
+ const identifierToCall = localIdentifier ?? hookName;
168
+
169
+ // Create: const _twColorScheme = useColorScheme(); (or aliased name if already imported)
155
170
  const hookCall = t.variableDeclaration("const", [
156
171
  t.variableDeclarator(
157
172
  t.identifier(colorSchemeVariableName),
158
- t.callExpression(t.identifier("useColorScheme"), []),
173
+ t.callExpression(t.identifier(identifierToCall), []),
159
174
  ),
160
175
  ]);
161
176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mgcrea/react-native-tailwind",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Compile-time Tailwind CSS for React Native with zero runtime overhead",
5
5
  "author": "Olivier Louvignes <olivier@mgcrea.io> (https://github.com/mgcrea)",
6
6
  "homepage": "https://github.com/mgcrea/react-native-tailwind#readme",
@@ -7,7 +7,9 @@ import babelPlugin, { type PluginOptions } from "./plugin.js";
7
7
  * Helper to transform code with the Babel plugin
8
8
  */
9
9
  function transform(code: string, options?: PluginOptions, includeJsx = false) {
10
- const presets = includeJsx ? ["@babel/preset-react"] : [];
10
+ const presets = includeJsx
11
+ ? ["@babel/preset-react", ["@babel/preset-typescript", { isTSX: true, allExtensions: true }]]
12
+ : [];
11
13
 
12
14
  const result = transformSync(code, {
13
15
  presets,
@@ -1022,6 +1024,278 @@ describe("Babel plugin - color scheme modifier transformation", () => {
1022
1024
  });
1023
1025
  });
1024
1026
 
1027
+ describe("Babel plugin - custom color scheme hook import", () => {
1028
+ it("should use custom import source for color scheme hook", () => {
1029
+ const input = `
1030
+ import React from 'react';
1031
+ import { View } from 'react-native';
1032
+
1033
+ export function Component() {
1034
+ return <View className="dark:bg-gray-900" />;
1035
+ }
1036
+ `;
1037
+
1038
+ const output = transform(
1039
+ input,
1040
+ {
1041
+ colorScheme: {
1042
+ importFrom: "@/hooks/useColorScheme",
1043
+ importName: "useColorScheme",
1044
+ },
1045
+ },
1046
+ true,
1047
+ );
1048
+
1049
+ // Should import from custom source
1050
+ expect(output).toContain('from "@/hooks/useColorScheme"');
1051
+ expect(output).not.toContain('useColorScheme } from "react-native"');
1052
+
1053
+ // Should inject hook call
1054
+ expect(output).toContain("_twColorScheme = useColorScheme()");
1055
+
1056
+ // Should have conditional styling
1057
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
1058
+ });
1059
+
1060
+ it("should use custom hook name", () => {
1061
+ const input = `
1062
+ import React from 'react';
1063
+ import { View } from 'react-native';
1064
+
1065
+ export function Component() {
1066
+ return <View className="dark:bg-gray-900" />;
1067
+ }
1068
+ `;
1069
+
1070
+ const output = transform(
1071
+ input,
1072
+ {
1073
+ colorScheme: {
1074
+ importFrom: "@react-navigation/native",
1075
+ importName: "useTheme",
1076
+ },
1077
+ },
1078
+ true,
1079
+ );
1080
+
1081
+ // Should import useTheme from React Navigation
1082
+ expect(output).toContain('from "@react-navigation/native"');
1083
+ expect(output).toContain("useTheme");
1084
+
1085
+ // Should call useTheme hook
1086
+ expect(output).toContain("_twColorScheme = useTheme()");
1087
+
1088
+ // Should have conditional styling
1089
+ expect(output).toMatch(/_twColorScheme\s*===\s*['"]dark['"]/);
1090
+ });
1091
+
1092
+ it("should merge custom hook with existing import from same source", () => {
1093
+ const input = `
1094
+ import React from 'react';
1095
+ import { View, Text } from 'react-native';
1096
+ import { useNavigation } from '@react-navigation/native';
1097
+
1098
+ export function Component() {
1099
+ const navigation = useNavigation();
1100
+ return (
1101
+ <View className="dark:bg-gray-900">
1102
+ <Text onPress={() => navigation.navigate('Home')}>Go Home</Text>
1103
+ </View>
1104
+ );
1105
+ }
1106
+ `;
1107
+
1108
+ const output = transform(
1109
+ input,
1110
+ {
1111
+ colorScheme: {
1112
+ importFrom: "@react-navigation/native",
1113
+ importName: "useTheme",
1114
+ },
1115
+ },
1116
+ true,
1117
+ );
1118
+
1119
+ // Should merge with existing import (both useNavigation and useTheme in same import)
1120
+ expect(output).toMatch(
1121
+ /import\s+\{\s*useNavigation[^}]*useTheme[^}]*\}\s+from\s+['"]@react-navigation\/native['"]/,
1122
+ );
1123
+ expect(output).toContain("useNavigation()");
1124
+ expect(output).toContain("useTheme()");
1125
+
1126
+ // Should only have one import from that source
1127
+ const importCount = (output.match(/@react-navigation\/native/g) ?? []).length;
1128
+ expect(importCount).toBe(1);
1129
+ });
1130
+
1131
+ it("should not duplicate custom hook if already imported", () => {
1132
+ const input = `
1133
+ import React from 'react';
1134
+ import { View } from 'react-native';
1135
+ import { useColorScheme } from '@/hooks/useColorScheme';
1136
+
1137
+ export function Component() {
1138
+ return <View className="dark:bg-gray-900" />;
1139
+ }
1140
+ `;
1141
+
1142
+ const output = transform(
1143
+ input,
1144
+ {
1145
+ colorScheme: {
1146
+ importFrom: "@/hooks/useColorScheme",
1147
+ importName: "useColorScheme",
1148
+ },
1149
+ },
1150
+ true,
1151
+ );
1152
+
1153
+ // Should not add duplicate import
1154
+ const importMatches = output.match(/import.*useColorScheme.*from ['"]@\/hooks\/useColorScheme['"]/g);
1155
+ expect(importMatches).toHaveLength(1);
1156
+
1157
+ // Should still inject hook call
1158
+ expect(output).toContain("_twColorScheme = useColorScheme()");
1159
+ });
1160
+
1161
+ it("should use react-native by default when no custom config provided", () => {
1162
+ const input = `
1163
+ import React from 'react';
1164
+ import { View } from 'react-native';
1165
+
1166
+ export function Component() {
1167
+ return <View className="dark:bg-gray-900" />;
1168
+ }
1169
+ `;
1170
+
1171
+ const output = transform(input, undefined, true);
1172
+
1173
+ // Should use default react-native import (can be single or double quotes)
1174
+ expect(output).toMatch(/useColorScheme\s*}\s*from\s+['"]react-native['"]/);
1175
+ expect(output).not.toContain("@/hooks");
1176
+ expect(output).not.toContain("@react-navigation");
1177
+
1178
+ // Should inject hook call with default name
1179
+ expect(output).toContain("_twColorScheme = useColorScheme()");
1180
+ });
1181
+
1182
+ it("should create separate import when only type-only import exists", () => {
1183
+ const input = `
1184
+ import React from 'react';
1185
+ import { View } from 'react-native';
1186
+ import type { NavigationProp } from '@react-navigation/native';
1187
+
1188
+ export function Component() {
1189
+ return <View className="dark:bg-gray-900" />;
1190
+ }
1191
+ `;
1192
+
1193
+ const output = transform(
1194
+ input,
1195
+ {
1196
+ colorScheme: {
1197
+ importFrom: "@react-navigation/native",
1198
+ importName: "useTheme",
1199
+ },
1200
+ },
1201
+ true,
1202
+ );
1203
+
1204
+ // TypeScript preset strips type-only imports, but the important thing is:
1205
+ // 1. useTheme hook is imported (not skipped thinking it was already imported)
1206
+ // 2. Hook is correctly called in the component
1207
+ expect(output).toMatch(/import\s+\{\s*useTheme\s*\}\s+from\s+['"]@react-navigation\/native['"]/);
1208
+ expect(output).toContain("_twColorScheme = useTheme()");
1209
+ });
1210
+
1211
+ it("should use aliased identifier when hook is already imported with alias", () => {
1212
+ const input = `
1213
+ import React from 'react';
1214
+ import { View, Text } from 'react-native';
1215
+ import { useTheme as navTheme } from '@react-navigation/native';
1216
+
1217
+ export function Component() {
1218
+ const theme = navTheme();
1219
+ return (
1220
+ <View className="dark:bg-gray-900">
1221
+ <Text>{theme.dark ? 'Dark' : 'Light'}</Text>
1222
+ </View>
1223
+ );
1224
+ }
1225
+ `;
1226
+
1227
+ const output = transform(
1228
+ input,
1229
+ {
1230
+ colorScheme: {
1231
+ importFrom: "@react-navigation/native",
1232
+ importName: "useTheme",
1233
+ },
1234
+ },
1235
+ true,
1236
+ );
1237
+
1238
+ // Should not add duplicate import
1239
+ const importMatches = output.match(
1240
+ /import\s+\{[^}]*useTheme[^}]*\}\s+from\s+['"]@react-navigation\/native['"]/g,
1241
+ );
1242
+ expect(importMatches).toHaveLength(1);
1243
+
1244
+ // Should still have the aliased import
1245
+ expect(output).toMatch(/useTheme\s+as\s+navTheme/);
1246
+
1247
+ // Should call the aliased name (navTheme), not the export name (useTheme)
1248
+ // Both the user's code and our injected hook should use navTheme
1249
+ expect(output).toContain("_twColorScheme = navTheme()");
1250
+ expect(output).not.toContain("_twColorScheme = useTheme()");
1251
+ });
1252
+
1253
+ it("should handle both type-only and aliased imports together", () => {
1254
+ const input = `
1255
+ import React from 'react';
1256
+ import { View, Text } from 'react-native';
1257
+ import type { Theme } from '@react-navigation/native';
1258
+ import { useTheme as getNavTheme } from '@react-navigation/native';
1259
+
1260
+ export function Component() {
1261
+ const theme = getNavTheme();
1262
+ return (
1263
+ <View className="dark:bg-gray-900">
1264
+ <Text>{theme.dark ? 'Dark Mode' : 'Light Mode'}</Text>
1265
+ </View>
1266
+ );
1267
+ }
1268
+ `;
1269
+
1270
+ const output = transform(
1271
+ input,
1272
+ {
1273
+ colorScheme: {
1274
+ importFrom: "@react-navigation/native",
1275
+ importName: "useTheme",
1276
+ },
1277
+ },
1278
+ true,
1279
+ );
1280
+
1281
+ // TypeScript preset strips type-only imports
1282
+ // The important thing is: should not add duplicate import, and should use aliased name
1283
+ expect(output).toMatch(
1284
+ /import\s+\{[^}]*useTheme\s+as\s+getNavTheme[^}]*\}\s+from\s+['"]@react-navigation\/native['"]/,
1285
+ );
1286
+
1287
+ // Should not add duplicate import - useTheme should only appear in the aliased import
1288
+ const useThemeImports = output.match(
1289
+ /import\s+\{[^}]*useTheme[^}]*\}\s+from\s+['"]@react-navigation\/native['"]/g,
1290
+ );
1291
+ expect(useThemeImports).toHaveLength(1);
1292
+
1293
+ // Should call the aliased name for both user code and our injected hook
1294
+ expect(output).toContain("_twColorScheme = getNavTheme()");
1295
+ expect(output).not.toContain("_twColorScheme = useTheme()");
1296
+ });
1297
+ });
1298
+
1025
1299
  describe("Babel plugin - import injection", () => {
1026
1300
  it("should not add StyleSheet import to files without className usage", () => {
1027
1301
  const input = `