[expo-modules-core][Android] ExpoComposeView's generateViewId() collides with react-native-screens' tab container id, crashing on tab switch
Summary
ExpoComposeView gives its hosting ComposeView an id from View.generateViewId(). Inside a React Native process that id space already has two other writers, and one of them — react-native-screens — uses it for the container it attaches tab fragments to. When a Host takes that container's id, switching tabs crashes the process:
java.lang.UnsupportedOperationException: Cannot add views to ComposeView; only Compose content is supported
androidx.compose.ui.platform.AbstractComposeView.checkAddView(ComposeView.android.kt:289)
androidx.fragment.app.FragmentStateManager.addViewToContainer(FragmentStateManager.java:902)
com.swmansion.rnscreens.gamma.tabs.container.TabsContainer.onMenuItemSelected(TabsContainer.kt:646)
One integer space, three writers
| Writer | Range it hands out |
|---|---|
the renderer — ViewManager.setId(reactTag); Fabric counts nextReactTag = 2; += 2 |
even, from 2 |
react-native-screens — NewArchAwareViewIdGenerator, for the tab fragment container |
odd, from 3 |
expo-modules-core — ExpoComposeView calling View.generateViewId() |
every integer, from 1 |
The second writer exists only because of the first: screens takes odd numbers precisely so it can never land on a react tag, and its own ViewIdHelpers.kt says the scheme "relies on internal renderer behaviours … therefore it must be revisited & kept up to date with current RN behaviour".
View.generateViewId() counts 1, 2, 3, 4 — every integer — so it walks straight through the numbers screens is using.
⚠️ Nobody is violating a documented contract here, and that is the point. generateViewId() promises only that its values will not collide with ids aapt generates: they are clamped to 0x00000001..0x00FFFFFF and aapt's R.id starts above that. It says nothing about ids a framework assigns by hand, and React Native assigns every one of them by hand. Equally, screens does not claim to reserve the odd numbers from third parties — its generator exists to dodge renderer tags, and its own comment says the scheme "relies on internal renderer behaviours … therefore it must be revisited".
So this is not a documented rule being broken. It is two allocators writing into one unowned integer space — and the combination that brings them together, expo-router/unstable-native-tabs over react-native-screens, is one Expo ships.
The same collision, already triaged next door
react-native-screens#4273 is this bug with a different victim: FragmentStateManager.createView resolves the container by id, finds a Google Maps ImageView that took it, and dies on the ViewGroup cast instead of on checkAddView. Open since 2026-07-06.
A screens maintainer set out the mechanism there — "FragmentActivity delegates the search to getWindow().getDecorView().findViewById(...) -> which simply does pre-order DFS. In case of id collision it can indeed find a wrong view" — and then stated the rule they expect third parties to follow:
The proper solution here would be to enforce different view id for your native ImageView, one that does not collide with React's view id domain. I believe that should be the solution.
At the same time, you should take care of avoiding id collisions with other
react-native-screenscustom views. We have a mechanism for that, see theViewIdGenerator.
ExpoComposeView's hosting ComposeView is exactly such a view: native, not a React view, living in the React hierarchy with an id drawn from React's domain. On that reading the change belongs here, and it is five lines.
Why it crashes rather than merely overlapping
A fragment's container is resolved by id, not by reference: FragmentStateManager.createView calls fragmentContainer.onFindViewById(mContainerId), and screens' FragmentManagerHelper hands it the activity's FragmentManager — so the search spans the whole activity and the first match wins. Once a Host has taken the tabs container's id, the FragmentManager hands the tab fragment to a ComposeView, which refuses child views outright.
This is the same shape as the older "No view found for id 0x…" reports against screens (#54, #463); the odd-number generator was the fix for that round.
Where it came from
The line arrives in #46650 ("[core] fix recompose when switch screens", merged 2026-06-11), which added it so each Host's rememberSaveable state gets its own key in the Activity's shared SavedStateRegistry. That reason is sound; only the source of the number is the problem. It is still on main at the time of writing, at ExpoComposeView.kt:227.
Minimal reproducible example
⚠️ No hosted repro repository yet — the report comes out of a production app, and the section below is the reduction of it rather than a link. Happy to publish a standalone repo if that is what unblocks triage; I did not want to sit on the diagnosis while building one.
What is needed is only: native tabs, and enough Hosts created before the first tab switch that View.generateViewId() reaches the tab container's id.
// app/(tabs)/_layout.tsx
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
export default function Layout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<Icon md="home" />
<Label>One</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="two">
<Icon md="star" />
<Label>Two</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
// app/(tabs)/index.tsx — each Host takes the next generateViewId()
import { Button, Host } from '@expo/ui/jetpack-compose';
export default function One() {
return (
<>
{[1, 2, 3, 4].map((n) => (
<Host key={n} style={{ height: 56 }}>
<Button onPress={() => {}}>{`Button ${n}`}</Button>
</Host>
))}
</>
);
}
Steps to reproduce
- Run the app on Android (a development build —
@expo/uineeds native code). - Land on the first tab, so the Hosts above are created.
- Tap the second tab.
The process dies with the UnsupportedOperationException at the top of this report. adb shell dumpsys activity top before the switch shows whether the collision is armed: if a ComposeView carries the same #id as TabsContainer, the next switch is the crash.
⚠️ It is a race between two counters, so the exact number of Hosts that arms it depends on what else in the process has called View.generateViewId(). In our app it is reliably armed by the first screen.
Environment
expo-modules-core57.0.17 (latest published)expo57,expo-router57.0.20, tabs viaexpo-router/unstable-native-tabsreact-native0.86.3,react-native-screens4.26.2- Android, emulator on API 36
The app's tab layout uses only Trigger, Icon and Label from expo-router/unstable-native-tabs, with no Host inside the tab bar itself; the Hosts are ordinary @expo/ui controls on the tab screens.
Expo Doctor Diagnostics
Running 21 checks on your project...
21/21 checks passed. No issues detected!
⚠️ react-native-screens 4.27.0 does not change this. Its one Android tabs fix there — "defensively guard against an unexpected menuItem Id" (#4450, backporting #4335) — touches TabsHostA11yCoordinator.kt and concerns bottom-navigation menu item ids, not view id allocation.
Suggested fix
Allocate Host ids from a window no other writer uses, rather than from View.generateViewId():
/**
* View ids for the hosting ComposeView of every Host.
*
* 0x01000000 is above View.generateViewId()'s ceiling (0x00FFFFFF) and below the range
* aapt assigns to R.id (0x7f000000), so this window has no other writer.
*/
private val nextHostingViewId = java.util.concurrent.atomic.AtomicInteger(0x01000000)
- it.id = generateViewId()
+ it.id = nextHostingViewId.getAndIncrement()
0x01000000 is above generateViewId()'s documented ceiling and below aapt's 0x7f000000, so the window has no other writer. The id is used only to key rememberSaveable state; the ComposeView is held by a direct reference and is never looked up by id, so its numeric range is free.
Why not ViewIdGenerator.externalGenerator
That is the hook the screens maintainer points at above, and Expo is in a position to use it in a way an app is not: it is a Kotlin object reachable only from native code, so an app needs a Kotlin initializer and a config plugin, while expo-router could set it from its own native side.
Two things still argue for the range change instead. It would make expo-modules-core depend on a react-native-screens symbol for correctness, when the Hosts it creates have nothing to do with screens. And it only moves which allocator yields: @expo/ui Hosts would still be handing out low integers into the space the RENDERER uses, so the next collision is with a react tag rather than with a tab container. Allocating above 0x00FFFFFF steps out of both at once and needs no coordination with anybody.
⚠️ For completeness: native tabs is documented as alpha ("its API is subject to change"), so this may well be a known rough edge rather than news. It reproduces on the default configuration of that API, though, and the failure is a process death rather than a visual glitch.
The two allocators, side by side on a running process
adb shell dumpsys activity top, with the patch above applied, on an emulator running API 36:
com.swmansion.rnscreens.gamma.tabs.container.TabsContainer #3
androidx.compose.ui.platform.ComposeView #1000001
androidx.compose.ui.platform.ComposeView #1000002
TabsContainer holds 3 — NewArchAwareViewIdGenerator's very first value — and the two Hosts hold 0x01000000 + 1 and + 2. Unpatched, View.generateViewId() hands out 1, 2, 3, 4 …, so the third view in the process to ask it takes id 3, and the next tab switch hands the tab fragment to whatever that view is.
⚠️ The tab container is not the only target. Other screens views in the same dump sit at 0x42, 0x4e, 0x52, 0x54, 0x5e, 0x166, 0x1b4, 0x1b8, 0x1ba — every one of them inside generateViewId()'s 0x1..0xFFFFFF range.
We have been running the patch since 2026-09-09; before it, the same app died with the trace at the top of this report on tab switch.
Happy to open a PR if the approach looks right.
Source: expo/expo