I started building this app in a lecture hall.
I used to photograph the whiteboard during class. A few shots of a derivation here, a few of a slide there, across three or four courses in a day. Then I’d get home, open my camera roll, and find four hundred nearly identical photos of whiteboards with no idea which one belonged to which lecture.
So I’d sort. Every evening. And every evening I’d think: the mess didn’t start when I opened the camera roll. It started hours earlier, every time I pressed the shutter without saying where the photo belonged.
That’s ToFolder. You pick the folder first, then you shoot. The photo is filed the moment it exists.
Simple idea. Two weeks of work, I thought. It took twenty-five builds before the app would open at all.
Here is every single thing that broke, in order, and what each one actually was. None of them were what I thought they were.
The Crash That Wasn’t a Crash
First TestFlight build. I sent it to my phone, tapped the icon, watched the splash screen, and got this:
Cannot read property ‘ErrorBoundary’ of undefined
My first theory was Babel, and it was a reasonable one, because when I looked, the project had no babel.config.js at all. It had never existed. Not in the current tree, not anywhere in the git history. So babel-preset-expo and the expo-router transform had never once run.
I added the config. Rebuilt. Waited.
Identical crash.
The real cause took another day to find, and it’s the kind of thing that makes you feel stupid afterwards even though nothing in the tooling warns you: expo-router treats every single file under app/ as a route.
I had app/(main)/components/gallery/types.ts sitting there. Types and a couple of helper functions. No component, no default export. Router tried to load it as a screen, got undefined, and threw an error message that points at absolutely nothing useful.
I moved everything that wasn’t a route out of app/. Crash gone.
The part worth remembering: expo export doesn’t catch this. Your bundle builds cleanly. It’s a runtime routing failure, so everything looks perfect right up until the app dies on a real phone.
Then Hermes Started Corrupting Its Own Memory
Build 4. Splash screen, then the app just vanished. Not a JavaScript error this time. No error boundary, no red screen, nothing. Just gone.
iOS writes a .ips crash log for that. Mine was enormous, roughly thirty thousand tokens of thread dumps, and buried in the middle was this:
EXC_BAD_ACCESS (SIGSEGV)
Thread: com.facebook.react.runtime.JavaScript
hermes::vm::DictPropertyMap::findOrAdd
hermes::vm::JSError::setMessage
And on another thread, convertNSExceptionToJSError.
Read that sequence slowly, because it’s genuinely interesting: a native module threw an Objective-C exception. Hermes tried to convert it into a JavaScript error. And in the act of constructing that error object, it corrupted its own heap and took the process down.
This is expo/expo#44606. iOS 26 changed VM layout assumptions, and the prebuilt Hermes binary shipped with Expo SDK 54 was never rebuilt for it. Not my code. Not fixable in my code.
I tried two things that felt clever and were not:
Setting jsEngine: "jsc" to escape Hermes entirely. This is silently ignored. The New Architecture forces Hermes, and the New Architecture is required by Reanimated 4, which I was using. I burned a build to learn that, and the crash log still cheerfully said hermes.framework.
Downgrading Reanimated. This fights the SDK instead of the bug, and creates two new problems for every one it solves.
What actually worked: upgrading Expo SDK 54 to 56. React Native 0.81 to 0.85. Two major versions on a project that had never successfully shipped once. It took a week and it was the right call, because SDK 56 ships a Hermes built for iOS 26.
A Different Crash, Which Is a Strange Thing to Celebrate
Build 6. The heap corruption was gone. In its place: a clean fatal JavaScript exception, RCTExceptionsManager.reportException, with the JS thread sitting inside Hermes stringPrototypeSplit.
Something was calling .split on undefined. There is no .split( anywhere in my
So I built a dev client and ran the whole app locally, where it worked fine, and just read the console. And there it was, a warning I’d been scrolling past for weeks:
Failed to load audio/subtitle tracks for file:///
Two places in my code passed '' to useVideoPlayer when there was no video to show. expo-video reads an empty string as the path file:///, fails to load it, and shrugs. In development that’s a yellow warning. In a release build it’s fatal.
The fix was changing '' to null in two files. Two characters. Six builds of context to find them.
react-native-screens, and Four Ways to Be Wrong
Build 7. Crash again, and this time the main thread was very specific:
RNSScreenStackView
UINavigationController _updateBars
UINavigationBar layoutSubviews
Swift Dictionary._forceBridgeFromObjectiveC
swift_dynamicCast
A known iOS 26 crash in react-native-screens. Version 4.25.2, which is exactly what SDK 56 pins, and which was the latest release in existence. There was nothing to upgrade to.
The person testing it gave me the detail that mattered: the app launched fine, the onboarding slides worked perfectly, and it died the instant they tapped “Get Started”. That button does router.replace('/(main)'). Which is the exact moment a second, nested UINavigationController gets created.
So I went after the nesting.
- Build 8. Flattened the whole navigation into a single root Stack. Deleted the nested layout entirely, renamed the route. No second navigation controller can exist now. Still crashed, this time at
setNavigationBarHidden:animated:. - Build 9. Set
animation: 'none'on the Stack, in case the transition animation was the trigger. Still crashed, and the stack shifted slightly toupdateScreenEdgesDeferringSystemGestures. - Build 10. Called
enableScreens(false)at the top of the root layout, which is supposed to disable native screens entirely and fall back to plain JS views. This is silently ignored by expo-router’s native-stack on the New Architecture. The crash log still showedRNSScreenStackView, which is how I found out. - Build 11. Gave up on configuration. Wrote a patch-package patch wrapping both
setNavigationBarHiddencalls in@try/@catchwith ananimated:NOfallback, and wiredpostinstallso EAS would apply it in the cloud. That got past it.
Four builds to learn that it wasn’t nesting, wasn’t animation, and wasn’t something a flag could turn off. Every screen transition’s navigation-bar update was crashing, full stop.
One More, and Then It Opened
Build 12 gave me a race condition in Reanimated’s worklets, AnimationFrameBatchinator::flush fighting performOperations during a fast navigation transition. That one was already documented, which after everything felt like a gift.
Builds 13 and 14 didn’t even compile. EAS died at npm install with ERESOLVE peer conflicts. The fix was an .npmrc containing legacy-peer-deps=true, and two more builds gone.
Build 15 opened. And kept opening.
I Learned Nothing, Apparently
Months later I added AES-256 encrypted export, the feature I’m proudest of. 124 tests passing. Type-check clean. Lint clean. I shipped it to TestFlight expecting a formality.
Three bugs were waiting. None of them was the kind of thing a test can reach.
The share sheet that silently never appeared. iOS refuses to present a UIActivityViewController while another modal is still animating closed, and it fails without a word. No sheet, no error, nothing in the console. I was closing the key screen and calling share in the same tick, so the archive was built, encrypted, and went absolutely nowhere.
Plain export never showed this, because several seconds of zipping happen between its sheet closing and the share call. The encrypted path was just fast enough to lose.
The fix: share from the modal’s onDismiss, which only fires after the animation completes. And keep the sheet mounted with visible={false} instead of unmounting it, because onDismiss can’t fire on a component that no longer exists.
A key that opened the archive nowhere but inside my own app. The generator produced ABCDE23456FGHJK789MN. The screen displayed and copied ABCDE-23456-FGHJK-789MN, with dashes, because that’s far easier to read and retype.
My import normalises dashes away before comparing. So archives opened perfectly in ToFolder, and 7-Zip, WinRAR and iOS all rejected the key the user had just copied. Someone receiving my archive had the file, a key that looked completely correct, and no way in.
The whole promise of the format is that it’s a standard ZIP any computer can open. That promise was broken for a week, with every test green.
Export cleanup deleting a restore that was already running. Export clears its working directory in the finally after Sharing.shareAsync. But that promise doesn’t settle when the share sheet opens. It settles when the user comes back to the app from WhatsApp.
So: export a folder, share it, leave, come back, tap restore. The export’s cleanup fires right about then and deletes the working directory out from under an import that already started. Extraction fails on a folder that existed a second ago, with an error naming a path that belongs to a completely different screen.
The One Thing I’d Actually Tell You
Every bug in this story was invisible in development.
Hermes runs interpreted locally and compiles to bytecode for release. The native layer behaves differently under optimisation. Modals animate on a real device and not in your imagination. A cleanup fires when a user comes back from another app, which is a thing that simply cannot happen while you’re staring at a simulator.
A development build proves your logic works. It proves nothing at all about your app.
Some smaller things I’d pass on:
- A different crash is progress. Builds 4 through 15 look like pure failure written down like this. They weren’t. Each new crash meant the previous one was genuinely fixed and the failure had moved one layer down.
- Parse crash logs with a script. A
.ipsfile is tens of thousands of tokens. Print the crashing thread’s frames and nothing else. The line that matters is usually four frames down, and you will never find it by reading top to bottom. - When the bug is upstream, say so out loud and move on. Three of these were open GitHub issues in libraries I didn’t write. The fix was a version bump and a patch file, not insight. I lost days hunting for my own mistake inside code that wasn’t mine.
- Test the sequence an actual human performs. Export, share, leave the app, come back, restore. No unit test describes that. A real user does it on day one, which is exactly when they did.
ToFolder is on the App Store now. Thirteen languages, 151 tests, one one-time purchase, and every file stays on the device.
Twenty-five builds. Almost none of them were about my code.
