From 62de83acdf9cff60dbe96e3701a251d7add6a597 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Wed, 24 Jun 2026 14:22:21 +0200 Subject: [PATCH 1/8] feat: unistyles v3 support --- .../react-native-boost/src/plugin/index.ts | 27 ++++- .../unistyles-aliased-style/code.js | 5 + .../unistyles-aliased-style/output.js | 17 ++++ .../unistyles-imported-style-bails/code.js | 3 + .../unistyles-imported-style-bails/output.js | 3 + .../unistyles-literal-style/code.js | 2 + .../unistyles-literal-style/output.js | 14 +++ .../unistyles-no-style/code.js | 2 + .../unistyles-no-style/output.js | 8 ++ .../unistyles-plain-rn-style/code.js | 3 + .../unistyles-plain-rn-style/output.js | 18 ++++ .../unistyles-style-array/code.js | 4 + .../unistyles-style-array/output.js | 21 ++++ .../unistyles-style/code.js | 4 + .../unistyles-style/output.js | 16 +++ .../unistyles-unknown-style-bails/code.js | 2 + .../unistyles-unknown-style-bails/output.js | 2 + .../optimizers/text/__tests__/index.test.ts | 10 ++ .../src/plugin/optimizers/text/index.ts | 62 ++++++++++-- .../unistyles-imported-style-bails/code.js | 3 + .../unistyles-imported-style-bails/output.js | 3 + .../unistyles-no-style/code.js | 2 + .../unistyles-no-style/output.js | 3 + .../unistyles-plain-rn-style/code.js | 3 + .../unistyles-plain-rn-style/output.js | 8 ++ .../code.js | 5 + .../output.js | 11 +++ .../unistyles-style/code.js | 4 + .../unistyles-style/output.js | 9 ++ .../unistyles-unknown-style-bails/code.js | 2 + .../unistyles-unknown-style-bails/output.js | 2 + .../optimizers/view/__tests__/index.test.ts | 10 ++ .../src/plugin/optimizers/view/index.ts | 37 ++++++- .../src/plugin/types/index.ts | 25 ++++- .../src/plugin/utils/common/base.ts | 29 ++++-- .../src/plugin/utils/common/validation.ts | 99 +++++++++++++++++++ .../src/plugin/utils/constants.ts | 16 +++ .../src/plugin/utils/generate-test-plugin.ts | 4 +- .../src/plugin/utils/unistyles.ts | 23 +++++ 39 files changed, 494 insertions(+), 27 deletions(-) create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-aliased-style/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-aliased-style/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-imported-style-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-imported-style-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-literal-style/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-literal-style/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-no-style/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-no-style/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-plain-rn-style/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-plain-rn-style/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style-array/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style-array/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-imported-style-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-imported-style-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-no-style/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-no-style/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-plain-rn-style/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-plain-rn-style/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style-in-resolvable-spread-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style-in-resolvable-spread-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style/output.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/code.js create mode 100644 packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/output.js create mode 100644 packages/react-native-boost/src/plugin/utils/unistyles.ts diff --git a/packages/react-native-boost/src/plugin/index.ts b/packages/react-native-boost/src/plugin/index.ts index eeb4e62..c9b59d3 100644 --- a/packages/react-native-boost/src/plugin/index.ts +++ b/packages/react-native-boost/src/plugin/index.ts @@ -4,6 +4,7 @@ import { PluginLogger, PluginOptions } from './types'; import { createLogger } from './utils/logger'; import { viewOptimizer } from './optimizers/view'; import { isIgnoredFile } from './utils/common'; +import { isUnistylesInstalled } from './utils/unistyles'; export type { PluginOptimizationOptions, PluginOptions } from './types'; @@ -12,24 +13,42 @@ type PluginState = { __reactNativeBoostLogger?: PluginLogger; }; -export default declare((api) => { +export default declare((api, rawOptions, dirname?: string) => { api.assertVersion(7); + const options = (rawOptions ?? {}) as PluginOptions; + // Target platform, resolved at build time. Metro sets this on the Babel caller per platform bundle, // letting optimizers inline platform-specific defaults instead of deferring them to the runtime. const platform = api.caller((caller) => (caller as { platform?: string } | undefined)?.platform); + // Resolve "Unistyles mode" once per plugin instance. An explicit `unistyles` flag always wins; + // otherwise auto-detect an installed `react-native-unistyles` and, when found, enable the mode but + // hint (once) to set the flag explicitly — a detected package does not prove its Babel plugin is active. + const autoDetectedUnistyles = options.unistyles === undefined && isUnistylesInstalled(dirname); + const unistylesEnabled = options.unistyles === true || autoDetectedUnistyles; + let unistylesHintLogged = false; + return { name: 'react-native-boost', visitor: { JSXOpeningElement(path, state) { const pluginState = state as PluginState; - const options = (pluginState.opts ?? {}) as PluginOptions; const logger = getOrCreateLogger(pluginState, options); + if (autoDetectedUnistyles && !unistylesHintLogged) { + unistylesHintLogged = true; + logger.warning({ + message: + 'react-native-unistyles was detected, so Unistyles mode was enabled automatically. Set ' + + '`unistyles: true` in the react-native-boost plugin options to make this explicit, or ' + + '`unistyles: false` to opt out.', + }); + } + if (isIgnoredFile(path, options.ignores ?? [])) return; - if (options.optimizations?.text !== false) textOptimizer(path, logger, options, platform); - if (options.optimizations?.view !== false) viewOptimizer(path, logger, options); + if (options.optimizations?.text !== false) textOptimizer(path, logger, options, platform, unistylesEnabled); + if (options.optimizations?.view !== false) viewOptimizer(path, logger, options, platform, unistylesEnabled); }, }, }; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-aliased-style/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-aliased-style/code.js new file mode 100644 index 0000000..5b0444d --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-aliased-style/code.js @@ -0,0 +1,5 @@ +import { Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ text: { color: 'red' } }); +const textStyle = styles.text; +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-aliased-style/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-aliased-style/output.js new file mode 100644 index 0000000..dd7fd17 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-aliased-style/output.js @@ -0,0 +1,17 @@ +import { NativeText as _UnistylesNativeText } from 'react-native-unistyles/components/native/NativeText'; +import { getDefaultTextAccessible as _getDefaultTextAccessible } from 'react-native-boost/runtime'; +import { Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ + text: { + color: 'red', + }, +}); +const textStyle = styles.text; +<_UnistylesNativeText + style={textStyle} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + Hello +; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-imported-style-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-imported-style-bails/code.js new file mode 100644 index 0000000..e243bf5 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-imported-style-bails/code.js @@ -0,0 +1,3 @@ +import { Text } from 'react-native'; +import { styles } from './styles'; +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-imported-style-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-imported-style-bails/output.js new file mode 100644 index 0000000..e243bf5 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-imported-style-bails/output.js @@ -0,0 +1,3 @@ +import { Text } from 'react-native'; +import { styles } from './styles'; +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-literal-style/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-literal-style/code.js new file mode 100644 index 0000000..f543f26 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-literal-style/code.js @@ -0,0 +1,2 @@ +import { Text } from 'react-native'; +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-literal-style/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-literal-style/output.js new file mode 100644 index 0000000..3b99e62 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-literal-style/output.js @@ -0,0 +1,14 @@ +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; +import { Text } from 'react-native'; +<_NativeText + style={{ + color: 'red', + }} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + Hello +; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-no-style/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-no-style/code.js new file mode 100644 index 0000000..c81e1cc --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-no-style/code.js @@ -0,0 +1,2 @@ +import { Text } from 'react-native'; +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-no-style/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-no-style/output.js new file mode 100644 index 0000000..4ab57f8 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-no-style/output.js @@ -0,0 +1,8 @@ +import { + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; +import { Text } from 'react-native'; +<_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> + Hello +; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-plain-rn-style/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-plain-rn-style/code.js new file mode 100644 index 0000000..1a3b4ee --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-plain-rn-style/code.js @@ -0,0 +1,3 @@ +import { Text, StyleSheet } from 'react-native'; +const styles = StyleSheet.create({ text: { color: 'red' } }); +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-plain-rn-style/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-plain-rn-style/output.js new file mode 100644 index 0000000..c4f7278 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-plain-rn-style/output.js @@ -0,0 +1,18 @@ +import { + processTextStyle as _processTextStyle, + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; +import { Text, StyleSheet } from 'react-native'; +const styles = StyleSheet.create({ + text: { + color: 'red', + }, +}); +<_NativeText + allowFontScaling={true} + ellipsizeMode={'tail'} + {..._processTextStyle(styles.text)} + accessible={_getDefaultTextAccessible()}> + Hello +; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style-array/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style-array/code.js new file mode 100644 index 0000000..d4d59f7 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style-array/code.js @@ -0,0 +1,4 @@ +import { Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ text: { color: 'red' } }); +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style-array/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style-array/output.js new file mode 100644 index 0000000..69d854a --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style-array/output.js @@ -0,0 +1,21 @@ +import { NativeText as _UnistylesNativeText } from 'react-native-unistyles/components/native/NativeText'; +import { getDefaultTextAccessible as _getDefaultTextAccessible } from 'react-native-boost/runtime'; +import { Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ + text: { + color: 'red', + }, +}); +<_UnistylesNativeText + style={[ + styles.text, + { + margin: 4, + }, + ]} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + Hello +; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style/code.js new file mode 100644 index 0000000..7b051d0 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style/code.js @@ -0,0 +1,4 @@ +import { Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ text: { color: 'red' } }); +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style/output.js new file mode 100644 index 0000000..788859f --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-style/output.js @@ -0,0 +1,16 @@ +import { NativeText as _UnistylesNativeText } from 'react-native-unistyles/components/native/NativeText'; +import { getDefaultTextAccessible as _getDefaultTextAccessible } from 'react-native-boost/runtime'; +import { Text } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ + text: { + color: 'red', + }, +}); +<_UnistylesNativeText + style={styles.text} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + Hello +; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/code.js new file mode 100644 index 0000000..58d559e --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/code.js @@ -0,0 +1,2 @@ +import { Text } from 'react-native'; +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/output.js new file mode 100644 index 0000000..58d559e --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/output.js @@ -0,0 +1,2 @@ +import { Text } from 'react-native'; +Hello; diff --git a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/index.test.ts b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/index.test.ts index 7b9276e..6a2d58b 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/__tests__/index.test.ts +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/index.test.ts @@ -41,3 +41,13 @@ pluginTester({ outputFixture: path.resolve(import.meta.dirname, `fixtures/${name}/dangerous-output.js`), })), }); + +pluginTester({ + plugin: generateTestPlugin(textOptimizer, { unistyles: true }), + title: 'text unistyles', + fixtures: path.resolve(import.meta.dirname, 'fixtures-unistyles'), + babelOptions: { + plugins: ['@babel/plugin-syntax-jsx'], + }, + formatResult: formatTestResult, +}); diff --git a/packages/react-native-boost/src/plugin/optimizers/text/index.ts b/packages/react-native-boost/src/plugin/optimizers/text/index.ts index 6543208..2b8b1c9 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/text/index.ts @@ -23,8 +23,10 @@ import { extractSelectableAndUpdateStyle, tryBuildStaticTextStyle, ancestorBailoutChecks, + classifyStyleOrigin, + StyleOrigin, } from '../../utils/common'; -import { ACCESSIBILITY_PROPERTIES, RUNTIME_MODULE_NAME } from '../../utils/constants'; +import { ACCESSIBILITY_PROPERTIES, RUNTIME_MODULE_NAME, UNISTYLES_NATIVE_TEXT_MODULE } from '../../utils/constants'; export const textBlacklistedProperties = new Set([ 'onLongPress', @@ -82,13 +84,21 @@ const TEXT_SPREAD_GUARD_KEYS = new Set([ const isNormalizedProperty = (attribute: t.JSXAttribute | t.JSXSpreadAttribute): attribute is t.JSXAttribute => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && NORMALIZED_PROPERTIES.has(attribute.name.name); -export const textOptimizer: Optimizer = (path, logger, options, platform) => { +export const textOptimizer: Optimizer = (path, logger, options, platform, unistylesEnabled) => { if (!isValidJSXComponent(path, 'Text')) return; if (!isReactNativeImport(path, 'Text')) return; const parent = path.parent as t.JSXElement; const forced = isForcedLine(path); + // In Unistyles mode, classify the direct `style` origin (lazily, once). A `style` carried by a spread + // is already guarded (`style` ∈ TEXT_SPREAD_GUARD_KEYS), so only the direct attribute is classified. + let styleOrigin: StyleOrigin | undefined; + const getStyleOrigin = (): StyleOrigin => { + if (!unistylesEnabled) return 'plain'; + return (styleOrigin ??= classifyStyleOrigin(path, extractStyleAttribute(path.node.attributes).styleExpr)); + }; + const overridableChecks: BailoutCheck[] = [ { reason: 'contains blacklisted props', @@ -98,6 +108,10 @@ export const textOptimizer: Optimizer = (path, logger, options, platform) => { reason: 'has a spread that may carry a translated, normalized, or clamped prop', shouldBail: () => hasBlacklistedPropertyInSpread(path, TEXT_SPREAD_GUARD_KEYS), }, + { + reason: 'has an unresolved style source that may be a Unistyles style', + shouldBail: () => getStyleOrigin() === 'unknown', + }, { reason: 'has both a dynamic `id` and a `nativeID` (ambiguous precedence)', shouldBail: () => hasAmbiguousIdNativeID(path), @@ -148,15 +162,27 @@ export const textOptimizer: Optimizer = (path, logger, options, platform) => { path, }); + const routeToUnistyles = getStyleOrigin() === 'unistyles'; + // Process props fixNegativeNumberOfLines({ path, logger, file }); renameIdToNativeID(path); addDefaultProperty(path, 'allowFontScaling', t.booleanLiteral(true)); addDefaultProperty(path, 'ellipsizeMode', t.stringLiteral('tail')); - processProps(path, file, platform); - - // Replace the Text component with NativeText - replaceWithNativeComponent(path, parent, file, 'NativeText'); + processProps(path, file, platform, routeToUnistyles); + + // A Unistyles-styled Text routes to Unistyles' lean host (a registering wrapper around `RCTText`); its + // `style` is passed by identity (see `processProps`) so the Unistyles native-state — and therefore the + // shadow-tree registration — survives. Plain text optimizes to Boost's own raw host as usual. + if (routeToUnistyles) { + replaceWithNativeComponent(path, parent, file, 'NativeText', { + moduleName: UNISTYLES_NATIVE_TEXT_MODULE, + importName: 'NativeText', + nameHint: 'UnistylesNativeText', + }); + } else { + replaceWithNativeComponent(path, parent, file, 'NativeText'); + } }; /** @@ -252,8 +278,21 @@ function staticNumberOfLines(expression: t.Expression): number | undefined { /** * Processes style and accessibility attributes, replacing them with optimized versions. + * + * When `passStyleByIdentity` is set (Unistyles routing), the `style` attribute is left exactly as + * written instead of being flattened/normalized through `processTextStyle` — flattening would strip the + * Unistyles native-state the engine needs. Accessibility, `selectionColor`, and the `accessible` default + * are still applied (they do not touch `style`). The skipped text normalizations (numeric `fontWeight`, + * `verticalAlign`, `userSelect` → `selectable`) cannot be reproduced safely here: for a reactive style + * Unistyles' C++ engine re-commits the raw parsed values on every update, overwriting any JS-side + * normalization, so matching Unistyles' own lean-host semantics (raw pass-through) is the correct contract. */ -function processProps(path: NodePath, file: HubFile, platform?: string) { +function processProps( + path: NodePath, + file: HubFile, + platform?: string, + passStyleByIdentity = false +) { // Grab the up-to-date list of attributes const currentAttributes = [...path.node.attributes]; @@ -300,7 +339,9 @@ function processProps(path: NodePath, file: HubFile, platfo let selectableAttribute: t.JSXAttribute | undefined; let staticStyleAttribute: t.JSXAttribute | undefined; let styleSpread: t.JSXSpreadAttribute | undefined; - if (styleExpr) { + // `passStyleByIdentity` (Unistyles routing) skips all style transforms — the original `style` + // attribute is left untouched in the collected attributes below so it reaches the native host intact. + if (styleExpr && !passStyleByIdentity) { const selectableValue = extractSelectableAndUpdateStyle(styleExpr); if (selectableValue != null) { @@ -357,8 +398,9 @@ function processProps(path: NodePath, file: HubFile, platfo const remainingAttributes: (t.JSXAttribute | t.JSXSpreadAttribute)[] = []; for (const attribute of currentAttributes) { - // Skip the style attribute (we have replaced it with a `style` object or `processTextStyle` spread) - if (styleAttribute && attribute === styleAttribute) continue; + // Skip the style attribute (we have replaced it with a `style` object or `processTextStyle` spread). + // When passing the style by identity (Unistyles routing) it is retained here, untouched. + if (!passStyleByIdentity && styleAttribute && attribute === styleAttribute) continue; // Skip the props we routed through `processAccessibilityProps` if (shouldNormalize && isNormalizedProperty(attribute)) continue; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-imported-style-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-imported-style-bails/code.js new file mode 100644 index 0000000..b0f8159 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-imported-style-bails/code.js @@ -0,0 +1,3 @@ +import { View } from 'react-native'; +import { styles } from './styles'; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-imported-style-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-imported-style-bails/output.js new file mode 100644 index 0000000..b0f8159 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-imported-style-bails/output.js @@ -0,0 +1,3 @@ +import { View } from 'react-native'; +import { styles } from './styles'; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-no-style/code.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-no-style/code.js new file mode 100644 index 0000000..39f2025 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-no-style/code.js @@ -0,0 +1,2 @@ +import { View } from 'react-native'; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-no-style/output.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-no-style/output.js new file mode 100644 index 0000000..a027ffa --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-no-style/output.js @@ -0,0 +1,3 @@ +import { NativeView as _NativeView } from 'react-native-boost/runtime'; +import { View } from 'react-native'; +<_NativeView />; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-plain-rn-style/code.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-plain-rn-style/code.js new file mode 100644 index 0000000..98ba426 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-plain-rn-style/code.js @@ -0,0 +1,3 @@ +import { View, StyleSheet } from 'react-native'; +const styles = StyleSheet.create({ box: { flex: 1 } }); +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-plain-rn-style/output.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-plain-rn-style/output.js new file mode 100644 index 0000000..14f7d24 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-plain-rn-style/output.js @@ -0,0 +1,8 @@ +import { NativeView as _NativeView } from 'react-native-boost/runtime'; +import { View, StyleSheet } from 'react-native'; +const styles = StyleSheet.create({ + box: { + flex: 1, + }, +}); +<_NativeView style={styles.box} />; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style-in-resolvable-spread-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style-in-resolvable-spread-bails/code.js new file mode 100644 index 0000000..ffcf385 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style-in-resolvable-spread-bails/code.js @@ -0,0 +1,5 @@ +import { View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ box: { flex: 1 } }); +const extra = { style: styles.box }; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style-in-resolvable-spread-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style-in-resolvable-spread-bails/output.js new file mode 100644 index 0000000..69d9936 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style-in-resolvable-spread-bails/output.js @@ -0,0 +1,11 @@ +import { View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ + box: { + flex: 1, + }, +}); +const extra = { + style: styles.box, +}; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style/code.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style/code.js new file mode 100644 index 0000000..2351730 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style/code.js @@ -0,0 +1,4 @@ +import { View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ box: { flex: 1 } }); +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style/output.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style/output.js new file mode 100644 index 0000000..1bd07b4 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-style/output.js @@ -0,0 +1,9 @@ +import _UnistylesNativeView from 'react-native-unistyles/components/native/NativeView'; +import { View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ + box: { + flex: 1, + }, +}); +<_UnistylesNativeView style={styles.box} />; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/code.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/code.js new file mode 100644 index 0000000..210ccdb --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/code.js @@ -0,0 +1,2 @@ +import { View } from 'react-native'; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/output.js b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/output.js new file mode 100644 index 0000000..210ccdb --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/fixtures-unistyles/unistyles-unknown-style-bails/output.js @@ -0,0 +1,2 @@ +import { View } from 'react-native'; +; diff --git a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/index.test.ts b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/index.test.ts index 33da878..7d303a9 100644 --- a/packages/react-native-boost/src/plugin/optimizers/view/__tests__/index.test.ts +++ b/packages/react-native-boost/src/plugin/optimizers/view/__tests__/index.test.ts @@ -31,3 +31,13 @@ pluginTester({ }, ], }); + +pluginTester({ + plugin: generateTestPlugin(viewOptimizer, { unistyles: true }), + title: 'view unistyles', + fixtures: path.resolve(import.meta.dirname, 'fixtures-unistyles'), + babelOptions: { + plugins: ['@babel/plugin-syntax-jsx'], + }, + formatResult: formatTestResult, +}); diff --git a/packages/react-native-boost/src/plugin/optimizers/view/index.ts b/packages/react-native-boost/src/plugin/optimizers/view/index.ts index 683f470..af3c3d0 100644 --- a/packages/react-native-boost/src/plugin/optimizers/view/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/view/index.ts @@ -13,8 +13,11 @@ import { isReactNativeImport, replaceWithNativeComponent, ancestorBailoutChecks, + extractStyleAttribute, + classifyStyleOrigin, + StyleOrigin, } from '../../utils/common'; -import { RUNTIME_MODULE_NAME } from '../../utils/constants'; +import { RUNTIME_MODULE_NAME, UNISTYLES_NATIVE_VIEW_MODULE } from '../../utils/constants'; /** * Props the `View` wrapper destructures and transforms before handing off to its native host. The @@ -49,16 +52,31 @@ const VIEW_SPREAD_GUARD_KEYS = new Set([ const ARIA_STATE_PROPERTIES = new Set(['aria-busy', 'aria-checked', 'aria-disabled', 'aria-expanded', 'aria-selected']); const ARIA_VALUE_PROPERTIES = new Set(['aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext']); -export const viewOptimizer: Optimizer = (path, logger, options) => { +export const viewOptimizer: Optimizer = (path, logger, options, _platform, unistylesEnabled) => { if (!isValidJSXComponent(path, 'View')) return; if (!isReactNativeImport(path, 'View')) return; const forced = isForcedLine(path); + // In Unistyles mode, classify the direct `style` origin (lazily, once). A `style` carried by a + // resolvable spread is guarded too (`style` is added to the spread keys below); an unresolvable spread + // already bails. See {@link classifyStyleOrigin}. + let styleOrigin: StyleOrigin | undefined; + const getStyleOrigin = (): StyleOrigin => { + if (!unistylesEnabled) return 'plain'; + return (styleOrigin ??= classifyStyleOrigin(path, extractStyleAttribute(path.node.attributes).styleExpr)); + }; + + const spreadGuardKeys = unistylesEnabled ? new Set([...VIEW_SPREAD_GUARD_KEYS, 'style']) : VIEW_SPREAD_GUARD_KEYS; + const overridableChecks: BailoutCheck[] = [ { reason: 'has a spread that may carry a translated prop', - shouldBail: () => hasBlacklistedPropertyInSpread(path, VIEW_SPREAD_GUARD_KEYS), + shouldBail: () => hasBlacklistedPropertyInSpread(path, spreadGuardKeys), + }, + { + reason: 'has an unresolved style source that may be a Unistyles style', + shouldBail: () => getStyleOrigin() === 'unknown', }, { reason: 'has both a dynamic `id` and a `nativeID` (ambiguous precedence)', @@ -104,7 +122,18 @@ export const viewOptimizer: Optimizer = (path, logger, options) => { processViewProps(path, file); - replaceWithNativeComponent(path, parent, file, 'NativeView'); + // A Unistyles-styled View routes to Unistyles' lean host (a registering wrapper around `RCTView`) so + // its shadow-tree registration survives; the `style` already passes through verbatim. Everything else + // optimizes to Boost's own raw host as usual. + if (getStyleOrigin() === 'unistyles') { + replaceWithNativeComponent(path, parent, file, 'NativeView', { + moduleName: UNISTYLES_NATIVE_VIEW_MODULE, + importType: 'default', + nameHint: 'UnistylesNativeView', + }); + } else { + replaceWithNativeComponent(path, parent, file, 'NativeView'); + } }; /** diff --git a/packages/react-native-boost/src/plugin/types/index.ts b/packages/react-native-boost/src/plugin/types/index.ts index d94cb34..6b363ff 100644 --- a/packages/react-native-boost/src/plugin/types/index.ts +++ b/packages/react-native-boost/src/plugin/types/index.ts @@ -43,6 +43,23 @@ export interface PluginOptions { * If omitted, all available optimizers are enabled. */ optimizations?: PluginOptimizationOptions; + /** + * Enables "Unistyles mode": keep `react-native-unistyles` reactivity working on optimized elements. + * + * When enabled, a `Text`/`View` whose `style` resolves to a Unistyles `StyleSheet.create` style is + * rewritten to Unistyles' own lean host (so its shadow-tree registration survives and theme/breakpoint/ + * variant updates keep working) instead of Boost's raw host; a `Text`/`View` with an unresolvable + * direct `style` (e.g. `style={props.style}`, a function call) is left untouched, because it could be a + * Unistyles style arriving from elsewhere; plain/RN styles still optimize to Boost's host as usual. + * + * Style origin is resolved within a single file only (cross-file stylesheets count as unresolvable). + * + * When omitted, the plugin auto-detects whether `react-native-unistyles` is installed and enables this + * if so (and logs a one-time hint to set the flag explicitly). Set to `false` to force it off even when + * Unistyles is installed. + * @default undefined (auto-detected) + */ + unistyles?: boolean; /** * Opt-in flag that allows View optimization when ancestor components cannot be statically resolved. * @@ -93,7 +110,13 @@ export type Optimizer = ( logger: PluginLogger, options?: PluginOptions, /** Target platform from Babel's caller (e.g. Metro sets `'ios'`/`'android'`). Lets optimizers resolve platform-specific defaults at build time. */ - platform?: string + platform?: string, + /** + * Whether "Unistyles mode" is active for this build (resolved once at plugin init from the `unistyles` + * option + install auto-detection). When `true`, optimizers classify each element's `style` origin and + * route Unistyles styles to Unistyles' lean host instead of Boost's raw host. + */ + unistylesEnabled?: boolean ) => void; export type HubFile = t.File & { diff --git a/packages/react-native-boost/src/plugin/utils/common/base.ts b/packages/react-native-boost/src/plugin/utils/common/base.ts index cbcdab2..32f95ed 100644 --- a/packages/react-native-boost/src/plugin/utils/common/base.ts +++ b/packages/react-native-boost/src/plugin/utils/common/base.ts @@ -34,6 +34,20 @@ export function addFileImportHint({ return file.__hasImports[nameHint]; } +/** + * Where to import an optimized element's replacement host from. Defaults to Boost's own runtime + * (`react-native-boost/runtime`, named import). Unistyles mode overrides this to point at Unistyles' + * lean host components, which are version-matched registering wrappers (e.g. `NativeView` is a default + * export). A distinct `nameHint` keeps the import cache from colliding when a file uses both a Boost + * host and a Unistyles host. + */ +export interface NativeComponentSource { + moduleName?: string; + importName?: string; + importType?: 'named' | 'default'; + nameHint?: string; +} + /** * Replaces a component with its native counterpart. * This function handles both the opening and closing tags. @@ -41,24 +55,25 @@ export function addFileImportHint({ * @param path - The path to the JSXOpeningElement. * @param parent - The parent JSX element. * @param file - The Babel file object. - * @param nativeComponentName - The name of the native component to import. - * @param moduleName - The module to import the native component from. + * @param nativeComponentName - The local-name basis for the injected import (and the default import name). + * @param source - Optional override for where to import the host from (see {@link NativeComponentSource}). * @returns The identifier for the imported native component. */ export const replaceWithNativeComponent = ( path: NodePath, parent: t.JSXElement, file: HubFile, - nativeComponentName: string + nativeComponentName: string, + source: NativeComponentSource = {} ): t.Identifier => { // Add native component import (cached on file) to prevent duplicate imports const nativeIdentifier = addFileImportHint({ file, - nameHint: nativeComponentName, + nameHint: source.nameHint ?? nativeComponentName, path, - importName: nativeComponentName, - moduleName: RUNTIME_MODULE_NAME, - importType: 'named', + importName: source.importName ?? nativeComponentName, + moduleName: source.moduleName ?? RUNTIME_MODULE_NAME, + importType: source.importType ?? 'named', }); // Get the current name of the component, which may be aliased (i.e. Text -> RNText) diff --git a/packages/react-native-boost/src/plugin/utils/common/validation.ts b/packages/react-native-boost/src/plugin/utils/common/validation.ts index a919308..18ed90e 100644 --- a/packages/react-native-boost/src/plugin/utils/common/validation.ts +++ b/packages/react-native-boost/src/plugin/utils/common/validation.ts @@ -4,6 +4,7 @@ import { HubFile } from '../../types'; import { minimatch } from 'minimatch'; import nodePath from 'node:path'; import PluginError from '../plugin-error'; +import { UNISTYLES_MODULE_NAME } from '../constants'; /** * Checks if the file is in the list of ignored files. @@ -712,3 +713,101 @@ function getStaticExpressionTruthiness(expression: t.Expression | t.JSXEmptyExpr return undefined; } + +export type StyleOrigin = 'unistyles' | 'plain' | 'unknown'; + +/** + * Classifies where a JSX element's direct `style` value comes from, used to route an element in + * "Unistyles mode". Resolution is **same-file only** — anything that would require following an import, + * or that cannot be proven, is `'unknown'`. + * + * - `'plain'` — no `style`, an object literal, or a `StyleSheet.create(...)` imported from `react-native`: + * provably not a Unistyles style, so it is safe to optimize to Boost's own host as usual. + * - `'unistyles'` — a `StyleSheet.create(...)` imported from `react-native-unistyles`, used directly or as + * any element of a style array: must be routed to Unistyles' lean host so its registration survives. + * - `'unknown'` — a prop/param/call/conditional, an imported stylesheet, or any unresolvable reference: + * undecidable within one file, so it could be a Unistyles style arriving from elsewhere. + */ +export const classifyStyleOrigin = ( + path: NodePath, + styleExpr: t.Expression | undefined +): StyleOrigin => { + if (!styleExpr) return 'plain'; + return classifyStyleExpression(path, styleExpr); +}; + +function classifyStyleExpression(path: NodePath, expr: t.Expression): StyleOrigin { + // An inline object literal could only carry Unistyles state by spreading one in — which strips that + // state anyway and is already broken under Unistyles — so a spread makes it unprovable; a plain + // literal is provably non-Unistyles. + if (t.isObjectExpression(expr)) { + return expr.properties.some((property) => t.isSpreadElement(property)) ? 'unknown' : 'plain'; + } + + if (t.isArrayExpression(expr)) { + let result: StyleOrigin = 'plain'; + for (const element of expr.elements) { + if (element == null) continue; // hole → flattens away + if (t.isSpreadElement(element)) return 'unknown'; + const elementOrigin = classifyStyleExpression(path, element); + // A single Unistyles element makes the whole array Unistyles-managed: routing the array by + // identity to the lean host preserves that element's registration regardless of its siblings. + if (elementOrigin === 'unistyles') return 'unistyles'; + if (elementOrigin === 'unknown') result = 'unknown'; + } + return result; + } + + // `styles.foo` / `styles['foo']` — classify by the `styles` container's `StyleSheet.create` origin. + if (t.isMemberExpression(expr)) { + if (!t.isIdentifier(expr.object)) return 'unknown'; + return classifyStyleContainerBinding(path.scope.getBinding(expr.object.name)); + } + + // A local `const x =