This article was originally published on Medium
Upgrades are supposed to be boring. Ours was a nightmare.
When we moved our production app to Expo SDK 54 and ReactNative 0.81, the UI regressed in multiple places (text clipping, broken tabs, safe-area overlap). At the same time, Play Store policy changes forced native build updates (Target SDK and 16 KB page size readiness).
This post is a practical write-up of what broke, why, and the fixes that made the app stable again.
The Real Upgrade Scope
This was not just “bump Expo”. These were the actual versions in our codebase.
Runtime and SDK
- Expo SDK:
~54.0.27 - ReactNative:
0.81.5 - React:
19.1.0 - React Navigation:
@react-navigation/*^7.x - Safe area:
react-native-safe-area-context5.6.2
Android toolchain
- Gradle wrapper:
8.14.3 - compileSdk / targetSdk:
35 - buildTools:
35.0.0 - Hermes enabled:
hermesEnabled=true - New Architecture enabled:
newArchEnabled=true
Patch policy
patch-packageinpostinstall- Custom patch:
patches/expo-modules-core+3.0.26.patch
The mismatch between config files, Gradle scripts, and JavaScript dependencies is where most of the pain starts.
Why Upgrades Break Even When Builds Pass
Expo apps are driven by two worlds:
- JavaScript dependencies (Expo, ReactNative, navigation, etc.)
- Native toolchain (Gradle, AGP, NDK, SDK levels)
When those drift out of sync, you can get:
- Builds that pass locally but fail in CI
- Correct UI on one device and broken layout on another
- Play Console warnings even when the app runs fine
This kind of upgrade is a system migration, not a dependency update.
Why We Didn’t Catch Some Issues in Development (But Saw Them After Deployment)

Some bugs were barely visible during dev, then obvious in internal testing or production. That’s common in ReactNative upgrades, and it usually happens for these reasons:
- Debug vs Release differences: Release builds can render text and layout slightly differently (font rasterization, optimization, Hermes behavior).
- Device coverage gaps: Dev testing is often 1–2 devices. Real users bring small screens, weird DPI, and different OEM font stacks.
- Accessibility settings: System font size and “Display size” settings can completely change layouts.
- Navigation mode differences: Gesture navigation vs 3-button navigation changes bottom insets and can break sticky buttons or tab bars.
- Real data is longer: Real phone numbers, user names, and localized strings expose clipping that dummy test data never triggers.
- Cache drift: CI caches and Gradle caches can hide issues locally and show failures after reinstall or in pipelines.
Lesson: after a major upgrade, do a short “release-like QA pass” early, even if everything looks fine in dev.
Play Store Policy Pressure (Target SDK + 16 KB Page Size)
Two policy changes landed in the middle of this upgrade.
Target SDK policy
Google requires apps to target recent SDKs. We aligned on:
compileSdk 35targetSdk 35buildTools 35.0.0
These values are set via expo-build-properties and reinforced in Gradle.
16 KB page size compatibility
Play Console flags native libraries not compatible with 16 KB memory pages (a requirement for upcoming Android releases). We addressed that by upgrading and pinning:
- NDK:
27.1.12297006 - Gradle wrapper:
8.14.3
UI Regressions (and the Fixes)
1) Text Clipping, Wrapping, and Scaling Issues (Across Screens)
This was not limited to one screen. After the upgrade, we saw text issues in headers, cards, buttons, helper messages, and any place that displays long dynamic strings.
Common symptoms
- Text clipped at the top/bottom (especially headings on Android)
- Labels wrap unexpectedly or get cut off inside buttons/cards
- Long strings (names, phone numbers, translations, API text) push layout outside the container
- Inconsistent line height across devices
Why it happens
It usually comes from a combination of:
- Android text rendering default changes (glyph padding and font metrics behavior)
- Font scaling and accessibility settings
- Different OEM fonts
- Fixed-height containers combined with long text
- Missing wrapping/ellipsis rules for long strings
Fix pattern A: Normalize text defaults globally (best baseline)
// src/theme/textDefaults.ts
import { Platform, Text, TextInput } from 'react-native';
export const configureTextDefaults = () => {
const includePaddingStyle =
Platform.OS === 'android' ? { includeFontPadding: true } : undefined;
Text.defaultProps = {
...(Text.defaultProps || {}),
allowFontScaling: false,
maxFontSizeMultiplier: 1.1,
style: [Text.defaultProps?.style, includePaddingStyle].filter(Boolean),
};
TextInput.defaultProps = {
...(TextInput.defaultProps || {}),
allowFontScaling: false,
maxFontSizeMultiplier: 1.1,
};
};
Run it once:
// App.tsx
import { configureTextDefaults } from './src/theme/textDefaults';
configureTextDefaults();
Fix pattern B: Wrap long dynamic strings safely
Use this whenever content can grow unexpectedly.
<Text
style={{ lineHeight: 24, textAlign: 'center' }}
numberOfLines={10}
>
{message}
</Text>
If you use NativeWind/Tailwind:
<Text className="leading-6 text-center break-words text-wrap" numberOfLines={10}>
{message}
</Text>Fix pattern C: Stabilize row layouts (common in cards and list items)
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ flex: 1 }} numberOfLines={1} ellipsizeMode="tail">
{title}
</Text>
<Text>{rightLabel}</Text>
</View>
2) Bottom Tabs Overlapped Content or Wrapped Labels
Symptoms
- Labels wrap to two lines
- Tab bar overlaps content on gesture navigation
- Taps misfire on small devices
Root cause
React Navigation 7 + safe-area changes require dynamic insets and consistent sizing.
Fix: Safe-area aware tab bar sizing
const TAB_WIDTH = 90;
const BASE_TAB_BAR_HEIGHT = 64;
tabBarItemStyle: {
width: TAB_WIDTH,
paddingVertical: 8,
},
tabBarStyle: {
height: BASE_TAB_BAR_HEIGHT + insets.bottom,
paddingBottom: Math.max(10, insets.bottom),
},
3) Safe Areas and Sticky Buttons
Symptoms
- Floating buttons overlap the home indicator
- Scroll views end too early, hiding content
Fix: Always include bottom inset padding
const contentBottomPadding = Math.max(24, insets.bottom + 24);
<ScrollView contentContainerStyle={{ paddingBottom: contentBottomPadding }}>
{/* ... */}
</ScrollView>

4) Splash Screen and Launcher Assets (Android 12+)
Symptoms
- Black flash on cold start
- Cropped splash assets on some densities
Fix
- Regenerated density-specific splash assets
- Updated inset drawables and splash styles
- Refreshed launcher icons to Android 12+ templates

Build Failures We Had To Fix
1) Toolchain Drift (Gradle, AGP, Caches)
Symptoms
- Plugin incompatibility errors
- CI fails while local builds pass
Fix
We aligned the toolchain:
- Gradle wrapper:
8.14.3 - compileSdk / targetSdk:
35 - Verified Hermes + New Architecture toggles
Then we invalidated caches to avoid stale Gradle artifacts.
2) expo-modules-core patching
We temporarily patched expo-modules-core to unblock builds:
- Patch file:
patches/expo-modules-core+3.0.26.patch - Applied via
postinstall
This is easy to miss because it is not visible in package.json alone.
The Checklist We Run After Every Upgrade
- Check text clipping and line-height issues across Android screens
- Verify bottom tabs on gesture navigation and 3-button navigation
- Test long dynamic strings and long locale strings
- Confirm splash screen behavior on cold start
- Clear CI caches after toolchain changes
After days of debugging, patching, and testing, we made it stable. Here’s the core philosophy that got us through.

Final Takeaway
Most of the breakages were not app-specific. They were defaults changing under our feet: text rendering, safe-area insets, Play Store policies, and Android splash rules.
If you are about to upgrade, start with a version audit, normalize text globally, and validate tabs and safe areas early. It will save you days.
If you’re building a SaaS or mobile product and want an engineering team that can ship safely through upgrades, Play Store policy changes, and production regressions, we can help. Click here to book a free SaaS plan & quote.
