diff --git a/CONTEXT.md b/CONTEXT.md index 33ba4339..236e07d6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,6 +118,8 @@ Metro integration: - `withUniwindConfig(config, uniwindConfig)` patches Metro graph support for uncached modules. - Metro adds `css` as source extension and removes it from asset extensions. - Metro transformer handles the configured CSS entry file specially. +- `experimental.optimizeClasslessComponents` (off by default) compiles classless native elements + to raw React Native components; styled or uncertain references keep existing wrappers. - Metro transformer worker selection is lazy, cached per Expo/non-Expo config type, and follows Expo transformer paths or Expo-specific config markers. - Native platform CSS transforms into a JS module that calls `Uniwind.__reinit(...)`. - Web platform CSS transforms into CSS plus web runtime setup. diff --git a/PR.md b/PR.md new file mode 100644 index 00000000..046e9c8b --- /dev/null +++ b/PR.md @@ -0,0 +1 @@ +Litwo! Ojczyzno moja! ty jesteÅ› jak zdrowie; diff --git a/packages/uniwind/build.config.ts b/packages/uniwind/build.config.ts index 6a28bd9d..a4a0c1cd 100644 --- a/packages/uniwind/build.config.ts +++ b/packages/uniwind/build.config.ts @@ -51,6 +51,11 @@ export default defineBuildConfig({ input: './src/bundler/adapters/metro/transformer.ts', name: 'metro/transformer', }, + { + builder: 'rollup', + input: './src/bundler/adapters/metro/babel-transformer.ts', + name: 'metro/babel-transformer', + }, { builder: 'mkdist', input: './src/bundler/adapters/metro', diff --git a/packages/uniwind/jest.config.native.js b/packages/uniwind/jest.config.native.js index de6032e0..f84b4cfc 100644 --- a/packages/uniwind/jest.config.native.js +++ b/packages/uniwind/jest.config.native.js @@ -8,6 +8,7 @@ export default { ], moduleNameMapper: { '^react-native$': '/../../node_modules/react-native', + '^yaml$': '/../../node_modules/yaml/dist/index.js', '^@/(.*)$': '/src/$1', }, } diff --git a/packages/uniwind/src/bundler/adapters/metro/babel-transformer.ts b/packages/uniwind/src/bundler/adapters/metro/babel-transformer.ts new file mode 100644 index 00000000..589400ff --- /dev/null +++ b/packages/uniwind/src/bundler/adapters/metro/babel-transformer.ts @@ -0,0 +1,62 @@ +import type { + BabelTransformer, + BabelTransformerArgs, +} from 'metro-babel-transformer' +import { componentTransform } from './component-transform' +import { + TRANSFORM_COMPONENTS, + UPSTREAM_BABEL_TRANSFORMER, +} from './constants' + +type BabelTransformerModule = BabelTransformer & { + default?: BabelTransformer +} + +const transformerCache = new Map() + +const getTransformer = (transformerPath: string) => { + const cached = transformerCache.get(transformerPath) + if (cached) { + return cached + } + + const module = require(transformerPath) as BabelTransformerModule + const transformer = typeof module.transform === 'function' + ? module + : module.default + + if (!transformer || typeof transformer.transform !== 'function') { + throw new Error(`Uniwind: Invalid upstream Babel transformer at ${transformerPath}`) + } + + transformerCache.set(transformerPath, transformer) + + return transformer +} + +export const transform = (args: BabelTransformerArgs) => { + const customOptions = args.options.customTransformOptions ?? {} + const upstreamPath = customOptions[UPSTREAM_BABEL_TRANSFORMER] + + if (typeof upstreamPath !== 'string') { + throw new Error('Uniwind: Missing upstream Babel transformer path') + } + + const { + [TRANSFORM_COMPONENTS]: shouldTransform, + [UPSTREAM_BABEL_TRANSFORMER]: _upstreamPath, + ...upstreamCustomOptions + } = customOptions + const transformer = getTransformer(upstreamPath) + + return transformer.transform({ + ...args, + options: { + ...args.options, + customTransformOptions: upstreamCustomOptions, + }, + plugins: shouldTransform + ? [...args.plugins ?? [], componentTransform] + : args.plugins, + }) +} diff --git a/packages/uniwind/src/bundler/adapters/metro/component-transform.ts b/packages/uniwind/src/bundler/adapters/metro/component-transform.ts new file mode 100644 index 00000000..05dcac20 --- /dev/null +++ b/packages/uniwind/src/bundler/adapters/metro/component-transform.ts @@ -0,0 +1,395 @@ +import type { NodePath, PluginObj } from '@babel/core' +import type * as t from '@babel/types' +import { + NATIVE_COMPONENT_NAME_SET, + type NativeComponentName, + RAW_COMPONENTS_MODULE, +} from './constants' + +const REACT_NATIVE_MODULE = 'react-native' +const REACT_MODULE = 'react' + +const isNativeComponentName = (name: string): name is NativeComponentName => NATIVE_COMPONENT_NAME_SET.has(name) + +const getImportSource = (path: NodePath) => + path.parentPath?.isImportDeclaration() + ? path.parentPath.node.source.value + : undefined + +const getImportedName = (path: NodePath) => { + if (!path.isImportSpecifier()) { + return undefined + } + + return path.node.imported.type === 'Identifier' + ? path.node.imported.name + : path.node.imported.value +} + +const isRequireCall = (path: NodePath | null | undefined, moduleName: string) => { + if (!path?.isCallExpression()) { + return false + } + + const callee = path.get('callee') + const args = path.get('arguments') + + return callee.isIdentifier({ name: 'require' }) + && args.length === 1 + && args[0]?.isStringLiteral({ value: moduleName }) === true +} + +const isModuleNamespace = ( + path: NodePath | null | undefined, + moduleName: string, + visited: Set, +): boolean => { + if (!path) { + return false + } + + if (isRequireCall(path, moduleName)) { + return true + } + + if (!path.isIdentifier() && !path.isJSXIdentifier()) { + return false + } + + const binding = path.scope.getBinding(path.node.name) + if (!binding?.constant || visited.has(binding.path.node)) { + return false + } + visited.add(binding.path.node) + + if ( + (binding.path.isImportDefaultSpecifier() || binding.path.isImportNamespaceSpecifier()) + && getImportSource(binding.path) === moduleName + ) { + return true + } + + if (!binding.path.isVariableDeclarator()) { + return false + } + + const init = binding.path.get('init') + if (Array.isArray(init) || !init.node) { + return false + } + + return isModuleNamespace(init as NodePath, moduleName, visited) +} + +const getObjectPropertyName = (path: NodePath) => { + if (!path.isObjectProperty() || path.node.computed) { + return undefined + } + + const key = path.get('key') + if (key.isIdentifier()) { + return key.node.name + } + if (key.isStringLiteral()) { + return key.node.value + } + + return undefined +} + +const resolveDestructuredComponent = ( + bindingPath: NodePath, + localName: string, + visited: Set, +) => { + const id = bindingPath.get('id') + const init = bindingPath.get('init') + + if ( + !id.isObjectPattern() + || Array.isArray(init) + || !init.node + || !isModuleNamespace(init as NodePath, REACT_NATIVE_MODULE, visited) + ) { + return undefined + } + + for (const property of id.get('properties')) { + if (!property.isObjectProperty()) { + continue + } + + const value = property.get('value') + if (!value.isIdentifier({ name: localName })) { + continue + } + + const componentName = getObjectPropertyName(property) + if (componentName && isNativeComponentName(componentName)) { + return componentName + } + } + + return undefined +} + +const resolveComponentReference = ( + path: NodePath | null | undefined, + visited = new Set(), +): NativeComponentName | undefined => { + if (!path) { + return undefined + } + + if ( + path.isTSAsExpression() + || path.isTSTypeAssertion() + || path.isTypeCastExpression() + || path.isParenthesizedExpression() + ) { + const expression = path.get('expression') + + return Array.isArray(expression) + ? undefined + : resolveComponentReference(expression, visited) + } + + if (path.isJSXMemberExpression() || path.isMemberExpression()) { + if (path.isMemberExpression() && path.node.computed) { + return undefined + } + + const property = path.get('property') + const object = path.get('object') + if ( + !Array.isArray(property) + && (property.isIdentifier() || property.isJSXIdentifier()) + && isNativeComponentName(property.node.name) + && !Array.isArray(object) + && isModuleNamespace(object, REACT_NATIVE_MODULE, visited) + ) { + return property.node.name + } + + return undefined + } + + if (!path.isIdentifier() && !path.isJSXIdentifier()) { + return undefined + } + + const binding = path.scope.getBinding(path.node.name) + if (!binding?.constant || visited.has(binding.path.node)) { + return undefined + } + visited.add(binding.path.node) + + if (binding.path.isImportSpecifier() && getImportSource(binding.path) === REACT_NATIVE_MODULE) { + const importedName = getImportedName(binding.path) + + return importedName && isNativeComponentName(importedName) + ? importedName + : undefined + } + + if (!binding.path.isVariableDeclarator()) { + return undefined + } + + const destructuredComponent = resolveDestructuredComponent( + binding.path, + path.node.name, + visited, + ) + if (destructuredComponent) { + return destructuredComponent + } + + const init = binding.path.get('init') + + return Array.isArray(init) || !init.node + ? undefined + : resolveComponentReference(init as NodePath, visited) +} + +const isUniwindProp = (name: string) => name === 'className' || name.endsWith('ClassName') + +const isProvablyClasslessJSX = (path: NodePath) => { + for (const attribute of path.get('attributes')) { + if (attribute.isJSXSpreadAttribute()) { + return false + } + + if (!attribute.isJSXAttribute()) { + continue + } + + const name = attribute.get('name') + if (name.isJSXIdentifier() && isUniwindProp(name.node.name)) { + return false + } + } + + return true +} + +const isProvablyClasslessObject = (path: NodePath | undefined) => { + if (!path || path.isNullLiteral()) { + return true + } + + if (!path.isObjectExpression()) { + return false + } + + for (const property of path.get('properties')) { + if (property.isSpreadElement()) { + return false + } + + if (!property.isObjectMethod() && !property.isObjectProperty()) { + return false + } + + if (property.node.computed) { + return false + } + + const key = property.get('key') + if (Array.isArray(key)) { + return false + } + + const name = key.isIdentifier() + ? key.node.name + : key.isStringLiteral() + ? key.node.value + : undefined + + if (name && isUniwindProp(name)) { + return false + } + } + + return true +} + +const isReactCreateElement = (path: NodePath) => { + const callee = path.get('callee') + + if (callee.isIdentifier()) { + const binding = callee.scope.getBinding(callee.node.name) + + return binding?.constant === true + && binding.path.isImportSpecifier() + && getImportSource(binding.path) === REACT_MODULE + && getImportedName(binding.path) === 'createElement' + } + + if (!callee.isMemberExpression() || callee.node.computed) { + return false + } + + const property = callee.get('property') + const object = callee.get('object') + + return !Array.isArray(property) + && property.isIdentifier({ name: 'createElement' }) + && !Array.isArray(object) + && ( + isModuleNamespace(object, REACT_MODULE, new Set()) + || (object.isIdentifier({ name: 'React' }) && object.scope.getBinding('React') === undefined) + ) +} + +export const componentTransform = ({ types }: { types: typeof t }): PluginObj => ({ + name: 'uniwind-component-transform', + visitor: { + Program(programPath) { + const rawIdentifiers = new Map() + const getRawIdentifier = (componentName: NativeComponentName) => { + const existing = rawIdentifiers.get(componentName) + if (existing) { + return existing + } + + const identifier = programPath.scope.generateUidIdentifier(`Raw${componentName}`) + rawIdentifiers.set(componentName, identifier) + + return identifier + } + + programPath.traverse({ + JSXElement(elementPath) { + const openingElement = elementPath.get('openingElement') + if (!isProvablyClasslessJSX(openingElement)) { + return + } + + const name = openingElement.get('name') + const componentName = Array.isArray(name) + ? undefined + : resolveComponentReference(name) + if (!componentName) { + return + } + + const rawName = types.jsxIdentifier(getRawIdentifier(componentName).name) + openingElement.node.name = rawName + if (elementPath.node.closingElement) { + elementPath.node.closingElement.name = types.jsxIdentifier(rawName.name) + } + }, + CallExpression(callPath) { + if (!isReactCreateElement(callPath)) { + return + } + + const args = callPath.get('arguments') + const component = args[0] + const props = args[1] + if ( + !component + || component.isSpreadElement() + || (props?.isSpreadElement() === true) + || !isProvablyClasslessObject(props) + ) { + return + } + + const componentName = resolveComponentReference(component) + if (!componentName) { + return + } + + component.replaceWith(types.cloneNode(getRawIdentifier(componentName))) + }, + }) + + if (rawIdentifiers.size === 0) { + return + } + + const specifiers = Array.from( + rawIdentifiers, + ([componentName, local]) => + types.importSpecifier( + types.cloneNode(local), + types.identifier(componentName), + ), + ) + const declaration = types.importDeclaration( + specifiers, + types.stringLiteral(RAW_COMPONENTS_MODULE), + ) + const imports = programPath.get('body').filter(path => path.isImportDeclaration()) + const lastImport = imports.at(-1) + + if (lastImport) { + lastImport.insertAfter(declaration) + } else { + programPath.unshiftContainer('body', declaration) + } + }, + }, +}) diff --git a/packages/uniwind/src/bundler/adapters/metro/constants.ts b/packages/uniwind/src/bundler/adapters/metro/constants.ts new file mode 100644 index 00000000..88c5e0fb --- /dev/null +++ b/packages/uniwind/src/bundler/adapters/metro/constants.ts @@ -0,0 +1,32 @@ +export const RAW_COMPONENTS_MODULE = 'uniwind/.internal/raw-components' +export const UPSTREAM_BABEL_TRANSFORMER = 'uniwind_upstreamBabelTransformerPath' +export const TRANSFORM_COMPONENTS = 'uniwind_transformComponents' + +export const NATIVE_COMPONENT_NAMES = [ + 'ActivityIndicator', + 'Button', + 'FlatList', + 'Image', + 'ImageBackground', + 'InputAccessoryView', + 'KeyboardAvoidingView', + 'Modal', + 'Pressable', + 'RefreshControl', + 'SafeAreaView', + 'ScrollView', + 'SectionList', + 'Switch', + 'Text', + 'TextInput', + 'TouchableHighlight', + 'TouchableNativeFeedback', + 'TouchableOpacity', + 'TouchableWithoutFeedback', + 'View', + 'VirtualizedList', +] as const + +export type NativeComponentName = typeof NATIVE_COMPONENT_NAMES[number] + +export const NATIVE_COMPONENT_NAME_SET = new Set(NATIVE_COMPONENT_NAMES) diff --git a/packages/uniwind/src/bundler/adapters/metro/index.d.ts b/packages/uniwind/src/bundler/adapters/metro/index.d.ts index 1784bafe..4cfe3357 100644 --- a/packages/uniwind/src/bundler/adapters/metro/index.d.ts +++ b/packages/uniwind/src/bundler/adapters/metro/index.d.ts @@ -4,6 +4,14 @@ type Polyfills = { rem?: number } +type ExperimentalOptions = { + /** + * Rewrites statically classless React Native elements to raw components. + * @default false + */ + optimizeClasslessComponents?: boolean +} + type UniwindConfig = { cssEntryFile: string extraThemes?: Array @@ -11,6 +19,7 @@ type UniwindConfig = { polyfills?: Polyfills debug?: boolean isTV?: boolean + experimental?: ExperimentalOptions } export declare function withUniwindConfig(config: MetroConfig, options: UniwindConfig): MetroConfig diff --git a/packages/uniwind/src/bundler/adapters/metro/metro.ts b/packages/uniwind/src/bundler/adapters/metro/metro.ts index cfb06cff..481172c6 100644 --- a/packages/uniwind/src/bundler/adapters/metro/metro.ts +++ b/packages/uniwind/src/bundler/adapters/metro/metro.ts @@ -1,9 +1,10 @@ import { UniwindBundlerConfig } from '@/bundler/config' -import type { UniwindConfig } from '@/bundler/types' +import type { UniwindMetroConfig } from '@/bundler/types' import { Platform } from '@/common/consts' import type { MetroConfig } from 'metro-config' import type { CustomResolver } from 'metro-resolver' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { RAW_COMPONENTS_MODULE } from './constants' import { cacheStore, patchMetroGraphToSupportUncachedModules } from './patches' import { nativeResolver, webResolver } from './resolvers' @@ -23,10 +24,17 @@ const isExpoMetroConfig = (config: MetroConfig) => { export const withUniwindConfig = ( config: T, - uniwindConfig: UniwindConfig, + uniwindConfig: UniwindMetroConfig, ): T => { const bundlerConfig = UniwindBundlerConfig.fromMetroConfig(uniwindConfig) const pinnedUniwindOrigin = join(config.projectRoot ?? process.cwd(), 'package.json') + const optimizeClasslessComponents = uniwindConfig.experimental?.optimizeClasslessComponents === true + const rawComponentsPath = optimizeClasslessComponents + ? join( + dirname(require.resolve('uniwind/package.json')), + 'src/bundler/adapters/metro/raw-components.ts', + ) + : undefined patchMetroGraphToSupportUncachedModules() @@ -50,6 +58,13 @@ export const withUniwindConfig = ( resolveRequest: (context, moduleName, platform) => { const baseResolver = config.resolver?.resolveRequest ?? context.resolveRequest const resolver: CustomResolver = (nextContext, nextModuleName, nextPlatform) => { + if (nextModuleName === RAW_COMPONENTS_MODULE && rawComponentsPath) { + return { + type: 'sourceFile', + filePath: rawComponentsPath, + } + } + if (isUniwindRequest(nextModuleName)) { return baseResolver( { diff --git a/packages/uniwind/src/bundler/adapters/metro/raw-components.ts b/packages/uniwind/src/bundler/adapters/metro/raw-components.ts new file mode 100644 index 00000000..91d9af73 --- /dev/null +++ b/packages/uniwind/src/bundler/adapters/metro/raw-components.ts @@ -0,0 +1,24 @@ +export { + ActivityIndicator, + Button, + FlatList, + Image, + ImageBackground, + InputAccessoryView, + KeyboardAvoidingView, + Modal, + Pressable, + RefreshControl, + SafeAreaView, + ScrollView, + SectionList, + Switch, + Text, + TextInput, + TouchableHighlight, + TouchableNativeFeedback, + TouchableOpacity, + TouchableWithoutFeedback, + View, + VirtualizedList, +} from 'react-native' diff --git a/packages/uniwind/src/bundler/adapters/metro/resolvers.ts b/packages/uniwind/src/bundler/adapters/metro/resolvers.ts index eac99b31..f75f2d01 100644 --- a/packages/uniwind/src/bundler/adapters/metro/resolvers.ts +++ b/packages/uniwind/src/bundler/adapters/metro/resolvers.ts @@ -1,6 +1,7 @@ import type { CustomResolutionContext, CustomResolver } from 'metro-resolver' import { realpathSync } from 'node:fs' import { basename, dirname, sep } from 'node:path' +import { NATIVE_COMPONENT_NAME_SET } from './constants' type ResolverConfig = { platform: string | null @@ -40,31 +41,10 @@ const isInternalOrigin = (originModulePath: string) => { } } -const SUPPORTED_COMPONENTS = [ - 'ActivityIndicator', - 'Button', - 'FlatList', - 'Image', - 'ImageBackground', - 'InputAccessoryView', - 'KeyboardAvoidingView', - 'Modal', - 'Pressable', - 'RefreshControl', - 'SafeAreaView', - 'ScrollView', - 'SectionList', - 'Switch', - 'Text', - 'TextInput', - 'TouchableHighlight', - 'TouchableNativeFeedback', - 'TouchableOpacity', - 'TouchableWithoutFeedback', - 'View', - 'VirtualizedList', +const SUPPORTED_COMPONENTS = new Set([ + ...NATIVE_COMPONENT_NAME_SET, 'createOrderedCSSStyleSheet', -] +]) export const nativeResolver = ({ context, @@ -98,7 +78,7 @@ export const nativeResolver = ({ const filename = basename(resolution.filePath.split(sep).at(-1) ?? '') const module = filename.split('.').at(0) - if (module !== undefined && SUPPORTED_COMPONENTS.includes(module)) { + if (module !== undefined && SUPPORTED_COMPONENTS.has(module)) { return resolver(context, `uniwind/components/${module}`, platform) } } @@ -132,7 +112,7 @@ export const webResolver = ({ return resolver(context, `uniwind/components/createOrderedCSSStyleSheet`, platform) } - if (!isIndex || module === undefined || !SUPPORTED_COMPONENTS.includes(module) || context.originModulePath.endsWith(`${module}${sep}index.js`)) { + if (!isIndex || module === undefined || !SUPPORTED_COMPONENTS.has(module) || context.originModulePath.endsWith(`${module}${sep}index.js`)) { return resolution } diff --git a/packages/uniwind/src/bundler/adapters/metro/transformer.ts b/packages/uniwind/src/bundler/adapters/metro/transformer.ts index 08059b0f..6907202e 100644 --- a/packages/uniwind/src/bundler/adapters/metro/transformer.ts +++ b/packages/uniwind/src/bundler/adapters/metro/transformer.ts @@ -6,6 +6,10 @@ import type * as ExpoMetroConfig from '@expo/metro-config' import type * as MetroTransformWorker from 'metro-transform-worker' import type { JsTransformerConfig, JsTransformOptions } from 'metro-transform-worker' import path from 'path' +import { + TRANSFORM_COMPONENTS, + UPSTREAM_BABEL_TRANSFORMER, +} from './constants' const cssArtifactPath = path.resolve(__dirname, '../../uniwind.css') @@ -37,6 +41,15 @@ const getTransformWorker = (isExpoProject?: boolean): typeof MetroTransformWorke return resolvedWorker } +export const shouldTransformClasslessComponents = ( + config: Pick, + data: Buffer, + options: Pick, +) => config.experimental?.optimizeClasslessComponents === true + && options.type !== 'asset' + && options.platform !== Platform.Web + && data.includes('react-native') + export const transform = async ( config: JsTransformerConfig & { uniwind: UniwindMetroConfig @@ -62,7 +75,33 @@ export const transform = async ( } if (!isCss) { - return worker.transform(config, projectRoot, filePath, data, options) + const shouldTransformComponents = shouldTransformClasslessComponents( + config.uniwind, + data, + options, + ) + + if (!shouldTransformComponents) { + return worker.transform(config, projectRoot, filePath, data, options) + } + + return worker.transform( + { + ...config, + babelTransformerPath: require.resolve('./babel-transformer.cjs'), + }, + projectRoot, + filePath, + data, + { + ...options, + customTransformOptions: { + ...options.customTransformOptions, + [TRANSFORM_COMPONENTS]: true, + [UPSTREAM_BABEL_TRANSFORMER]: config.babelTransformerPath, + }, + }, + ) } const bundlerConfig = UniwindBundlerConfig.fromMetroConfig(config.uniwind, options.platform) diff --git a/packages/uniwind/src/bundler/types.ts b/packages/uniwind/src/bundler/types.ts index 5f7d831a..ec8b5e27 100644 --- a/packages/uniwind/src/bundler/types.ts +++ b/packages/uniwind/src/bundler/types.ts @@ -8,9 +8,14 @@ export type Polyfills = { rem?: number } +export type ExperimentalMetroOptions = { + optimizeClasslessComponents?: boolean +} + export type UniwindMetroConfig = UniwindConfig & { polyfills?: Polyfills debug?: boolean isExpoProject?: boolean isTV?: boolean + experimental?: ExperimentalMetroOptions } diff --git a/packages/uniwind/tests/native/bundler/component-transform-expo.test.ts b/packages/uniwind/tests/native/bundler/component-transform-expo.test.ts new file mode 100644 index 00000000..4ab3ac05 --- /dev/null +++ b/packages/uniwind/tests/native/bundler/component-transform-expo.test.ts @@ -0,0 +1,224 @@ +import { getDefaultConfig, unstable_transformerPath } from '@expo/metro-config' +import type * as ExpoMetroWorker from '@expo/metro-config/build/transform-worker/transform-worker' +import type { JsTransformerConfig } from '@expo/metro/metro-transform-worker' +import path from 'node:path' +import { + TRANSFORM_COMPONENTS, + UPSTREAM_BABEL_TRANSFORMER, +} from '../../../src/bundler/adapters/metro/constants' + +const PROJECT_ROOT = path.resolve(__dirname, '../../../../..') +const UNIWIND_BABEL_TRANSFORMER = require.resolve( + '../../../src/bundler/adapters/metro/babel-transformer', +) +const expoWorker = require(unstable_transformerPath) as typeof ExpoMetroWorker + +const getBabelTransformerPath = (config: { + transformer?: { + babelTransformerPath?: string + } +}) => { + const transformerPath = config.transformer?.babelTransformerPath + if (!transformerPath) { + throw new Error('Expected Metro config to provide a Babel transformer') + } + + return transformerPath +} + +const upstreamTransformers = [ + { + name: 'Expo', + path: getBabelTransformerPath(getDefaultConfig(PROJECT_ROOT)), + }, + { + name: 'Expo through Sentry-style delegation', + path: require.resolve( + './fixtures/sentry-babel-transformer.cjs', + ), + }, +] + +const transform = async ( + source: string, + { + filename, + reactCompiler, + upstreamTransformerPath, + }: { + filename: string + reactCompiler: boolean + upstreamTransformerPath: string + }, +) => { + const transformerConfig = { + ...getDefaultConfig(PROJECT_ROOT).transformer as JsTransformerConfig, + babelTransformerPath: UNIWIND_BABEL_TRANSFORMER, + } + const result = await expoWorker.transform( + transformerConfig, + PROJECT_ROOT, + filename, + Buffer.from(source), + { + customTransformOptions: { + engine: 'hermes', + reactCompiler, + [TRANSFORM_COMPONENTS]: true, + [UPSTREAM_BABEL_TRANSFORMER]: upstreamTransformerPath, + }, + dev: true, + experimentalImportSupport: true, + inlinePlatform: true, + inlineRequires: false, + minify: false, + nonInlinedRequires: [], + platform: 'ios', + type: 'module', + unstable_transformProfile: 'hermes-stable', + }, + ) + + const output = result.output[0] + if (!output) { + throw new Error('Expected Metro to produce JavaScript output') + } + + return output.data.code +} + +const countRawComponentReferences = (code: string, componentName: string) => + code.match( + new RegExp( + `_uniwindInternalRawComponents\\d*\\.${componentName}\\b`, + 'g', + ), + )?.length ?? 0 + +const malformedRawComponentPattern = /createElement\(\s*_uniwindInternalRawComponents\d*\s*,/ + +const fixtures = [ + { + name: 'Legend List ESM alias shape', + filename: 'react-native.mjs', + hasStyledAlias: true, + source: ` + import * as React2 from "react"; + import { + Text as Text$1, + View as View$1, + } from "react-native"; + + var View = View$1; + var Text = Text$1; + + export function Fixture({ nested }) { + return React2.createElement( + View, + { className: "p-4" }, + React2.createElement( + View, + null, + React2.createElement(Text, null, "First"), + ), + nested + && React2.createElement( + View$1, + null, + React2.createElement(Text$1, null, "Second"), + ), + ); + } + `, + }, + { + name: 'compiled CommonJS namespace shape', + filename: 'compiled.js', + hasStyledAlias: false, + source: ` + const React = require("react"); + const ReactNative = require("react-native"); + + export function Fixture() { + return React.createElement( + ReactNative.View, + null, + React.createElement(ReactNative.Text, null, "First"), + React.createElement( + ReactNative.View, + null, + React.createElement(ReactNative.Text, null, "Second"), + ), + ); + } + `, + }, +] + +describe.each(upstreamTransformers)( + '$name component transform pipeline', + ({ path: upstreamTransformerPath }) => { + describe.each([false, true])( + 'with React Compiler $reactCompiler', + reactCompiler => { + test.each(fixtures)( + 'keeps every raw component reference callable for $name', + async ({ filename, hasStyledAlias, source }) => { + const code = await transform(source, { + filename: path.join( + PROJECT_ROOT, + 'node_modules', + '.uniwind-transform-fixtures', + filename, + ), + reactCompiler, + upstreamTransformerPath, + }) + + expect(code).not.toMatch(malformedRawComponentPattern) + expect(countRawComponentReferences(code, 'View')).toBe(2) + expect(countRawComponentReferences(code, 'Text')).toBe(2) + if (hasStyledAlias) { + expect(code).toMatch( + /createElement\(\s*View\s*,\s*\{\s*className:/, + ) + } + }, + ) + + test('keeps className elements on the wrapped component path', async () => { + const code = await transform( + ` + import * as React from "react"; + import { View } from "react-native"; + + export function Fixture() { + return React.createElement( + View, + { className: "p-4" }, + React.createElement(View, null), + ); + } + `, + { + filename: path.join( + PROJECT_ROOT, + 'node_modules', + '.uniwind-transform-fixtures', + 'class-name.js', + ), + reactCompiler, + upstreamTransformerPath, + }, + ) + + expect(code).not.toMatch(malformedRawComponentPattern) + expect(countRawComponentReferences(code, 'View')).toBe(1) + expect(code).toMatch( + /createElement\(\s*_reactNative\d*\.View\s*,\s*\{\s*className:/, + ) + }) + }, + ) + }, +) diff --git a/packages/uniwind/tests/native/bundler/component-transform.test.ts b/packages/uniwind/tests/native/bundler/component-transform.test.ts new file mode 100644 index 00000000..db4bb806 --- /dev/null +++ b/packages/uniwind/tests/native/bundler/component-transform.test.ts @@ -0,0 +1,352 @@ +import { transformSync, traverse } from '@babel/core' +import { componentTransform } from '../../../src/bundler/adapters/metro/component-transform' +import { + NATIVE_COMPONENT_NAMES, + type NativeComponentName, + RAW_COMPONENTS_MODULE, +} from '../../../src/bundler/adapters/metro/constants' +import * as rawComponents from '../../../src/bundler/adapters/metro/raw-components' +import { shouldTransformClasslessComponents } from '../../../src/bundler/adapters/metro/transformer' + +const CLASS_PROPS_BY_COMPONENT = { + ActivityIndicator: ['className', 'colorClassName'], + Button: ['colorClassName'], + FlatList: [ + 'className', + 'columnWrapperClassName', + 'contentContainerClassName', + 'ListFooterComponentClassName', + 'ListHeaderComponentClassName', + 'endFillColorClassName', + ], + Image: ['className', 'tintColorClassName'], + ImageBackground: ['className', 'imageClassName', 'tintColorClassName'], + InputAccessoryView: ['className', 'backgroundColorClassName'], + KeyboardAvoidingView: ['className', 'contentContainerClassName'], + Modal: ['className', 'backdropColorClassName'], + Pressable: ['className'], + RefreshControl: [ + 'className', + 'colorsClassName', + 'tintColorClassName', + 'titleColorClassName', + 'progressBackgroundColorClassName', + ], + SafeAreaView: ['className'], + ScrollView: ['className', 'contentContainerClassName', 'endFillColorClassName'], + SectionList: [ + 'className', + 'contentContainerClassName', + 'ListFooterComponentClassName', + 'ListHeaderComponentClassName', + 'endFillColorClassName', + ], + Switch: [ + 'className', + 'trackColorOnClassName', + 'trackColorOffClassName', + 'thumbColorClassName', + 'ios_backgroundColorClassName', + ], + Text: ['className', 'selectionColorClassName'], + TextInput: [ + 'className', + 'cursorColorClassName', + 'selectionColorClassName', + 'placeholderTextColorClassName', + 'selectionHandleColorClassName', + 'underlineColorAndroidClassName', + ], + TouchableHighlight: ['className', 'underlayColorClassName'], + TouchableNativeFeedback: ['className'], + TouchableOpacity: ['className'], + TouchableWithoutFeedback: ['className'], + View: ['className'], + VirtualizedList: [ + 'className', + 'contentContainerClassName', + 'ListFooterComponentClassName', + 'ListHeaderComponentClassName', + 'endFillColorClassName', + ], +} as const satisfies Record> + +const runTransform = (source: string) => { + const result = transformSync(source, { + ast: true, + babelrc: false, + configFile: false, + filename: 'Component.tsx', + parserOpts: { + plugins: ['jsx', 'typescript'], + }, + plugins: [componentTransform], + }) + + if (!result?.ast || !result.code) { + throw new Error('Expected Babel to produce an AST and code') + } + + return { + ast: result.ast, + code: result.code, + } +} + +const transform = (source: string) => runTransform(source).code + +describe.each(NATIVE_COMPONENT_NAMES)('%s compile-time dispatch', componentName => { + test('is exported by the private raw-component module', () => { + expect(rawComponents[componentName]).toBeDefined() + }) + + test('rewrites statically classless JSX to the raw component', () => { + const code = transform(` + import { ${componentName} } from 'react-native' + + export const Component = () => <${componentName} testID="component" /> + `) + + expect(code).toContain(`from "${RAW_COMPONENTS_MODULE}"`) + expect(code).toContain(`${componentName} as _Raw${componentName}`) + expect(code).toContain(`<_Raw${componentName} testID="component" />`) + }) + + test.each(CLASS_PROPS_BY_COMPONENT[componentName])( + 'keeps %s on the Uniwind wrapper path', + classProp => { + const code = transform(` + import { ${componentName} } from 'react-native' + + export const Component = () => <${componentName} ${classProp}="test" /> + `) + + expect(code).not.toContain(RAW_COMPONENTS_MODULE) + expect(code).toContain(`<${componentName} ${classProp}="test" />`) + }, + ) + + test('keeps prop spreads on the Uniwind wrapper path', () => { + const code = transform(` + import { ${componentName} } from 'react-native' + + export const Component = props => <${componentName} {...props} /> + `) + + expect(code).not.toContain(RAW_COMPONENTS_MODULE) + expect(code).toContain(`<${componentName} {...props} />`) + }) +}) + +test('uses raw components only for provably classless JSX', () => { + const code = transform(` + import { Text, View } from 'react-native' + + export const Component = () => ( + + Styled + Raw + + ) + `) + + expect(code).toContain(`from "${RAW_COMPONENTS_MODULE}"`) + expect(code).toMatch(/import \{ View as _RawView, Text as _RawText \}/) + expect(code).toContain('<_RawView') + expect(code).toContain('') + expect(code).toContain('<_RawText style=') +}) + +test('keeps elements with spreads on the wrapped component path', () => { + const code = transform(` + import { View } from 'react-native' + + export const Component = (props) => ( + <> + + + + ) + `) + + expect(code).toContain('') + expect(code).toContain('<_RawView style={props.style} />') +}) + +test('supports namespace imports and constant aliases', () => { + const code = transform(` + import * as RN from 'react-native' + import { View } from 'react-native' + + const Alias = View + + export const Component = () => ( + <> + + + + ) + `) + + expect(code).toMatch(/import \{ Text as _RawText, View as _RawView \}/) + expect(code).toContain('<_RawText />') + expect(code).toContain('<_RawView />') +}) + +test('supports CommonJS destructuring', () => { + const code = transform(` + const { Text: Label, View } = require('react-native') + + export const Component = () => ( + + + ) + `) + + expect(code).toMatch(/import \{ View as _RawView, Text as _RawText \}/) + expect(code).toContain('<_RawView>') + expect(code).toContain('<_RawText />') +}) + +test('optimizes only createElement calls with static classless props', () => { + const code = transform(` + import React, { createElement } from 'react' + import { View } from 'react-native' + + export const raw = React.createElement(View, { style: { flex: 1 } }) + export const rawNamed = createElement(View, null) + export const styled = React.createElement(View, { className: 'flex-1' }) + export const dynamic = React.createElement(View, props) + `) + + expect(code).toContain('React.createElement(_RawView, {') + expect(code).toContain('createElement(_RawView, null)') + expect(code).toContain('React.createElement(View, {\n className: \'flex-1\'') + expect(code).toContain('React.createElement(View, props)') +}) + +test('creates an independent identifier node for every raw component reference', () => { + const result = runTransform(` + import React from 'react' + import { View } from 'react-native' + + export const Component = () => React.createElement( + View, + null, + React.createElement(View, null), + ) + `) + const rawImport = result.ast.program.body.find( + node => + node.type === 'ImportDeclaration' + && node.source.value === RAW_COMPONENTS_MODULE, + ) + if (!rawImport || rawImport.type !== 'ImportDeclaration') { + throw new Error('Expected a raw component import') + } + + const rawSpecifier = rawImport.specifiers.find( + specifier => + specifier.type === 'ImportSpecifier' + && specifier.imported.type === 'Identifier' + && specifier.imported.name === 'View', + ) + if (!rawSpecifier) { + throw new Error('Expected a raw View import') + } + + const rawReferences: object[] = [] + traverse(result.ast, { + CallExpression(path) { + const component = path.node.arguments[0] + if ( + component?.type === 'Identifier' + && component.name === rawSpecifier.local.name + ) { + rawReferences.push(component) + } + }, + }) + + expect(rawReferences).toHaveLength(2) + expect(rawReferences[0]).not.toBe(rawReferences[1]) + expect(rawReferences).not.toContain(rawSpecifier.local) +}) + +test('does not optimize mutable component aliases', () => { + const code = transform(` + import React from 'react' + import { View } from 'react-native' + + let Alias = View + Alias = CustomView + + export const Component = () => React.createElement(Alias, null) + `) + + expect(code).not.toContain(RAW_COMPONENTS_MODULE) + expect(code).toContain('React.createElement(Alias, null)') +}) + +test('does not rewrite unrelated components', () => { + const code = transform(` + import { View as CustomView } from './components' + + export const Component = () => + `) + + expect(code).not.toContain(RAW_COMPONENTS_MODULE) + expect(code).toContain(' { + const code = transform(` + import { StatusBar } from 'react-native' + + export const Component = () => + `) + + expect(code).not.toContain(RAW_COMPONENTS_MODULE) + expect(code).toContain('') +}) + +test('requires the Metro experimental option', () => { + const source = Buffer.from(`import { View } from 'react-native'`) + const nativeOptions = { + platform: 'android', + type: 'module' as const, + } + + expect(shouldTransformClasslessComponents({}, source, nativeOptions)).toBe(false) + expect(shouldTransformClasslessComponents( + { + experimental: { + optimizeClasslessComponents: false, + }, + }, + source, + nativeOptions, + )).toBe(false) + expect(shouldTransformClasslessComponents( + { + experimental: { + optimizeClasslessComponents: true, + }, + }, + source, + nativeOptions, + )).toBe(true) + expect(shouldTransformClasslessComponents( + { + experimental: { + optimizeClasslessComponents: true, + }, + }, + source, + { + ...nativeOptions, + platform: 'web', + }, + )).toBe(false) +}) diff --git a/packages/uniwind/tests/native/bundler/fixtures/sentry-babel-transformer.cjs b/packages/uniwind/tests/native/bundler/fixtures/sentry-babel-transformer.cjs new file mode 100644 index 00000000..cb67e51b --- /dev/null +++ b/packages/uniwind/tests/native/bundler/fixtures/sentry-babel-transformer.cjs @@ -0,0 +1,20 @@ +const expoTransformer = require('@expo/metro-config/build/babel-transformer') + +const componentAnnotatePlugin = () => ({ + name: 'sentry-component-annotate-fixture', + visitor: {}, +}) + +module.exports = { + ...expoTransformer, + transform(args) { + if ( + !args.filename.includes('node_modules') + && Array.isArray(args.plugins) + ) { + args.plugins.push(componentAnnotatePlugin) + } + + return expoTransformer.transform(args) + }, +}