A four-hop incremental strategy, the bugs that only show up in release builds, and an honest look at what an AI assistant is actually good for.
Ten minor versions is not an upgrade. It’s a migration.
Between React Native 0.76 and 0.86 you cross React 18 to 19, the bridge to bridgeless transition, Reanimated 3 to 4, Gradle 8 to 9, and a pile of library breakages that only show up when you actually run the app. If you bump package.json straight from 0.76.9 to 0.86.2 and run yarn install, you’ll get a build failure. You’ll fix it. You’ll get another one. Somewhere around the fifth failure you’ll have lost track of which version caused what, and the only honest thing left to do is git reset --hard.
I know because I nearly did exactly that.
This post is about the approach that worked instead: four deliberate hops, each one verified before the next one started. It took six days on a large production app with 47 autolinked native modules and nine patched packages. I’ll walk through the strategy, the specific bugs, and since I did this with Claude as a working partner, an honest account of where the AI helped and where it sent me down the wrong path.
Why incremental, concretely
People usually justify incremental upgrades with something vague about smaller changes being safer. Here’s the version with actual teeth.
Failures compound. With one version bump, a build failure has one plausible cause. With ten stacked bumps it has ten, and you can’t bisect your way out, because there’s no intermediate state that ever built.
Some breakages are completely silent. I’ll get to this below, but the worst bug in the whole upgrade produced no crash, no error, and nothing in any log. Just a white screen. If that had landed on top of ten simultaneous version bumps, I’d have lost days instead of hours.
A green build proves very little. I’m going to keep repeating this, because it was the most expensive lesson of the whole project. Compiling successfully tells you almost nothing about whether the app works.
Picking your hops
Don’t go version by version. That’s twenty hops and most of them are wasted motion. Hop at architectural boundaries instead:
Hop Versions Why stop here 1 0.76.9 → 0.78.3 React 18 to 19, and bridgeless becomes the default 2 0.78.3 → 0.81.6 Build tooling churn, release-only code paths 3 0.81.6 → 0.84.1 Reanimated 3 to 4, prebuilt React-Core, Gradle 9 4 0.84.1 → 0.86.2 Final catch-up, relatively smallEvery hop got a git tag, so any one of them could be rolled back on its own.
The setup work that made this survivable
Three things, all done before touching a single version number. Skipping any of them costs more than it saves.
1. Get a typecheck baseline (and don’t trust an improvement)
Run tsc --noEmit on your untouched codebase and write down the number.
yarn typecheck 2>&1 | grep -cE 'error TS'
Enter fullscreen mode Exit fullscreen mode
Mine was 466 errors. Not because the codebase was bad, but because most mature React Native apps have accumulated type debt that never blocked a build. The absolute number doesn’t matter. The delta does.
Here’s the thing though: my first measurement said 7. That was wrong, and the way it was wrong is worth remembering. Three files had syntax errors, so tsc bailed out before it ever got to semantic checking. Fixing those three files revealed the real 466.
Which gives you a rule that feels backwards at first:
A sudden drop in type errors is more suspicious than a spike. A spike means new type problems. A drop usually means the compiler stopped early.
Later on, one hop showed 485 → 937. Looked like a disaster. The actual cause was that the RN typescript-config package added an exports map, so "extends": "@react-native/typescript-config/tsconfig.json" quietly stopped resolving. tsc fell back to bare defaults with no esModuleInterop, no skipLibCheck, and no path aliases. The fix was deleting nine characters:
- "extends": "@react-native/typescript-config/tsconfig.json"
+ "extends": "@react-native/typescript-config"
Enter fullscreen mode Exit fullscreen mode
So before you start triaging individual errors, check that your config actually resolves:
npx tsc --showConfig | grep -E "esModuleInterop|skipLibCheck"
Enter fullscreen mode Exit fullscreen mode
2. Inventory your patches
I had nine patch-package patches in play. Every one of them is a landmine. It’s either a stale backport that’s now redundant, or it’s the only thing holding the current version together, and you can’t tell which by looking.
For each patch, write down what it changes, whether upstream has fixed it, and what breaks if you remove it.
One of mine looked like an obvious stale backport. The library’s modern import was correct for our target version, so out it went. pod install failed immediately with a codegen error, which left a generated header missing, which broke the iOS build several targets later. Patch restored.
A patch that looks like a backport to your current version may be the only reason your current version works. Remove patches during a hop, alongside the version bump. Never as pre-work.
3. Write the manual QA script
Do this before you start. List every flow that touches native code: deep links, push notifications, widgets, in-app purchases, camera and gallery, biometrics, WebViews, RTL layouts.
This is the artifact that matters most, because it’s the only thing that catches the bugs I’m about to describe.
The bugs your build won’t catch
The silent white screen
Hop 1 compiled cleanly, installed fine, and launched to a white screen. No crash, no red box, nothing in any log.
What happened: at 0.78, the default app delegate’s bridgelessEnabled started defaulting to newArchEnabled. Our native modules already compiled with RCT_NEW_ARCH_ENABLED=1, so bridgeless silently switched on while the app delegate was still written for the bridge era with zero bridgeless overrides.
The fix was migrating the Objective-C AppDelegate.mm over to Swift, matching the template for that exact version. And I do mean that exact version. My first attempt used a factory pattern from a later release, which doesn’t exist at 0.78 and wouldn’t compile against the real headers.
Then a second bug that the build also didn’t catch. This compiles fine:
override func applicationDidBecomeActive(_ application: UIApplication) {
super.applicationDidBecomeActive(application) // crashes at runtime
}
Enter fullscreen mode Exit fullscreen mode
Swift accepts it because the selector exists somewhere up the UIResponder chain. At runtime it throws doesNotRecognizeSelector, because the parent class doesn’t actually implement it. Three lifecycle methods had this problem. Worth noting the original Objective-C never called super there either.
If you hit this, iOS writes crash reports to ~/Library/Logs/DiagnosticReports/, and lastExceptionBacktrace will name the exact line:
ls -lt ~/Library/Logs/DiagnosticReports/*.ips | head
Enter fullscreen mode Exit fullscreen mode
The release-only failures
Hop 2 passed every debug build and every QA pass, then fell over on assembleRelease:
[Error: ENOENT: no such file or directory, open '.jso/dist/undefined']
Enter fullscreen mode Exit fullscreen mode
Both bugs in that hop lived purely in the release path, because obfuscation and R8 only run when minifyEnabled is true, and debug builds never touch that.
The root cause is a good example of why “it’s just a path bug” is often wrong. Metro’s serializer at the newer version calls processModuleFilter multiple times per module across its passes. We measured 1,311 calls for 437 unique modules, a 3x multiplier. The obfuscation plugin was collecting filenames into a Set keyed only on normalized module path, so those repeat calls silently collided. Once dedup left fewer entries than the raw tagged chunks, later array index lookups ran off the end and returned undefined. String coercion turned that into a literal .jso/dist/undefined path, which multiple unrelated modules then wrote to and read back from. An actual file collision quietly corrupting bundle content, not a cosmetic path issue.
The newer plugin version swapped the Set for an array with unique filename disambiguation. Same 437-module bundle, 874 uniquely named files, no collision.
Build
assembleReleaseat every hop, not just debug. Minification, obfuscation and ProGuard/R8 are a completely separate code path.
Reanimated 3 to 4
Two changes, both mechanical, both easy to miss.
The Babel plugin moved to a different package. It still has to be last in the list:
plugins: [
// ...other plugins
- 'react-native-reanimated/plugin',
+ 'react-native-worklets/plugin',
],
Enter fullscreen mode Exit fullscreen mode
react-native-worklets becomes a direct dependency. Don’t trust the build here, actually check that worklets got extracted:
npx react-native bundle --platform ios --dev false \
--entry-file index.js --bundle-output /tmp/check.jsbundle
grep -c "worklet" /tmp/check.jsbundle
Enter fullscreen mode Exit fullscreen mode
Extrapolate became Extrapolation. The old name still works but it’s deprecated.
Library majors hiding inside a version bump
The riskiest breakages weren’t React Native itself. They were libraries that had to move in lockstep with it.
A carousel library went 4.x to 5.x and dropped its default export. The app crashed on the home screen:
Element type is invalid: expected a string or a class/function
but got: undefined.
Enter fullscreen mode Exit fullscreen mode
That message is the signature of an import resolving to undefined. Fixing the import then surfaced a full API rename underneath it:
import Carousel from
import { Carousel } from
Pagination.Basic
Pagination
width / height
itemSize + style
autoPlay
autoplay
enabled
scrollEnabled
pagingEnabled / snapEnabled
snapMode="page"
onProgressChange={sharedValue}
progress={sharedValue}
data={arr} (pagination)
count={arr.length}
There’s a subtler lesson buried in this one, about double compensation. The app had manual RTL handling: reversing the data array, pinning defaultIndex, flipping the dots with row-reverse. All of it written because v4 had no RTL support. v5 handles RTL natively. Keeping both meant the same correction got applied twice, which put the active dot on the wrong end.
When a library gains a feature you’d previously hand-rolled, delete your workaround. Two corrections cancel out into a brand new bug.
React 19 removed defaultProps for function components
This one deserves its own section, because it’ll hit any app with older dependencies and it fails silently.
React 19 dropped defaultProps support for function components. Libraries that rely on it now get undefined where they expect a default:
export const Flag = ({ countryCode, withEmoji, withFlagButton }) =>
withFlagButton ? (/* render */) : null;
Flag.defaultProps = { withEmoji: true, withFlagButton: true };
Enter fullscreen mode Exit fullscreen mode
With defaultProps ignored, withFlagButton is undefined, the ternary takes the null branch, and the component renders nothing at all. No error, no warning, just a blank space where a flag should be.
The fix is real default parameters, applied through patch-package:
- export const Flag = ({ countryCode, withEmoji, withFlagButton }) =>
+ export const Flag = ({ countryCode, withEmoji = true, withFlagButton = true }) =>
Enter fullscreen mode Exit fullscreen mode
You can find your exposure early:
grep -rn "defaultProps" node_modules/<suspect-package>/lib/
Enter fullscreen mode Exit fullscreen mode
Check the package’s peer dependencies too. If it’s asking for react@^16, assume more of it is affected. In that one library, eight files used defaultProps.
Native code follows React Native’s C++ API
Two libraries failed to compile on the last hop, both because RN changed C++ signatures underneath them:
- An SVG library passed an observer by value where the coordinator now wants a
shared_ptr. - A keyboard library overrode
onConfigurationChanged(newConfig: Configuration?), but the parameter became non-null, so it was overriding nothing and Kotlin compilation failed.
Both were fixed by upgrading the library. Both had been triaged as “optional minor bumps” in my plan. They were not optional.
Native module compile errors after an RN bump usually mean the library needs upgrading, not patching. Check for a newer release first, since maintainers often ship version-guarded fixes:
#if REACT_NATIVE_MINOR_VERSION > 84
Stuff that ate hours and shouldn’t have
Stale native build state. A Reanimated CMake task failed on a missing log file, which was masking the real problem: a missing codegen header. Nothing was actually wrong with the code.
rm -rf android/build android/app/build android/.cxx
find node_modules -maxdepth 3 -type d -path "*/android/build" -exec rm -rf {} +
Enter fullscreen mode Exit fullscreen mode
Do this first whenever a native Android task fails in a weird way right after an RN bump.
CocoaPods crashing inside its own error reporter. On Ruby 3.4, pod install died with Encoding::CompatibilityError from the code that formats error messages, which hid the real error completely.
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pod install
Enter fullscreen mode Exit fullscreen mode
The actual error turned out to be trivial: prebuilt podspecs had changed version and just needed pod update <pod-names>.
patch-package deleting your patches. If you have both yarn.lock and a stray package-lock.json, it defaults to npm, crashes, and deletes the patch file it was in the middle of regenerating. A 768 KB patch vanished on me. So:
npx patch-package <package> --use-yarn
Enter fullscreen mode Exit fullscreen mode
Disk space. Four hops of iOS and Android builds filled a 460 GB disk. DerivedData alone was 32 GB, Gradle caches another 25 GB.
Working with an AI assistant: the honest version
I did this upgrade with Claude (in Claude Code) as a working partner. It genuinely compressed the timeline. It also confidently gave me several wrong answers, and the split between those two outcomes follows a pattern that’s worth understanding before you try this yourself.
Where it was genuinely good
Reading library source to answer a specific question. Instead of guessing whether a newer library version fixed a compile error, it read the actual source and found the version guard. That turns “maybe upgrading helps?” into a verified yes or no in seconds.
Root-causing from evidence. The .jso/dist/undefined failure got diagnosed by instrumenting the plugin’s internals and measuring the call counts, 1,311 calls for 437 modules, rather than pattern-matching on the error text.
Archaeology. git log -S across a year of history kept answering the question “was this ever actually working?” One broken background turned out to have been broken for a year, introduced by a refactor that referenced a style key nobody ever defined. That reframed it from “upgrade regression” to “pre-existing bug,” which completely changed what to do about it.
Differential verification. This was the strongest technique of the whole project: typecheck the previous hop’s source against the new node_modules in a scratch worktree. Same error count means the upgrade introduced nothing new. It turns “485 errors, is that bad?” into an actual answer.
git worktree add /tmp/prev-hop <previous-tag>
ln -s "$PWD/node_modules" /tmp/prev-hop/node_modules
cd /tmp/prev-hop && npx tsc --noEmit 2>&1 | grep -cE "error TS"
Enter fullscreen mode Exit fullscreen mode
Where it kept failing
Every single visual bug. The pattern here was stark. On build failures, compile errors and dependency resolution it was reliably right, because those have verifiable outputs. On “this doesn’t look right” it was wrong over and over, because it was reasoning about rendering from static code with no way to see the result.
On one missing background it gave me three separate confident diagnoses before finding the real cause. On a missing icon, two. Every explanation was plausible and internally consistent. Every one was wrong.
It introduced a regression. While fixing image flicker it added a custom memo comparator that compared eight named props and silently dropped ...props. On a component used across the entire app, that’s a terrible trade for a fix that was never visually confirmed. I reverted it.
“It compiles” quietly became a stand-in for “it works.” More than once, work got reported as verified on the strength of a passing typecheck, a clean bundle and green unit tests, none of which exercise rendering at all. The one time a fix was confirmed on device, it was still sitting uncommitted, and a later cleanup threw it away.
The rules I ended up with
- Verifiable output means trust but check. Visual output means don’t trust at all. Compile errors have ground truth. Layout doesn’t.
- Never accept “it builds” as done. Ask directly: how was this verified? If the answer is only typecheck, bundle and tests, it isn’t verified.
- Commit working fixes immediately. Uncommitted work gets thrown away during cleanup. This cost me a confirmed fix.
- Cap speculative attempts at two. After two failed diagnoses, stop and get real measurements. Instrumentation, DevTools, a screenshot, anything with ground truth.
- Watch the blast radius. A fix for one screen that edits an app-wide shared component needs far more evidence than a local change does.
-
Make it prove pre-existence. “Was this broken before the upgrade?” is answerable with
git log -Sand a worktree. Insist on seeing the evidence, because the answer changes your plan.
The short version: excellent at the mechanical 80%, which is version research, cross-referencing, native build errors, git archaeology and writing up findings. Unreliable at the visual 20%. Knowing which mode you’re in is the entire skill.
The checklist
Before you start
- [ ] Record the typecheck baseline, and fix any syntax errors masking it
- [ ] Inventory every patch: what it does, why, what breaks without it
- [ ] Write the manual QA script
- [ ] Make sure you have 100 GB+ free disk
- [ ] Check Node against the target’s
enginesfield
Every hop
- [ ] Read release notes for every version you’re crossing
- [ ] Pull the official upgrade diff (
react-native-community/rn-diff-purge) instead of guessing - [ ] Bump RN and all
@react-native/*packages in lockstep - [ ]
yarn install, confirm every patch still applies - [ ]
pod install(use--use-yarnwith patch-package, UTF-8 locale for pods) - [ ] Typecheck, compare against the previous hop via worktree
- [ ] Build iOS and Android, in debug and release
- [ ] Run the app. Work through the QA script.
- [ ] Tag the hop
Red flags
- Type errors drop sharply → the compiler probably stopped early
- Weird native Android failure → clear the build dirs first
- Component renders nothing → check
defaultPropsunder React 19 - Works in debug, fails in release → obfuscation or R8
- Builds fine but looks wrong → nothing in your build pipeline is going to catch this
Wrapping up
Four hops, six days, roughly a dozen real breakages. Four of them were invisible to every automated check we had: a white screen with no logs, two release-only failures, and a component silently rendering null under React 19.
The strategy that worked wasn’t clever. It was just refusing to let two unverified changes exist at the same time. Every hop got tagged, typechecked against its predecessor, built on both platforms in both configurations, and only then extended.
If you take one thing from this: a green build is where verification starts, not where it ends. Every bug that cost me real time compiled perfectly.
Written up from a real production upgrade. The AI section reflects what actually happened, including the parts that went badly, which felt more useful than a success story.

답글 남기기