Skip to content
Draft
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
46 changes: 46 additions & 0 deletions docs/src/content/docs/guides/compile-time-tw.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,52 @@ Both `style` (with runtime conditionals) and `darkStyle`/`lightStyle` properties
- **Manual control**: Access `textStyles.darkStyle` or `textStyles.lightStyle` for custom logic
:::

### Shared Module-Level Theme Styles

Hooks cannot run at module scope, but theme-aware styles can still be declared there and resolved reactively where they are consumed:

```tsx
import { View } from "react-native";
import { tw, useTwStyle } from "@mgcrea/react-native-tailwind";

const cardStyles = tw`bg-white dark:bg-gray-900 light:bg-gray-50`;

export function Card({ selected }: { selected: boolean }) {
// Call the hook unconditionally inside the component.
const themedCardStyle = useTwStyle(cardStyles);

return <View style={selected ? themedCardStyle : undefined} />;
}
```

At compile time, the module-level declaration retains every variant without attempting an invalid module-level hook call:

```tsx
const cardStyles = {
style: styles._bg_white,
darkStyle: styles._dark_bg_gray_900,
lightStyle: styles._light_bg_gray_50,
};
```

`useTwStyle` uses the color-scheme hook configured in the Babel plugin, including custom theme-provider hooks. The consuming component re-renders and selects the matching variant when that hook changes. The module-level `style` property remains the base style; it is intentionally not a mutable global value.

For an explicit or controlled scheme value, use the pure resolver:

```tsx
import { resolveTwStyle } from "@mgcrea/react-native-tailwind";
import { useAppColorScheme } from "./theme";

export function Card() {
const colorScheme = useAppColorScheme();
const themedCardStyle = resolveTwStyle(cardStyles, colorScheme);

return <View style={themedCardStyle} />;
}
```

The same API works with `twStyle("...")` declarations.

## With Platform Modifiers

Platform modifiers (`ios:`, `android:`, `web:`) work in `tw` calls anywhere:
Expand Down
32 changes: 31 additions & 1 deletion docs/src/content/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ function parseClassName(
): ViewStyle | TextStyle | ImageStyle;
```

## useTwStyle

Reactively resolve a module-level compiled `tw` or `twStyle` object with the color-scheme hook configured in the Babel plugin:

```tsx
import { tw, useTwStyle } from "@mgcrea/react-native-tailwind";

const sharedStyles = tw`bg-white dark:bg-gray-900`;

function Component() {
const style = useTwStyle(sharedStyles);
return <View style={style} />;
}
```

Call `useTwStyle` unconditionally inside a function component, like any React hook. The Babel plugin injects the configured hook, including custom `colorScheme.importFrom` and `colorScheme.importName` providers.

## resolveTwStyle

Resolve the same compiled object with an explicit scheme. Use this for controlled or imperative theme state when you already have a scheme value:

```tsx
import { resolveTwStyle } from "@mgcrea/react-native-tailwind";

const style = resolveTwStyle(sharedStyles, "dark");
// [sharedStyles.style, sharedStyles.darkStyle]
```

The resolver accepts `"light"`, `"dark"`, React Native's `"unspecified"`, `null`, or `undefined`. It does not mutate the compiled style object or an existing style array.

## Constants

Access default color and spacing scales:
Expand Down Expand Up @@ -166,7 +196,7 @@ function ThemedComponent() {

## Important Notes

- The programmatic API parses styles at **runtime**, not compile-time
- `parseClassName` parses styles at **runtime**; `useTwStyle` and `resolveTwStyle` only select already compiled variants
- For production apps, prefer using `className` prop for compile-time optimization
- Use the programmatic API for:
- Testing
Expand Down
3 changes: 3 additions & 0 deletions src/babel/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export default function reactNativeTailwindBabelPlugin(
const importedName = spec.imported.name;
if (importedName === "tw" || importedName === "twStyle") {
state.twImportNames.add(spec.local.name);
} else if (importedName === "useTwStyle") {
state.useTwStyleImportNames.add(spec.local.name);
}
}
}
Expand All @@ -99,6 +101,7 @@ export default function reactNativeTailwindBabelPlugin(
state.supportedAttributes,
state.attributePatterns,
state.twImportNames,
state.useTwStyleImportNames,
t,
)
) {
Expand Down
2 changes: 2 additions & 0 deletions src/babel/plugin/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export type PluginState = PluginPass & {
stylesIdentifier: string;
// Track tw/twStyle imports from main package
twImportNames: Set<string>; // e.g., ['tw', 'twStyle'] or ['tw as customTw']
useTwStyleImportNames: Set<string>;
hasTwImport: boolean;
// Track react-native import path for conditional StyleSheet/Platform injection
reactNativeImportPath?: NodePath<BabelTypes.ImportDeclaration>;
Expand Down Expand Up @@ -178,6 +179,7 @@ export function createInitialState(
attributePatterns: patterns,
stylesIdentifier,
twImportNames: new Set(),
useTwStyleImportNames: new Set(),
hasTwImport: false,
reactNativeImportPath: undefined,
functionComponentsNeedingColorScheme: new Set(),
Expand Down
2 changes: 2 additions & 0 deletions src/babel/plugin/visitors/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ export function importDeclarationVisitor(
const localName = spec.local.name;
state.twImportNames.add(localName);
// Don't set hasTwImport yet - only set it when we successfully transform a call
} else if (importedName === "useTwStyle") {
state.useTwStyleImportNames.add(spec.local.name);
}
}
});
Expand Down
108 changes: 104 additions & 4 deletions src/babel/plugin/visitors/tw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,62 @@ describe("twStyle visitor - function transformation", () => {
});

describe("tw/twStyle - color scheme modifiers", () => {
it("should make useTwStyle use the configured color scheme hook", () => {
const output = transform(
`
import { tw, useTwStyle } from '@mgcrea/react-native-tailwind';
const sharedStyles = tw\`bg-white dark:bg-gray-900\`;

export function Component() {
return useTwStyle(sharedStyles);
}
`,
{
colorScheme: {
importFrom: "@/theme/useColorScheme",
importName: "useAppColorScheme",
},
},
);

expect(output).toContain('from "@/theme/useColorScheme"');
expect(output).toContain("_twColorScheme = useAppColorScheme()");
expect(output).toContain("useTwStyle(sharedStyles, _twColorScheme)");
expect(output).not.toContain('useColorScheme } from "react-native"');
});

it("should support aliased useTwStyle imports and reuse an existing hook alias", () => {
const output = transform(
`
import { useAppColorScheme as useTheme } from '@/theme/useColorScheme';
import { tw, useTwStyle as useSharedStyle } from '@mgcrea/react-native-tailwind';
const sharedStyles = tw\`bg-white dark:bg-gray-900\`;

export const Component = () => useSharedStyle(sharedStyles);
`,
{
colorScheme: {
importFrom: "@/theme/useColorScheme",
importName: "useAppColorScheme",
},
},
);

expect(output.match(/useAppColorScheme\s+as\s+useTheme/g)).toHaveLength(1);
expect(output).toContain("_twColorScheme = useTheme()");
expect(output).toContain("useSharedStyle(sharedStyles, _twColorScheme)");
});

it("should reject useTwStyle outside a function component", () => {
expect(() =>
transform(`
import { tw, useTwStyle } from '@mgcrea/react-native-tailwind';
const sharedStyles = tw\`bg-white dark:bg-gray-900\`;
export const resolved = useTwStyle(sharedStyles);
`),
).toThrow(/useTwStyle\(\) must be called unconditionally inside a React function component/);
});

it("should transform tw with dark: modifier inside component", () => {
const input = `
import { tw } from '@mgcrea/react-native-tailwind';
Expand Down Expand Up @@ -302,7 +358,7 @@ describe("tw/twStyle - color scheme modifiers", () => {
expect(output).toContain("_twStyles._active_bg_blue_500");
});

it("should warn if tw with color scheme modifiers used outside component", () => {
it("should preserve color scheme variants when tw is used outside a component", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const input = `
Expand All @@ -313,16 +369,60 @@ describe("tw/twStyle - color scheme modifiers", () => {

const output = transform(input);

// Should warn about usage outside component
// Should explain how to resolve the generated variants at the consumption site
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Color scheme modifiers (dark:, light:) in tw/twStyle calls"),
expect.stringContaining("use useTwStyle() or resolveTwStyle() inside the consumer"),
);

// Should not inject hook (no component scope)
expect(output).not.toContain("useColorScheme");

// Should still generate styles but without runtime conditionals
// Should generate the base style and raw dark variant without module-level hook conditionals
expect(output).toContain("_twStyles");
expect(output).toContain("style: _twStyles._bg_white");
expect(output).toContain("darkStyle: _twStyles._dark_bg_gray_900");
expect(output).not.toContain('_twColorScheme === "dark"');

consoleWarnSpy.mockRestore();
});

it("should preserve both variants for module-level twStyle calls", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const input = `
import { twStyle } from '@mgcrea/react-native-tailwind';

export const sharedStyles = twStyle('bg-gray-500 dark:bg-gray-900 light:bg-gray-100');
`;

const output = transform(input);

expect(output).not.toContain("useColorScheme");
expect(output).toContain("style: _twStyles._bg_gray_500");
expect(output).toContain("darkStyle: _twStyles._dark_bg_gray_900");
expect(output).toContain("lightStyle: _twStyles._light_bg_gray_100");

consoleWarnSpy.mockRestore();
});

it("should preserve the runtime useTwStyle import and inject its scheme argument", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const input = `
import { tw, useTwStyle } from '@mgcrea/react-native-tailwind';

const sharedStyles = tw\`bg-white dark:bg-gray-900\`;

export function Component() {
const style = useTwStyle(sharedStyles);
return style;
}
`;

const output = transform(input);

expect(output).not.toMatch(/import\s*{[^}]*\btw\b/);
expect(output).toMatch(/import\s*{\s*useTwStyle\s*}\s*from/);
expect(output).toContain("darkStyle: _twStyles._dark_bg_gray_900");
expect(output).toContain("useTwStyle(sharedStyles, _twColorScheme)");

consoleWarnSpy.mockRestore();
});
Expand Down
20 changes: 20 additions & 0 deletions src/babel/plugin/visitors/tw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ export function callExpressionVisitor(
}

const calleeName = node.callee.name;
if (state.useTwStyleImportNames.has(calleeName)) {
if (node.arguments.length !== 1) {
throw path.buildCodeFrameError(
"[react-native-tailwind] useTwStyle() expects exactly one compiled tw/twStyle object.",
);
}

const componentScope = findComponentScope(path, t);
if (!componentScope) {
throw path.buildCodeFrameError(
"[react-native-tailwind] useTwStyle() must be called unconditionally inside a React function component.",
);
}

state.functionComponentsNeedingColorScheme.add(componentScope);
state.needsColorSchemeImport = true;
node.arguments.push(t.identifier(state.colorSchemeVariableName));
return;
}

if (!state.twImportNames.has(calleeName)) {
return;
}
Expand Down
14 changes: 11 additions & 3 deletions src/babel/utils/preInjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,18 @@ export function scanForColorSchemeModifiers(
supportedAttributes: Set<string>,
attributePatterns: RegExp[],
twImportNames: Set<string>,
useTwStyleImportNames: Set<string>,
t: typeof BabelTypes,
): boolean {
return walkNode(node, supportedAttributes, attributePatterns, twImportNames, t);
return walkNode(node, supportedAttributes, attributePatterns, twImportNames, useTwStyleImportNames, t);
}

function walkNode(
node: BabelTypes.Node,
supportedAttributes: Set<string>,
attributePatterns: RegExp[],
twImportNames: Set<string>,
useTwStyleImportNames: Set<string>,
t: typeof BabelTypes,
): boolean {
// Check JSXAttribute with color scheme class names
Expand All @@ -59,6 +61,10 @@ function walkNode(

// Check CallExpression (twStyle("dark:..."))
if (t.isCallExpression(node) && t.isIdentifier(node.callee)) {
if (useTwStyleImportNames.has(node.callee.name)) {
return true;
}

if (twImportNames.has(node.callee.name)) {
const arg = node.arguments[0];
if (t.isStringLiteral(arg) && COLOR_SCHEME_PATTERN.test(arg.value)) {
Expand All @@ -80,15 +86,17 @@ function walkNode(
if (isASTNode(item)) {
// Skip nested functions (they have their own scope)
if (t.isFunction(item)) continue;
if (walkNode(item, supportedAttributes, attributePatterns, twImportNames, t)) {
if (
walkNode(item, supportedAttributes, attributePatterns, twImportNames, useTwStyleImportNames, t)
) {
return true;
}
}
}
} else if (isASTNode(child)) {
// Skip nested functions
if (t.isFunction(child)) continue;
if (walkNode(child, supportedAttributes, attributePatterns, twImportNames, t)) {
if (walkNode(child, supportedAttributes, attributePatterns, twImportNames, useTwStyleImportNames, t)) {
return true;
}
}
Expand Down
10 changes: 7 additions & 3 deletions src/babel/utils/twProcessing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ export function processTwCall(
if (process.env.NODE_ENV !== "production") {
console.warn(
`[react-native-tailwind] Color scheme modifiers (dark:, light:) in tw/twStyle calls ` +
`must be used inside a React component. Modifiers will be ignored.`,
`cannot resolve automatically outside a React component. ` +
`darkStyle/lightStyle variants were generated; use useTwStyle() or resolveTwStyle() inside the consumer.`,
);
}
} else {
Expand Down Expand Up @@ -182,9 +183,12 @@ export function processTwCall(

// Replace style property with array
objectProperties[0] = t.objectProperty(t.identifier("style"), t.arrayExpression(styleArrayElements));
}

// Also add darkStyle/lightStyle properties for manual processing
// (e.g., extracting raw hex values for Reanimated animations)
// Always expose darkStyle/lightStyle variants. Inside components the style
// property is also reactive; outside components these variants can be
// resolved by useTwStyle() or resolveTwStyle() at the consumption site.
if (hasColorSchemeModifiers) {
const darkModifiers = colorSchemeModifiers.filter((m) => m.modifier === "dark");
const lightModifiers = colorSchemeModifiers.filter((m) => m.modifier === "light");

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ export { parseClass, parseClassName } from "./parser";
export { flattenColors } from "./utils/flattenColors";
export { mergeStyles } from "./utils/mergeStyles";
export { generateStyleKey } from "./utils/styleKey";
export { resolveTwStyle, useTwStyle } from "./useTwStyle";

// Re-export types
export type { StyleObject } from "./types/core";
export type { NativeStyle, TwStyle } from "./types/runtime";
export type { ResolvedTwStyle, TwColorScheme } from "./useTwStyle";

// Re-export colors
export { TAILWIND_COLORS } from "./config/tailwind";
Expand Down
Loading
Loading