diff --git a/README.md b/README.md index 8d0a03a..2187eca 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ If you're using Expo and don't see the `babel.config.js` file, run the following npx expo customize babel.config.js ``` +If you're using Unistyles or Nativewind, refer to additional required setup instructions in [the documentation](https://react-native-boost.oss.kuatsu.de/docs). + Finally, restart your React Native development server and clear the bundler cache: ```sh diff --git a/apps/docs/content/docs/compatibility/unistyles.mdx b/apps/docs/content/docs/compatibility/unistyles.mdx index 4965536..60ffa23 100644 --- a/apps/docs/content/docs/compatibility/unistyles.mdx +++ b/apps/docs/content/docs/compatibility/unistyles.mdx @@ -1,31 +1,52 @@ --- title: Unistyles Support -description: Why React Native Boost and Unistyles are currently incompatible. +description: Keep Unistyles styling working on Boost-optimized components. --- - - React Native Boost and [Unistyles](https://www.unistyl.es) (v3) are currently incompatible. Enabling Boost in a Unistyles project can lead to dynamic styles becoming non-reactive. - +React Native Boost is compatible with react-native-unistyles v3. Previous versions of Unistyles are untested and not officially supported. + +## Setup + +Enable Boost's Unistyles support layer through the Babel plugin's config options: -## What happens +```js +// babel.config.js +module.exports = { + plugins: [ + ['react-native-boost/plugin', { unistyles: true }], + ['react-native-unistyles/plugin', { root: 'src' }], + ], +}; +``` + +Set `unistyles: false` to turn the mode off, e.g. when Unistyles is installed in your project's dependencies, but not actually used in code. The order of the two plugins does not matter. + + + React Native Boost can auto-detect Unistyles and enable this mode automatically if you haven't explicitly disabled it. However, this auto-detection is fragile and Boost will therefore log a warning to the console. Explicitly set the config flag as shown above to silence the warning. + -Unistyles updates styles natively, outside of React. A component only updates if it registers itself -with Unistyles' native engine at mount. Boost rewrites `Text`/`View` to their native counterparts -**before** Unistyles can do this, so registration never happens. +## How it works -When this happens, the component renders correctly once, then never updates again. +Unistyles updates styles natively, outside of React. -## What breaks +In Unistyles mode, Boost looks at each `Text`/`View`'s `style` and routes accordingly: -Anything Unistyles drives at runtime stops applying to optimized components: +- **A Unistyles style** (from `StyleSheet.create` imported from `react-native-unistyles`) → rewritten to Unistyles' own lean host, keeping Unistyles' reactivity, while still providing Boost's performance benefits. +- **A plain React Native style** (an object literal, or a `StyleSheet.create` from `react-native`) → + optimized to Boost's standard native host, exactly as in a non-Unistyles app. +- **A style Boost can't resolve** (e.g. `style={props.style}`, a function call, a conditional) → left + untouched. When Boost can't reliably tell if it's a Unistyles style arriving from elsewhere or a plain style object, it has to skip it. When Unistyles mode is disabled, this does not apply, and all components (that don't bail for other reasons) are optimized, no matter where their `style` comes from. -- Theme and color-scheme (light/dark) changes -- Breakpoint changes (rotation, resize, foldables) -- Variants, insets, font scale, and other runtime dependencies +### Known limitations -Note that the initial render looks correct, so the problem is easy to miss in testing. +The native components React Native Boost rewrites your components to don't perform some of the prop and style processing the standard JS-based wrapper components do. Without Unistyles, React Native Boost can do this processing for you. With Unistyles, this isn't possible, unfortunately. Therefore: -## Roadmap +| Avoid | Use | +| --- | --- | +| `fontWeight: 700` (a number) | `fontWeight: '700'` (a string) | +| `userSelect: 'none'` | the `selectable` prop, e.g. `selectable={false}` | +| `verticalAlign: 'middle'` | `textAlignVertical: 'center'` | -Proper Unistyles support is technically possible and planned. See the [tracking issue #58 on -GitHub](https://github.com/kuatsu/react-native-boost/issues/58). +Boost forwards a Unistyles style to the native host untouched (this is what preserves Unistyles' +reactivity), so the raw forms reach the host as-is. The right-hand values are already in their native +form, so they work whether or not Unistyles is in play. These only affect `Text`. diff --git a/apps/docs/content/docs/configuration/configure.mdx b/apps/docs/content/docs/configuration/configure.mdx index 1d07a76..74dcb55 100644 --- a/apps/docs/content/docs/configuration/configure.mdx +++ b/apps/docs/content/docs/configuration/configure.mdx @@ -18,6 +18,7 @@ module.exports = { { verbose: false, silent: false, + unistyles: false, ignores: ['node_modules/**'], optimizations: { text: true, diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 4e4c7e8..e544564 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -73,6 +73,13 @@ module.exports = { }; ``` +If you're using Unistyles or Nativewind in your project, refer to these additional setup instructions: + + + + + + 4. Restart the development server and clear cache: diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 7f4e97e..3f51cb5 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -14,8 +14,8 @@ "configuration/configure", "configuration/boost-decorator", "---Compatibility---", - "compatibility/nativewind", "compatibility/unistyles", + "compatibility/nativewind", "compatibility/uniwind", "---Runtime Library---", "runtime-library/index" diff --git a/apps/example/babel.config.js b/apps/example/babel.config.js index e3aa234..f8f8eca 100644 --- a/apps/example/babel.config.js +++ b/apps/example/babel.config.js @@ -3,7 +3,19 @@ module.exports = function (api) { return { presets: ['babel-preset-expo'], plugins: [ - ['react-native-boost/plugin', { ignores: ['node_modules/**', '../../node_modules/**', '**/*.unoptimized.tsx'] }], + [ + 'react-native-boost/plugin', + { + unistyles: true, + ignores: ['node_modules/**', '../../node_modules/**', '**/*.unoptimized.tsx'], + }, + ], + [ + 'react-native-unistyles/plugin', + { + root: 'src', + }, + ], ], }; }; diff --git a/apps/example/index.ts b/apps/example/index.ts index 21f8cd6..5cf4840 100644 --- a/apps/example/index.ts +++ b/apps/example/index.ts @@ -1,6 +1,7 @@ // Must be the first import: bakes the benchmark `core` profile's RN feature-flag overrides in before // react-native's Text module evaluates (no-op outside the benchmark). See the module's @remarks. import './src/benchmark/feature-flags'; +import './src/unistyles'; import { registerRootComponent } from 'expo'; diff --git a/apps/example/package.json b/apps/example/package.json index 07769e8..b0e37e4 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -25,9 +25,11 @@ "react-dom": "19.2.0", "react-native": "0.83.2", "react-native-boost": "workspace:*", + "react-native-nitro-modules": "0.35.7", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.23.0", "react-native-time-to-render": "workspace:*", + "react-native-unistyles": "3.2.5", "react-native-web": "^0.21.2" }, "devDependencies": { diff --git a/apps/example/src/app.tsx b/apps/example/src/app.tsx index 4f64b19..1749e80 100644 --- a/apps/example/src/app.tsx +++ b/apps/example/src/app.tsx @@ -8,6 +8,7 @@ import TradingDemoScreen from './screens/trading-demo'; import LauncherScreen from './screens/launcher'; import BenchmarkScreen from './screens/benchmark'; import BenchmarkRunner from './screens/benchmark-runner'; +import UnistylesDemoScreen from './screens/unistyles-demo'; const Stack = createNativeStackNavigator(); @@ -34,6 +35,7 @@ export default function App() { component={TradingDemoScreen} options={({ route }) => ({ title: coinsById[route.params.coinId]?.pair ?? 'Price Wall' })} /> + diff --git a/apps/example/src/navigation.ts b/apps/example/src/navigation.ts index b09e97a..fcf7d39 100644 --- a/apps/example/src/navigation.ts +++ b/apps/example/src/navigation.ts @@ -4,6 +4,7 @@ export type RootStackParamList = { Launcher: undefined; Benchmark: undefined; TradingDemo: { coinId: string }; + UnistylesDemo: undefined; }; export type RootStackScreenProps = NativeStackScreenProps< diff --git a/apps/example/src/screens/launcher.tsx b/apps/example/src/screens/launcher.tsx index 12d57e4..8fddb02 100644 --- a/apps/example/src/screens/launcher.tsx +++ b/apps/example/src/screens/launcher.tsx @@ -23,6 +23,16 @@ export default function LauncherScreen({ navigation }: RootStackScreenProps<'Lau Mount Benchmark Mount thousands of Text and View nodes and measure raw render time. + + [styles.card, pressed && styles.cardPressed]} + onPress={() => navigation.navigate('UnistylesDemo')}> + Unistyles Demo + + Boost-optimized Text and View driven by Unistyles serving as a test screen for Boost's Unistyles support + layer. + + ); } diff --git a/apps/example/src/screens/unistyles-demo/index.tsx b/apps/example/src/screens/unistyles-demo/index.tsx new file mode 100644 index 0000000..c565a80 --- /dev/null +++ b/apps/example/src/screens/unistyles-demo/index.tsx @@ -0,0 +1,138 @@ +import { Pressable, Text, View } from 'react-native'; +import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'; +import { RootStackScreenProps } from '../../navigation'; + +/** + * Boost × Unistyles compatibility probe. + * + * These subjects do NOT call `useUnistyles`, so they never re-render — any color/layout change after a theme toggle or a + * rotation can only come through the native (C++) update path, i.e. the registration Boost preserved. + */ +export default function UnistylesDemoScreen(_props: RootStackScreenProps<'UnistylesDemo'>) { + return ( + + Unistyles × Boost + + Every card below is a Boost-optimized host wired to a Unistyles stylesheet. Tap to toggle the theme — they + restyle instantly without re-rendering, which is only possible if their native registration survived + optimization. The breakpoint styles (column layout, accent color) resolve from the current screen width. + + + + + + + + Card A + background and text follow the theme + + + Card B + row on wide screens, column on narrow + + + + + Accent box — background is accent on narrow, card on wide + + + ); +} + +function ThemeToggle() { + const { rt } = useUnistyles(); + return ( + UnistylesRuntime.setTheme(rt.themeName === 'dark' ? 'light' : 'dark')}> + Toggle theme (current: {rt.themeName}) + + ); +} + +function StatusReadout() { + const { rt } = useUnistyles(); + return ( + + theme: {rt.themeName} + breakpoint: {rt.breakpoint} + width: {Math.round(rt.screen.width)} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + screen: { + flex: 1, + backgroundColor: theme.colors.background, + padding: theme.gap(2), + gap: theme.gap(1.5), + }, + title: { + fontSize: 26, + fontWeight: '800', + color: theme.colors.text, + }, + subtitle: { + fontSize: 13, + lineHeight: 18, + color: theme.colors.muted, + }, + toggle: { + borderRadius: 12, + padding: theme.gap(1.5), + backgroundColor: theme.colors.accent, + alignItems: 'center', + }, + toggleText: { + fontSize: 15, + fontWeight: '700', + color: '#ffffff', + }, + status: { + flexDirection: 'row', + gap: theme.gap(2), + paddingVertical: theme.gap(1), + }, + statusText: { + fontSize: 13, + color: theme.colors.muted, + }, + row: { + flexDirection: { xs: 'column', md: 'row' }, + gap: theme.gap(1.5), + }, + card: { + flexGrow: 1, + flexBasis: 'auto', + borderRadius: 16, + padding: theme.gap(2), + backgroundColor: theme.colors.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: theme.colors.border, + minHeight: 96, + gap: theme.gap(0.5), + }, + cardTitle: { + fontSize: 18, + fontWeight: '700', + color: theme.colors.text, + }, + cardBody: { + fontSize: 13, + color: theme.colors.muted, + }, + accentBox: { + borderRadius: 12, + padding: theme.gap(2), + backgroundColor: { + xs: theme.colors.accent, + md: theme.colors.card, + }, + }, + accentText: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.text, + }, +})); diff --git a/apps/example/src/unistyles.ts b/apps/example/src/unistyles.ts new file mode 100644 index 0000000..86e6e69 --- /dev/null +++ b/apps/example/src/unistyles.ts @@ -0,0 +1,57 @@ +import { StyleSheet } from 'react-native-unistyles'; + +const lightTheme = { + colors: { + background: '#ffffff', + card: '#f2f4f7', + border: '#d9dee5', + text: '#0b0e11', + muted: '#5b6470', + accent: '#2962ff', + }, + gap: (v: number) => v * 8, +}; + +const darkTheme = { + colors: { + background: '#0b0e11', + card: '#12161c', + border: '#2a3139', + text: '#eaecef', + muted: '#9aa3ad', + accent: '#f0b90b', + }, + gap: (v: number) => v * 8, +}; + +const appThemes = { + light: lightTheme, + dark: darkTheme, +}; + +const breakpoints = { + xs: 0, + sm: 380, + md: 600, + lg: 900, + xl: 1200, +}; + +type AppThemes = typeof appThemes; +type AppBreakpoints = typeof breakpoints; + +/* oxlint-disable no-empty-object-type */ +declare module 'react-native-unistyles' { + export interface UnistylesThemes extends AppThemes {} + export interface UnistylesBreakpoints extends AppBreakpoints {} +} +/* oxlint-enable no-empty-object-type */ + +StyleSheet.configure({ + settings: { + initialTheme: 'dark', + adaptiveThemes: false, + }, + breakpoints, + themes: appThemes, +}); diff --git a/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/cascades-view-text/code.js b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/cascades-view-text/code.js new file mode 100644 index 0000000..f474e24 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/cascades-view-text/code.js @@ -0,0 +1,11 @@ +import { Text, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ box: { flex: 1 }, t: {} }); +const C = () => ( + + top + + deep + + +); diff --git a/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/cascades-view-text/output.js b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/cascades-view-text/output.js new file mode 100644 index 0000000..4b0f32d --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/cascades-view-text/output.js @@ -0,0 +1,31 @@ +import { NativeText as _UnistylesNativeText } from 'react-native-unistyles/components/native/NativeText'; +import { getDefaultTextAccessible as _getDefaultTextAccessible } from 'react-native-boost/runtime'; +import _UnistylesNativeView from 'react-native-unistyles/components/native/NativeView'; +import { Text, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ + box: { + flex: 1, + }, + t: {}, +}); +const C = () => ( + <_UnistylesNativeView style={styles.box}> + <_UnistylesNativeText + style={styles.t} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + top + + <_UnistylesNativeView style={styles.box}> + <_UnistylesNativeText + style={styles.t} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + deep + + + +); diff --git a/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/mixed-origins-under-lean-view/code.js b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/mixed-origins-under-lean-view/code.js new file mode 100644 index 0000000..2b7a956 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/mixed-origins-under-lean-view/code.js @@ -0,0 +1,10 @@ +import { Text, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ box: {}, label: {} }); +const C = (props) => ( + + unistyles + plain literal + unknown bails + +); diff --git a/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/mixed-origins-under-lean-view/output.js b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/mixed-origins-under-lean-view/output.js new file mode 100644 index 0000000..cd07698 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting-unistyles/mixed-origins-under-lean-view/output.js @@ -0,0 +1,31 @@ +import { NativeText as _NativeText } from 'react-native-boost/runtime'; +import { NativeText as _UnistylesNativeText } from 'react-native-unistyles/components/native/NativeText'; +import { getDefaultTextAccessible as _getDefaultTextAccessible } from 'react-native-boost/runtime'; +import _UnistylesNativeView from 'react-native-unistyles/components/native/NativeView'; +import { Text, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +const styles = StyleSheet.create({ + box: {}, + label: {}, +}); +const C = (props) => ( + <_UnistylesNativeView style={styles.box}> + <_UnistylesNativeText + style={styles.label} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + unistyles + + <_NativeText + style={{ + color: 'red', + }} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + plain literal + + unknown bails + +); diff --git a/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/cascades-view-text/code.js b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/cascades-view-text/code.js new file mode 100644 index 0000000..19abddd --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/cascades-view-text/code.js @@ -0,0 +1,9 @@ +import { Text, View } from 'react-native'; +const C = () => ( + + top + + deep + + +); diff --git a/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/cascades-view-text/output.js b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/cascades-view-text/output.js new file mode 100644 index 0000000..34272ac --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/cascades-view-text/output.js @@ -0,0 +1,24 @@ +import { + NativeView as _NativeView, + getDefaultTextAccessible as _getDefaultTextAccessible, + NativeText as _NativeText, +} from 'react-native-boost/runtime'; +import { Text, View } from 'react-native'; +const C = () => ( + <_NativeView + style={{ + flex: 1, + }}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> + top + + <_NativeView + style={{ + gap: 4, + }}> + <_NativeText allowFontScaling={true} ellipsizeMode={'tail'} accessible={_getDefaultTextAccessible()}> + deep + + + +); diff --git a/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/text-in-text-stays-bailed/code.js b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/text-in-text-stays-bailed/code.js new file mode 100644 index 0000000..55fdea2 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/text-in-text-stays-bailed/code.js @@ -0,0 +1,7 @@ +import { Text } from 'react-native'; +const C = () => ( + + outer + inner + +); diff --git a/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/text-in-text-stays-bailed/output.js b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/text-in-text-stays-bailed/output.js new file mode 100644 index 0000000..55fdea2 --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/fixtures-nesting/text-in-text-stays-bailed/output.js @@ -0,0 +1,7 @@ +import { Text } from 'react-native'; +const C = () => ( + + outer + inner + +); diff --git a/packages/react-native-boost/src/plugin/__tests__/nesting.test.ts b/packages/react-native-boost/src/plugin/__tests__/nesting.test.ts new file mode 100644 index 0000000..059a91b --- /dev/null +++ b/packages/react-native-boost/src/plugin/__tests__/nesting.test.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; +import { pluginTester } from 'babel-plugin-tester'; +import { generateCombinedTestPlugin } from '../utils/generate-test-plugin'; +import { formatTestResult } from '../utils/format-test-result'; + +// These run BOTH optimizers per element (like the real plugin), which is required to exercise nested +// optimization: an outer `View` is rewritten first, then inner elements must recognize that rewritten +// host as a safe ancestor and keep optimizing down the tree. +pluginTester({ + plugin: generateCombinedTestPlugin(), + title: 'nesting', + fixtures: path.resolve(import.meta.dirname, 'fixtures-nesting'), + babelOptions: { + plugins: ['@babel/plugin-syntax-jsx'], + }, + formatResult: formatTestResult, +}); + +pluginTester({ + plugin: generateCombinedTestPlugin({ unistyles: true }), + title: 'nesting unistyles', + fixtures: path.resolve(import.meta.dirname, 'fixtures-nesting-unistyles'), + babelOptions: { + plugins: ['@babel/plugin-syntax-jsx'], + }, + formatResult: formatTestResult, +}); diff --git a/packages/react-native-boost/src/plugin/index.ts b/packages/react-native-boost/src/plugin/index.ts index eeb4e62..ed6185d 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,40 @@ 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 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; + + if (autoDetectedUnistyles) { + createLogger({ verbose: options.verbose === true, silent: options.silent === true }).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.', + }); + } + 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 (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-ts/unistyles-cast-style/code.tsx b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles-ts/unistyles-cast-style/code.tsx new file mode 100644 index 0000000..1dbb875 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles-ts/unistyles-cast-style/code.tsx @@ -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-ts/unistyles-cast-style/output.tsx b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles-ts/unistyles-cast-style/output.tsx new file mode 100644 index 0000000..0edce0f --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles-ts/unistyles-cast-style/output.tsx @@ -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 as TextStyle} + allowFontScaling={true} + ellipsizeMode={'tail'} + accessible={_getDefaultTextAccessible()}> + Hello +; 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-optional-member-style/code.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-optional-member-style/code.js new file mode 100644 index 0000000..4ae24c3 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-optional-member-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-optional-member-style/output.js b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-optional-member-style/output.js new file mode 100644 index 0000000..0837af7 --- /dev/null +++ b/packages/react-native-boost/src/plugin/optimizers/text/__tests__/fixtures-unistyles/unistyles-optional-member-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-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..18513ee 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,23 @@ 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, +}); + +pluginTester({ + plugin: generateTestPlugin(textOptimizer, { unistyles: true }), + title: 'text unistyles typescript', + fixtures: path.resolve(import.meta.dirname, 'fixtures-unistyles-ts'), + babelOptions: { + plugins: ['@babel/plugin-syntax-jsx', ['@babel/plugin-syntax-typescript', { isTSX: true }]], + }, + 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..65c380a 100644 --- a/packages/react-native-boost/src/plugin/optimizers/text/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/text/index.ts @@ -23,6 +23,8 @@ import { extractSelectableAndUpdateStyle, tryBuildStaticTextStyle, ancestorBailoutChecks, + createStyleOriginResolver, + UNISTYLES_TEXT_HOST, } from '../../utils/common'; import { ACCESSIBILITY_PROPERTIES, RUNTIME_MODULE_NAME } from '../../utils/constants'; @@ -82,13 +84,17 @@ 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. + const getStyleOrigin = createStyleOriginResolver(path, unistylesEnabled); + const overridableChecks: BailoutCheck[] = [ { reason: 'contains blacklisted props', @@ -98,6 +104,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 +158,19 @@ 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); + processProps(path, file, platform, routeToUnistyles); - // Replace the Text component with NativeText - replaceWithNativeComponent(path, parent, file, 'NativeText'); + // 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. + replaceWithNativeComponent(path, parent, file, 'NativeText', routeToUnistyles ? UNISTYLES_TEXT_HOST : undefined); }; /** @@ -252,8 +266,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 +327,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 +386,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..c971e43 100644 --- a/packages/react-native-boost/src/plugin/optimizers/view/index.ts +++ b/packages/react-native-boost/src/plugin/optimizers/view/index.ts @@ -13,6 +13,8 @@ import { isReactNativeImport, replaceWithNativeComponent, ancestorBailoutChecks, + createStyleOriginResolver, + UNISTYLES_VIEW_HOST, } from '../../utils/common'; import { RUNTIME_MODULE_NAME } from '../../utils/constants'; @@ -43,22 +45,38 @@ const VIEW_SPREAD_GUARD_KEYS = new Set([ 'tabIndex', ]); +// In Unistyles mode a `style` arriving through a resolvable spread must also bail (it could be a +// Unistyles style), so the guard set additionally includes `style`. Precomputed to avoid rebuilding it +// per element. +const VIEW_SPREAD_GUARD_KEYS_UNISTYLES = new Set([...VIEW_SPREAD_GUARD_KEYS, 'style']); + // ARIA siblings that trigger aggregation. Their presence routes the whole matching group (including a // passed `accessibilityState`/`accessibilityValue`) through the runtime helper, because the wrapper // merges them (`ariaX ?? source?.x`) and a partial literal translation could not reproduce that. 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 in the Unistyles spread keys); an unresolvable spread + // already bails. See {@link classifyStyleOrigin}. + const getStyleOrigin = createStyleOriginResolver(path, unistylesEnabled); + + const spreadGuardKeys = unistylesEnabled ? VIEW_SPREAD_GUARD_KEYS_UNISTYLES : 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,11 @@ 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. + const viewHost = getStyleOrigin() === 'unistyles' ? UNISTYLES_VIEW_HOST : undefined; + replaceWithNativeComponent(path, parent, file, 'NativeView', viewHost); }; /** 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..0cbea91 100644 --- a/packages/react-native-boost/src/plugin/utils/common/base.ts +++ b/packages/react-native-boost/src/plugin/utils/common/base.ts @@ -1,7 +1,8 @@ import { NodePath, types as t } from '@babel/core'; import { addDefault, addNamed } from '@babel/helper-module-imports'; import { FileImportOptions, HubFile } from '../../types'; -import { RUNTIME_MODULE_NAME } from '../constants'; +import { RUNTIME_MODULE_NAME, UNISTYLES_NATIVE_TEXT_MODULE, UNISTYLES_NATIVE_VIEW_MODULE } from '../constants'; +import { markOptimizedHost, OptimizedHostKind } from './optimized-host'; /** * Adds a hint to the file object to ensure that a specific import is added only once and cached on the file object. @@ -34,6 +35,45 @@ 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; +} + +/** The native hosts Boost rewrites elements into; the local-name basis for each injected import. */ +type NativeComponentName = 'NativeText' | 'NativeView'; + +/** Which context each optimized host establishes for the ancestor walk. Total over every host name. */ +const HOST_KIND_BY_NAME: Record = { + NativeText: 'text', + NativeView: 'view', +}; + +/** + * Import sources for Unistyles' own lean hosts, passed as the `source` override when an element's `style` + * resolves to a Unistyles style. + */ +export const UNISTYLES_TEXT_HOST: NativeComponentSource = { + moduleName: UNISTYLES_NATIVE_TEXT_MODULE, + importName: 'NativeText', + nameHint: 'UnistylesNativeText', +}; + +export const UNISTYLES_VIEW_HOST: NativeComponentSource = { + moduleName: UNISTYLES_NATIVE_VIEW_MODULE, + importType: 'default', + nameHint: 'UnistylesNativeView', +}; + /** * Replaces a component with its native counterpart. * This function handles both the opening and closing tags. @@ -41,29 +81,34 @@ 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: NativeComponentName, + 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) const currentName = (path.node.name as t.JSXIdentifier).name; + // Record what this element was optimized into so the ancestor walk can classify it once it becomes a + // descendant's ancestor (the injected import is not yet resolvable via scope this traversal). + markOptimizedHost(path.node, HOST_KIND_BY_NAME[nativeComponentName]); + // Replace the component with its native counterpart const jsxName = path.node.name as t.JSXIdentifier; jsxName.name = nativeIdentifier.name; diff --git a/packages/react-native-boost/src/plugin/utils/common/index.ts b/packages/react-native-boost/src/plugin/utils/common/index.ts index ad7df13..75e3360 100644 --- a/packages/react-native-boost/src/plugin/utils/common/index.ts +++ b/packages/react-native-boost/src/plugin/utils/common/index.ts @@ -1,3 +1,4 @@ export * from './validation'; export * from './attributes'; export * from './base'; +export * from './optimized-host'; diff --git a/packages/react-native-boost/src/plugin/utils/common/optimized-host.ts b/packages/react-native-boost/src/plugin/utils/common/optimized-host.ts new file mode 100644 index 0000000..df223dc --- /dev/null +++ b/packages/react-native-boost/src/plugin/utils/common/optimized-host.ts @@ -0,0 +1,19 @@ +import { types as t } from '@babel/core'; + +export type OptimizedHostKind = 'text' | 'view'; + +/** + * Records, per `JSXOpeningElement` node, which kind of host Boost rewrote it into. The ancestor walk + * reads this to classify an already-optimized ancestor by what it renders, without depending on the + * just-injected import being resolvable via scope (a fresh `addNamed` import is not yet crawled into + * scope during the same traversal, so `getBinding` returns `undefined` for it). A `WeakMap` keyed on the + * node avoids mutating the AST and is collected with the file's nodes after the transform. + */ +const optimizedHosts = new WeakMap(); + +export const markOptimizedHost = (openingElement: t.JSXOpeningElement, kind: OptimizedHostKind): void => { + optimizedHosts.set(openingElement, kind); +}; + +export const getOptimizedHostKind = (openingElement: t.JSXOpeningElement): OptimizedHostKind | undefined => + optimizedHosts.get(openingElement); 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..a29f6ca 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,14 @@ import { HubFile } from '../../types'; import { minimatch } from 'minimatch'; import nodePath from 'node:path'; import PluginError from '../plugin-error'; +import { + UNISTYLES_MODULE_NAME, + UNISTYLES_NATIVE_TEXT_MODULE, + UNISTYLES_NATIVE_VIEW_MODULE, + RUNTIME_MODULE_NAME, +} from '../constants'; +import { getOptimizedHostKind } from './optimized-host'; +import { extractStyleAttribute } from './attributes'; /** * Checks if the file is in the list of ignored files. @@ -242,6 +250,12 @@ function classifyJSXElementAsAncestor( path: NodePath, context: AncestorAnalysisContext ): AncestorClassification { + // An ancestor Boost already rewrote earlier in this same traversal: classify it by the host it + // became (Text → inline-text context, View → normal context). This is checked before the import-based + // paths because the freshly-injected host import is not yet resolvable via scope. + const optimizedHostKind = getOptimizedHostKind(path.node.openingElement); + if (optimizedHostKind) return optimizedHostKind === 'text' ? 'text' : 'safe'; + const openingElementName = path.node.openingElement.name; if (t.isJSXIdentifier(openingElementName)) { @@ -322,6 +336,14 @@ function classifyModuleBindingAsAncestor(binding: ScopeBinding): AncestorClassif return 'unknown'; } + // An ancestor Boost itself already rewrote (its own runtime host, or a Unistyles lean host in + // Unistyles mode) is a *known* host: a View establishes a normal context ('safe'), a Text an + // inline-text context ('text'). Without this, a descendant of an optimized element would read its + // rewritten ancestor as 'unknown' and bail — so only the outermost element of any subtree could ever + // optimize. Classifying by what the host renders lets optimization cascade down the tree. + const optimizedHost = classifyOptimizedHostAncestor(source, binding); + if (optimizedHost) return optimizedHost; + if (source === 'react' && t.isImportSpecifier(binding.path.node)) { const importedName = getImportSpecifierImportedName(binding.path.node); if (importedName === 'Fragment') return 'safe'; @@ -330,6 +352,26 @@ function classifyModuleBindingAsAncestor(binding: ScopeBinding): AncestorClassif return 'unknown'; } +/** + * Classifies an ancestor that is one of the optimized hosts Boost emits — its own runtime + * `NativeText`/`NativeView`, or (in Unistyles mode) Unistyles' lean `NativeText`/`NativeView`. Returns + * `'text'` for a Text host, `'safe'` for a View host, or `undefined` when the source is not a known + * optimized host. The Unistyles lean hosts are keyed purely by their (component-specific) import source; + * Boost's own runtime exports both hosts from one module, so its imported name is checked. + */ +function classifyOptimizedHostAncestor(source: string, binding: ScopeBinding): AncestorClassification | undefined { + if (source === UNISTYLES_NATIVE_TEXT_MODULE) return 'text'; + if (source === UNISTYLES_NATIVE_VIEW_MODULE) return 'safe'; + + if (source === RUNTIME_MODULE_NAME && t.isImportSpecifier(binding.path.node)) { + const importedName = getImportSpecifierImportedName(binding.path.node); + if (importedName === 'NativeText') return 'text'; + if (importedName === 'NativeView') return 'safe'; + } + + return undefined; +} + function classifyLocalBindingAsAncestor( binding: ScopeBinding, context: AncestorAnalysisContext @@ -712,3 +754,135 @@ function getStaticExpressionTruthiness(expression: t.Expression | t.JSXEmptyExpr return undefined; } + +export type StyleOrigin = 'unistyles' | 'plain' | 'unknown'; + +// Bounds the alias/array/wrapper recursion in `classifyStyleExpression`, which would otherwise loop +// forever on a pathological const cycle (`const a = b; const b = a`). Past this depth the source is +// undecidable, so it classifies as `'unknown'` (a safe bail). +const MAX_STYLE_RESOLUTION_DEPTH = 64; + +/** + * Builds a lazily-memoized resolver for an element's direct `style` origin, shared by the `Text` and + * `View` optimizers. Outside Unistyles mode it is a constant `'plain'` (no work). Inside, it classifies + * on first call and caches — kept lazy so an element that bails on a cheaper check never pays the + * classification cost. + */ +export const createStyleOriginResolver = ( + path: NodePath, + unistylesEnabled: boolean | undefined +): (() => StyleOrigin) => { + if (!unistylesEnabled) return () => 'plain'; + + let styleOrigin: StyleOrigin | undefined; + return () => (styleOrigin ??= classifyStyleOrigin(path, extractStyleAttribute(path.node.attributes).styleExpr)); +}; + +/** + * 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, depth = 0): StyleOrigin { + if (depth > MAX_STYLE_RESOLUTION_DEPTH) return 'unknown'; + + // TS-only and parenthesized wrappers do not change the runtime style value, so `styles.foo as TextStyle`, + // `styles.foo!`, `styles.foo satisfies …`, and `(styles.foo)` classify exactly like `styles.foo`. + if ( + t.isTSAsExpression(expr) || + t.isTSSatisfiesExpression(expr) || + t.isTSNonNullExpression(expr) || + t.isParenthesizedExpression(expr) + ) { + return classifyStyleExpression(path, expr.expression, depth + 1); + } + + // 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, depth + 1); + // 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']` / `styles?.foo` — classify by the `styles` container's origin. + if (t.isMemberExpression(expr) || t.isOptionalMemberExpression(expr)) { + if (!t.isIdentifier(expr.object)) return 'unknown'; + return classifyStyleContainerBinding(path.scope.getBinding(expr.object.name)); + } + + // A local `const x =