Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 39 additions & 18 deletions apps/docs/content/docs/compatibility/unistyles.mdx
Original file line number Diff line number Diff line change
@@ -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.
---

<Callout type="warn" title="Currently incompatible">
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.
</Callout>
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.

<Callout type="info" title="Auto-detection">
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.
</Callout>

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`.
1 change: 1 addition & 0 deletions apps/docs/content/docs/configuration/configure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module.exports = {
{
verbose: false,
silent: false,
unistyles: false,
ignores: ['node_modules/**'],
optimizations: {
text: true,
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ module.exports = {
};
```

If you're using Unistyles or Nativewind in your project, refer to these additional setup instructions:

<Cards>
<Card title="Unistyles Support" href="/docs/compatibility/unistyles" />
<Card title="Nativewind Support" href="/docs/compatibility/nativewind" />
</Cards>

4. Restart the development server and clear cache:

<Tabs items={['npm', 'pnpm', 'yarn', 'bun']} groupId="package-manager" persist>
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"configuration/configure",
"configuration/boost-decorator",
"---Compatibility---",
"compatibility/nativewind",
"compatibility/unistyles",
"compatibility/nativewind",
"compatibility/uniwind",
"---Runtime Library---",
"runtime-library/index"
Expand Down
14 changes: 13 additions & 1 deletion apps/example/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
],
};
};
1 change: 1 addition & 0 deletions apps/example/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 2 additions & 0 deletions apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions apps/example/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RootStackParamList>();

Expand All @@ -34,6 +35,7 @@ export default function App() {
component={TradingDemoScreen}
options={({ route }) => ({ title: coinsById[route.params.coinId]?.pair ?? 'Price Wall' })}
/>
<Stack.Screen name="UnistylesDemo" component={UnistylesDemoScreen} options={{ title: 'Unistyles' }} />
</Stack.Navigator>
</NavigationContainer>
</SafeAreaProvider>
Expand Down
1 change: 1 addition & 0 deletions apps/example/src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type RootStackParamList = {
Launcher: undefined;
Benchmark: undefined;
TradingDemo: { coinId: string };
UnistylesDemo: undefined;
};

export type RootStackScreenProps<RouteName extends keyof RootStackParamList> = NativeStackScreenProps<
Expand Down
10 changes: 10 additions & 0 deletions apps/example/src/screens/launcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export default function LauncherScreen({ navigation }: RootStackScreenProps<'Lau
<Text style={styles.cardTitle}>Mount Benchmark</Text>
<Text style={styles.cardBody}>Mount thousands of Text and View nodes and measure raw render time.</Text>
</Pressable>

<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={() => navigation.navigate('UnistylesDemo')}>
<Text style={styles.cardTitle}>Unistyles Demo</Text>
<Text style={styles.cardBody}>
Boost-optimized Text and View driven by Unistyles serving as a test screen for Boost's Unistyles support
layer.
</Text>
</Pressable>
</View>
);
}
Expand Down
138 changes: 138 additions & 0 deletions apps/example/src/screens/unistyles-demo/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.screen}>
<Text style={styles.title}>Unistyles × Boost</Text>
<Text style={styles.subtitle}>
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.
</Text>

<ThemeToggle />
<StatusReadout />

<View style={styles.row}>
<View style={styles.card}>
<Text style={styles.cardTitle}>Card A</Text>
<Text style={styles.cardBody}>background and text follow the theme</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Card B</Text>
<Text style={styles.cardBody}>row on wide screens, column on narrow</Text>
</View>
</View>

<View style={styles.accentBox}>
<Text style={styles.accentText}>Accent box — background is accent on narrow, card on wide</Text>
</View>
</View>
);
}

function ThemeToggle() {
const { rt } = useUnistyles();
return (
<Pressable
style={styles.toggle}
onPress={() => UnistylesRuntime.setTheme(rt.themeName === 'dark' ? 'light' : 'dark')}>
<Text style={styles.toggleText}>Toggle theme (current: {rt.themeName})</Text>
</Pressable>
);
}

function StatusReadout() {
const { rt } = useUnistyles();
return (
<View style={styles.status}>
<Text style={styles.statusText}>theme: {rt.themeName}</Text>
<Text style={styles.statusText}>breakpoint: {rt.breakpoint}</Text>
<Text style={styles.statusText}>width: {Math.round(rt.screen.width)}</Text>
</View>
);
}

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,
},
}));
57 changes: 57 additions & 0 deletions apps/example/src/unistyles.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Loading