import { type BabelFileResult, transformFromAstSync } from '@babel/core' import generator from '@babel/generator' import { declare } from '@babel/helper-plugin-utils' import { parse } from '@babel/parser' import template from '@babel/template' import * as t from '@babel/types' import { basename } from 'node:path' import { getPragmaOptions } from '../getPragmaOptions' import type { TamaguiOptions } from '../types' import { createExtractor } from './createExtractor' import { createLogger } from './createLogger' import { isSimpleSpread } from './extractHelpers' import { literalToAst } from './literalToAst' import { loadTamaguiBuildConfigSync } from './loadTamagui' const importNativeView = template(` const __ReactNativeView = require('react-native').View; const __ReactNativeText = require('react-native').Text; `) const importStyleSheet = template(` const __ReactNativeStyleSheet = require('react-native').StyleSheet; `) const importWithStyle = template.ast(`import { _withStableStyle } from '@tamagui/core';`) const extractor = createExtractor({ platform: 'native' }) let tamaguiBuildOptionsLoaded: TamaguiOptions | null export function extractToNative( sourceFileName: string, sourceCode: string, options: TamaguiOptions ): BabelFileResult { const ast = parse(sourceCode, { sourceType: 'module', plugins: ['jsx', 'typescript'], }) const babelPlugin = getBabelPlugin() const out = transformFromAstSync(ast, sourceCode, { plugins: [[babelPlugin, options]], configFile: false, sourceFileName, filename: sourceFileName, }) if (!out) { throw new Error(`No output returned`) } return out } export function getBabelPlugin() { return declare((api, options: TamaguiOptions) => { api.assertVersion(7) return getBabelParseDefinition(options) }) } export function getBabelParseDefinition(options: TamaguiOptions) { return { name: 'tamagui', visitor: { Program: { enter(this: any, root) { let sourcePath = this.file.opts.filename if (sourcePath?.includes('node_modules')) { return } // by default only pick up .jsx / .tsx if (!sourcePath?.endsWith('.jsx') && !sourcePath?.endsWith('.tsx')) { return } // this filename comes back incorrect in react-native, it adds /ios/ for some reason // adding a fix here, but it's a bit tentative... if (process.env.SOURCE_ROOT?.endsWith('ios')) { sourcePath = sourcePath.replace('/ios', '') } let hasImportedView = false let hasImportedViewWrapper = false let wrapperCount = 0 const sheetStyles = {} const sheetIdentifier = root.scope.generateUidIdentifier('sheet') // babel doesnt append the `//` so we need to const firstCommentContents = // join because you can join together multiple pragmas root.node.body[0]?.leadingComments ?.map((comment) => comment?.value || ' ') .join(' ') ?? '' const firstComment = firstCommentContents ? `//${firstCommentContents}` : '' const { shouldPrintDebug, shouldDisable } = getPragmaOptions({ source: firstComment, path: sourcePath, }) if (shouldDisable) { return } if (!options.config && !options.components) { // if no config/components given try and load from the tamagui.build.ts file tamaguiBuildOptionsLoaded ||= loadTamaguiBuildConfigSync({}) } const finalOptions = { // @ts-ignore just in case they leave it out platform: 'native', ...tamaguiBuildOptionsLoaded, ...options, } satisfies TamaguiOptions const printLog = createLogger(sourcePath, finalOptions) function addSheetStyle(style: any, node: t.JSXOpeningElement) { const styleIndex = `${Object.keys(sheetStyles).length}` let key = `${styleIndex}` if (process.env.NODE_ENV === 'development') { const lineNumbers = node.loc ? node.loc.start.line + (node.loc.start.line !== node.loc.end.line ? `-${node.loc.end.line}` : '') : '' key += `:${basename(sourcePath)}:${lineNumbers}` } sheetStyles[key] = style return readStyleExpr(key) } function readStyleExpr(key: string) { return template(`SHEET['KEY']`)({ SHEET: sheetIdentifier.name, KEY: key, })['expression'] as t.MemberExpression } let res try { res = extractor.parseSync(root, { importsWhitelist: ['constants.js', 'colors.js'], excludeProps: new Set([ 'className', 'userSelect', 'whiteSpace', 'textOverflow', 'cursor', 'contain', ]), // native props that should pass through without preventing extraction inlineProps: new Set([ 'testID', 'nativeID', 'accessibilityLabel', 'accessibilityHint', 'accessibilityRole', 'accessibilityState', 'accessibilityValue', 'accessibilityActions', 'accessibilityLabelledBy', 'accessibilityLiveRegion', 'accessibilityElementsHidden', 'accessibilityViewIsModal', 'importantForAccessibility', 'onAccessibilityAction', 'onAccessibilityEscape', 'onAccessibilityTap', 'onMagicTap', 'collapsable', 'needsOffscreenAlphaCompositing', 'removeClippedSubviews', 'renderToHardwareTextureAndroid', 'shouldRasterizeIOS', 'hitSlop', 'pointerEvents', ]), shouldPrintDebug, ...finalOptions, // disable extracting variables as no native concept of them (only theme values) disableExtractVariables: false, sourcePath, // disabling flattening for now // it's flattening a plain hello which breaks things because themes // thinking it's not really worth the effort to do much compilation on native // for now just disable flatten as it can only run in narrow places on native // disableFlattening: 'styled', getFlattenedNode({ isTextView }) { if (!hasImportedView) { hasImportedView = true root.unshiftContainer('body', importNativeView()) } return isTextView ? '__ReactNativeText' : '__ReactNativeView' }, onExtractTag(props) { assertValidTag(props.node) const stylesExpr = t.arrayExpression([]) const hocStylesExpr = t.arrayExpression([]) const expressions: t.Expression[] = [] const finalAttrs: (t.JSXAttribute | t.JSXSpreadAttribute)[] = [] const themeKeysUsed = new Set() function getStyleExpression(style: object | null, forTernary = false) { if (!style) return // split theme properties and leave them as props since RN has no concept of theme const { plain, themed } = splitThemeStyles(style) // TODO: themed is not a good name, because it's not just theme it also includes tokens let themeExpr: t.ObjectExpression | null = null if (themed) { for (const key in themed) { themeKeysUsed.add(themed[key].split('$')[1]) } // make a sub-array themeExpr = getThemedStyleExpression(themed) } const hasPlainKeys = Object.keys(plain).length > 0 const ident = hasPlainKeys ? addSheetStyle(plain, props.node) : null if (themeExpr) { if (forTernary) { // for ternary branches, return combined expression // without adding plain styles unconditionally if (ident) { return t.arrayExpression([ident, themeExpr]) } return themeExpr } // for base styles, add unconditionally if (ident) { addStyleExpression(ident) addStyleExpression(ident, true) } return themeExpr } return ident } function addStyleExpression(expr: any, HOC = false) { if (Array.isArray(expr)) { ;(HOC ? hocStylesExpr : stylesExpr).elements.push(...expr) } else { ;(HOC ? hocStylesExpr : stylesExpr).elements.push(expr) } } function getThemedStyleExpression(styles: object) { const themedStylesAst = literalToAst(styles) as t.ObjectExpression themedStylesAst.properties.forEach((_) => { const prop = _ as t.ObjectProperty if (prop.value.type === 'StringLiteral') { const propVal = prop.value.value.slice(1) const isComputed = !t.isValidIdentifier(propVal) prop.value = t.callExpression( t.memberExpression( t.memberExpression( t.identifier('theme'), isComputed ? t.stringLiteral(propVal) : t.identifier(propVal), isComputed ), t.identifier('get') ), [] ) } }) return themedStylesAst } let hasDynamicStyle = false let hasMediaKeys = false for (const attr of props.attrs) { switch (attr.type) { case 'style': { let styleExpr = getStyleExpression(attr.value) addStyleExpression(styleExpr) addStyleExpression(styleExpr, true) break } case 'ternary': { const { consequent, alternate } = attr.value const consExpr = getStyleExpression(consequent, true) const altExpr = getStyleExpression(alternate, true) if (attr.value.inlineMediaQuery) { hasMediaKeys = true } expressions.push(attr.value.test) addStyleExpression( t.conditionalExpression( t.identifier(`_expressions[${expressions.length - 1}]`), consExpr || t.nullLiteral(), altExpr || t.nullLiteral() ), true ) const styleExpr = t.conditionalExpression( attr.value.test, consExpr || t.nullLiteral(), altExpr || t.nullLiteral() ) addStyleExpression(styleExpr) break } case 'attr': { if (t.isJSXSpreadAttribute(attr.value)) { if (isSimpleSpread(attr.value)) { stylesExpr.elements.push( t.memberExpression(attr.value.argument, t.identifier('style')) ) hocStylesExpr.elements.push( t.memberExpression(attr.value.argument, t.identifier('style')) ) } } finalAttrs.push(attr.value) break } } } props.node.attributes = finalAttrs if ( themeKeysUsed.size || hocStylesExpr.elements.length > 1 || hasDynamicStyle ) { if (!hasImportedViewWrapper) { root.unshiftContainer('body', importWithStyle) hasImportedViewWrapper = true } const name = props.flatNodeName || props.node.name['name'] // Use a unique name that won't conflict with the base component const wrapperName = `_${name.replace(/^_+/, '')}Styled${wrapperCount++}` // Use regular identifier for variable declarations, JSX identifier for JSX elements const WrapperIdentifier = t.identifier(wrapperName) const WrapperJSXIdentifier = t.jsxIdentifier(wrapperName) const hasThemeKeysFlag = themeKeysUsed.size > 0 root.pushContainer( 'body', t.variableDeclaration('const', [ t.variableDeclarator( WrapperIdentifier, t.callExpression(t.identifier('_withStableStyle'), [ t.identifier(name), t.arrowFunctionExpression( [t.identifier('theme'), t.identifier('_expressions')], // return styles directly - no useMemo, theme changes must trigger style recalc t.arrayExpression([...hocStylesExpr.elements]) ), t.booleanLiteral(hasThemeKeysFlag), t.booleanLiteral(hasMediaKeys), ]) ), ]) ) // @ts-ignore - use JSX identifier for JSX elements props.node.name = WrapperJSXIdentifier // Also set the opening element directly via the path props.jsxPath.node.openingElement.name = WrapperJSXIdentifier if (props.jsxPath.node.closingElement) { // @ts-ignore props.jsxPath.node.closingElement.name = t.jsxIdentifier(wrapperName) } if (expressions.length) { // coerce runtime expressions to boolean so they can't be // confused with string media keys at runtime const safeExpressions = expressions.map((expr) => t.isStringLiteral(expr) ? expr : t.unaryExpression('!', t.unaryExpression('!', expr)) ) props.node.attributes.push( t.jsxAttribute( t.jsxIdentifier('_expressions'), t.jsxExpressionContainer(t.arrayExpression(safeExpressions)) ) ) } } else { props.node.attributes.push( t.jsxAttribute( t.jsxIdentifier('style'), t.jsxExpressionContainer( stylesExpr.elements.length === 1 ? (stylesExpr.elements[0] as any) : stylesExpr ) ) ) } }, }) } catch (err) { if (err instanceof Error) { // metro doesn't show stack so we can let message = `${shouldPrintDebug === 'verbose' ? err : err.message}` if (message.includes('Unexpected return value from visitor method')) { message = 'Unexpected return value from visitor method' } console.warn('Error in Tamagui parse, skipping', message, err.stack) return } } if (!Object.keys(sheetStyles).length) { if (shouldPrintDebug) { console.info('END no styles') } if (res) printLog(res) return } const sheetObject = literalToAst(sheetStyles) const sheetOuter = template( 'const SHEET = __ReactNativeStyleSheet.create(null)' )({ SHEET: sheetIdentifier.name, }) as any // replace the null with our object sheetOuter.declarations[0].init.arguments[0] = sheetObject root.unshiftContainer('body', sheetOuter) // add import root.unshiftContainer('body', importStyleSheet()) if (shouldPrintDebug) { console.info('\n -------- output code ------- \n') console.info( generator(root.parent) .code.split('\n') .filter((x) => !x.startsWith('//')) .join('\n') ) } if (res) printLog(res) }, }, }, } } function assertValidTag(node: t.JSXOpeningElement) { if (node.attributes.find((x) => x.type === 'JSXAttribute' && x.name.name === 'style')) { // we can just deopt here instead and log warning // need to make onExtractTag have a special catch error or similar if (process.env.DEBUG?.startsWith('tamagui')) { console.warn('⚠️ Cannot pass style attribute to extracted style') } } } function splitThemeStyles(style: object) { const themed: object = {} const plain: object = {} let noTheme = true for (const key in style) { const val = style[key] if (val && val[0] === '$') { themed[key] = val noTheme = false } else { plain[key] = val } } return { themed: noTheme ? null : themed, plain } }