diff --git a/docs/src/content/docs/guides/compile-time-tw.md b/docs/src/content/docs/guides/compile-time-tw.md index 07c95da..f0a5dbd 100644 --- a/docs/src/content/docs/guides/compile-time-tw.md +++ b/docs/src/content/docs/guides/compile-time-tw.md @@ -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 ; +} +``` + +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 ; +} +``` + +The same API works with `twStyle("...")` declarations. + ## With Platform Modifiers Platform modifiers (`ios:`, `android:`, `web:`) work in `tw` calls anywhere: diff --git a/docs/src/content/docs/reference/api.md b/docs/src/content/docs/reference/api.md index fe5d564..ebece59 100644 --- a/docs/src/content/docs/reference/api.md +++ b/docs/src/content/docs/reference/api.md @@ -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 ; +} +``` + +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: @@ -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 diff --git a/src/babel/plugin.ts b/src/babel/plugin.ts index 078f8c6..b245a94 100644 --- a/src/babel/plugin.ts +++ b/src/babel/plugin.ts @@ -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); } } } @@ -99,6 +101,7 @@ export default function reactNativeTailwindBabelPlugin( state.supportedAttributes, state.attributePatterns, state.twImportNames, + state.useTwStyleImportNames, t, ) ) { diff --git a/src/babel/plugin/state.ts b/src/babel/plugin/state.ts index 91d8971..8d97fbe 100644 --- a/src/babel/plugin/state.ts +++ b/src/babel/plugin/state.ts @@ -115,6 +115,7 @@ export type PluginState = PluginPass & { stylesIdentifier: string; // Track tw/twStyle imports from main package twImportNames: Set; // e.g., ['tw', 'twStyle'] or ['tw as customTw'] + useTwStyleImportNames: Set; hasTwImport: boolean; // Track react-native import path for conditional StyleSheet/Platform injection reactNativeImportPath?: NodePath; @@ -178,6 +179,7 @@ export function createInitialState( attributePatterns: patterns, stylesIdentifier, twImportNames: new Set(), + useTwStyleImportNames: new Set(), hasTwImport: false, reactNativeImportPath: undefined, functionComponentsNeedingColorScheme: new Set(), diff --git a/src/babel/plugin/visitors/imports.ts b/src/babel/plugin/visitors/imports.ts index 3dad7f2..2693405 100644 --- a/src/babel/plugin/visitors/imports.ts +++ b/src/babel/plugin/visitors/imports.ts @@ -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); } } }); diff --git a/src/babel/plugin/visitors/tw.test.ts b/src/babel/plugin/visitors/tw.test.ts index 1ac4a2e..d222158 100644 --- a/src/babel/plugin/visitors/tw.test.ts +++ b/src/babel/plugin/visitors/tw.test.ts @@ -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'; @@ -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 = ` @@ -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(); }); diff --git a/src/babel/plugin/visitors/tw.ts b/src/babel/plugin/visitors/tw.ts index bf501d0..f3a5404 100644 --- a/src/babel/plugin/visitors/tw.ts +++ b/src/babel/plugin/visitors/tw.ts @@ -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; } diff --git a/src/babel/utils/preInjection.ts b/src/babel/utils/preInjection.ts index 99461ba..c7bb530 100644 --- a/src/babel/utils/preInjection.ts +++ b/src/babel/utils/preInjection.ts @@ -25,9 +25,10 @@ export function scanForColorSchemeModifiers( supportedAttributes: Set, attributePatterns: RegExp[], twImportNames: Set, + useTwStyleImportNames: Set, t: typeof BabelTypes, ): boolean { - return walkNode(node, supportedAttributes, attributePatterns, twImportNames, t); + return walkNode(node, supportedAttributes, attributePatterns, twImportNames, useTwStyleImportNames, t); } function walkNode( @@ -35,6 +36,7 @@ function walkNode( supportedAttributes: Set, attributePatterns: RegExp[], twImportNames: Set, + useTwStyleImportNames: Set, t: typeof BabelTypes, ): boolean { // Check JSXAttribute with color scheme class names @@ -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)) { @@ -80,7 +86,9 @@ 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; } } @@ -88,7 +96,7 @@ function walkNode( } 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; } } diff --git a/src/babel/utils/twProcessing.ts b/src/babel/utils/twProcessing.ts index 6647df0..18de68b 100644 --- a/src/babel/utils/twProcessing.ts +++ b/src/babel/utils/twProcessing.ts @@ -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 { @@ -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"); diff --git a/src/index.ts b/src/index.ts index 0bd46f6..6eaa0e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 3fb2865..663cba5 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -7,7 +7,8 @@ export type NativeStyle = ViewStyle | TextStyle | ImageStyle; /** * Return type for tw/twStyle functions with separate style properties for modifiers - * When color-scheme modifiers (dark:, light:) are present, style becomes an array with runtime conditionals + * When color-scheme modifiers (dark:, light:) are used inside a component, style becomes an array with runtime conditionals. + * Module-level calls retain a base style and expose darkStyle/lightStyle for useTwStyle or resolveTwStyle. * When platform modifiers (ios:, android:, web:) are present, style becomes an array with Platform.select() */ export type TwStyle = { diff --git a/src/useTwStyle.test.ts b/src/useTwStyle.test.ts new file mode 100644 index 0000000..c928e20 --- /dev/null +++ b/src/useTwStyle.test.ts @@ -0,0 +1,49 @@ +import type { ViewStyle } from "react-native"; +import { describe, expect, it } from "vitest"; + +import type { TwStyle } from "./types/runtime"; +import { resolveTwStyle, useTwStyle } from "./useTwStyle"; + +const baseStyle: ViewStyle = { backgroundColor: "white" }; +const darkStyle: ViewStyle = { backgroundColor: "black" }; +const lightStyle: ViewStyle = { backgroundColor: "ivory" }; + +const sharedStyles: TwStyle = { + style: baseStyle, + darkStyle, + lightStyle, +}; + +describe("resolveTwStyle", () => { + it("should append the matching dark or light variant", () => { + expect(resolveTwStyle(sharedStyles, "dark")).toEqual([baseStyle, darkStyle]); + expect(resolveTwStyle(sharedStyles, "light")).toEqual([baseStyle, lightStyle]); + }); + + it("should leave the base style unchanged for a null scheme or missing variant", () => { + expect(resolveTwStyle(sharedStyles, "unspecified")).toBe(baseStyle); + expect(resolveTwStyle(sharedStyles, null)).toBe(baseStyle); + expect(resolveTwStyle({ style: baseStyle }, "dark")).toBe(baseStyle); + }); + + it("should append to an existing style array without mutating it", () => { + const baseArray = [baseStyle, false] as Array; + const styles: TwStyle = { style: baseArray, darkStyle }; + + expect(resolveTwStyle(styles, "dark")).toEqual([baseStyle, false, darkStyle]); + expect(baseArray).toEqual([baseStyle, false]); + }); + + it("should not duplicate a scheme style already resolved by the compiler", () => { + const resolvedArray = [baseStyle, darkStyle] as Array; + const styles: TwStyle = { style: resolvedArray, darkStyle }; + + expect(resolveTwStyle(styles, "dark")).toBe(resolvedArray); + }); +}); + +describe("useTwStyle", () => { + it("should fail clearly when the Babel plugin did not inject the configured scheme", () => { + expect(() => useTwStyle(sharedStyles)).toThrow(/must be transformed.*configured color-scheme hook/); + }); +}); diff --git a/src/useTwStyle.ts b/src/useTwStyle.ts new file mode 100644 index 0000000..25b7e76 --- /dev/null +++ b/src/useTwStyle.ts @@ -0,0 +1,48 @@ +import type { ColorSchemeName } from "react-native"; + +import type { NativeStyle, TwStyle } from "./types/runtime"; + +export type ResolvedTwStyle = T | Array; +export type TwColorScheme = ColorSchemeName | null | undefined; + +/** + * Resolve a compiled TwStyle object for an explicit color scheme. + * Useful with an application's custom theme hook. + */ +export function resolveTwStyle( + styles: TwStyle, + colorScheme: TwColorScheme, +): ResolvedTwStyle { + const schemeStyle = + colorScheme === "dark" ? styles.darkStyle : colorScheme === "light" ? styles.lightStyle : undefined; + + if (!schemeStyle) { + return styles.style; + } + + if (Array.isArray(styles.style)) { + // Calls compiled inside a component may already contain the active scheme + // style. Preserve that array instead of applying the same variant twice. + return styles.style.includes(schemeStyle) ? styles.style : [...styles.style, schemeStyle]; + } + + return [styles.style, schemeStyle]; +} + +/** + * Reactively resolve a module-level compiled TwStyle object with the color + * scheme hook configured in the Babel plugin. The plugin injects the second + * argument at compile time. + */ +export function useTwStyle(styles: TwStyle): ResolvedTwStyle; +export function useTwStyle( + styles: TwStyle, + colorScheme?: TwColorScheme, +): ResolvedTwStyle { + if (arguments.length < 2) { + throw new Error( + "useTwStyle() must be transformed by @mgcrea/react-native-tailwind/babel so it can use the configured color-scheme hook.", + ); + } + return resolveTwStyle(styles, colorScheme); +}