React Native tooling
Why __DEV__ is false in release builds, and how we gated dev tools by config instead
We had a set of in-game developer shortcuts hidden behind __DEV__. Then we needed one of them in a release build to validate a screen, and it was gone. __DEV__ was false.
The gate that protects a feature in development can be the same gate that hides it when you finally need it.
We ran into this in Daily Sudoku: Offline Puzzle. The game has a small set of developer shortcuts: solve the board, advance the timer to the difficulty target, add score points, add a credit. They exist to reach states that are slow or annoying to reach by hand, especially the victory screen, which normally requires actually finishing a puzzle.
Those shortcuts were guarded by __DEV__. Every handler started with if (!__DEV__) return;, and the button that opens them was wrapped in {__DEV__ ? ... : null}. That felt correct. Development-only tools should not ship. But it was the wrong gate, and the mismatch only showed up when we tried to validate the victory screen on a build that behaves like the real app.
The moment it bit us
We had just finished a visual redesign and wanted to confirm the victory screen looked right on device, not just in code review. The plan was ordinary: build a release-style APK, install it on a Redmi Note 7 and an emulator, play into a win, and check the result screen.
The problem is that winning a real puzzle to inspect one screen is slow, and we had a shortcut for exactly that: “solve board.” Except the shortcut was not there. The release APK had __DEV__ set to false, so the developer button never rendered. The tool we built to reach the victory screen was invisible in the only build where we wanted to look at it.
The reflex is to reach for a debug build, where __DEV__ is true. We deliberately did not. Our own repo rule says release-like validation should not use debug APKs, because a debug install can carry a different signature and runtime and quietly invalidate what you think you are testing. On top of that, our debug dev server had an unrelated bundling issue with a Skia dependency, so the debug path was both wrong in principle and broken in practice that day.
So the honest question was not “how do I get a debug build running.” It was “why is a testing tool tied to a flag that testing builds do not have.”
What DEV actually is
__DEV__ is a global that the React Native and Expo bundlers replace at build time. In a development bundle served by Metro, it is true. In a bundle compiled for release, the minifier replaces __DEV__ with false and then strips the dead branches behind it.
That is great for its real job: removing development-only cost from shipped code. Warnings, invariants, and expensive checks vanish from the production bundle because the compiler can prove the branch is dead.
But it means __DEV__ answers one specific question: “was this JavaScript bundled in development mode?” It does not answer the question we actually cared about: “should this build expose developer tooling?” Those two questions overlap while you work on your laptop with Metro running. They stop overlapping the moment you compile a release bundle for QA, which is the whole point of a release-style test build.
A release-signed build made for testing is still a release bundle. __DEV__ is false in it. The environment can be test or local, the app can be full of test affordances, and __DEV__ will still say “no.”
Moving the gate to runtime config
The fix was to stop asking the bundler and start asking the app’s own configuration.
The project already generates a typed config per environment from YAML files: config.production.yml, config.test.yml, config.local.yml. Each file is self-contained and carries a devModeEnabled flag. Production sets it to false. Test and local set it to true. A build step turns the selected YAML into a typed module the app imports, so the flag is a normal, statically typed value at runtime, not a magic global.
The settings screen already gated its own developer entry on config.devModeEnabled. The in-game shortcuts were the outlier still using __DEV__. So the change was small and mechanical: every if (!__DEV__) return; became if (!config.devModeEnabled) return;, and the button render moved from {__DEV__ ? ... } to {config.devModeEnabled ? ... }.
// Before: tied to how the bundle was built
if (!__DEV__) return;
// After: tied to what this environment declares
if (!config.devModeEnabled) return;
Nothing about the shortcuts themselves changed. What changed is the meaning of the gate. Now the tooling appears when the environment says developer mode is on, regardless of whether the JavaScript was bundled for development or release.
What this actually buys you
The payoff is that a release-signed build compiled with the test environment now surfaces the developer tools on a real release runtime. Production, built with devModeEnabled: false, never shows them. Same code, two honest outcomes driven by config instead of by an accident of how the bundle was compiled.
That directly unblocked the original task. We built a release APK with the test environment, installed it on the emulator, opened the game, and the developer button was finally there. One tap on “solve board,” the puzzle filled in, and the victory screen appeared exactly as designed. We could inspect the real screen on the real runtime, then rebuild the production APK to leave both devices in the shipping state.
The debugging conversation changed from “the tool is missing in this build” to “this build declares developer mode, so the tool is present.” That is a property you can reason about, not a side effect you have to remember.
The guardrail that keeps this safe
Moving a gate from a compiler flag to a config flag is powerful, and power here is also risk. A config flag can be flipped on in more places than __DEV__ ever could, so the boundary has to be explicit.
Our rule is narrow on purpose. devModeEnabled may only gate development-only gameplay shortcuts and test affordances: advancing time, granting points or coins, forcing a win or loss, opening a debug panel. It must never gate anything commercial or runtime-critical. Ads, analytics, consent, crash reporting, purchase flows, production configuration, and store-facing behavior are off limits. Those systems decide their own behavior from their own inputs, never from a developer switch.
The reason is simple. If devModeEnabled ever influences monetization or consent, then a test build is no longer testing the product users get. The flag would start hiding the exact behavior it is supposed to help you verify. Keeping it scoped to throwaway shortcuts is what makes it safe to have those shortcuts run on a release runtime at all.
The one place DEV has to stay
We did not delete __DEV__ from the codebase, and that was deliberate.
The environment resolver still uses it. When the app boots and no explicit environment is set, it falls back to local when __DEV__ is true and production otherwise. That gate cannot move to config, because it is the code that decides which config to load. Asking config.devModeEnabled there would be circular: the config does not exist yet at the moment this function runs. This is the correct home for __DEV__, because the question really is “is this a development bundle,” which is exactly what the flag knows.
Test setup keeps __DEV__ too, where a test stubs the global to exercise that resolver logic. That is not product behavior; it is a unit test asserting how bootstrap chooses an environment.
The distinction is the whole lesson. Use __DEV__ for questions about the bundle. Use config for questions about the product. Bootstrap environment selection is a bundle question. Whether to show developer tools is a product question.
How to apply this to your own app
If you have tooling hidden behind __DEV__ that QA or you will ever need in a compiled build, the migration is short:
- Add a boolean to your per-environment config, off in production and on in test and local builds.
- Replace
__DEV__gates on developer or test affordances with that config flag. - Keep
__DEV__for environment bootstrap and for stripping genuine development-only cost. - Write down, in one sentence, what the flag is allowed to gate, and treat monetization, consent, and analytics as permanently outside that list.
- Prove it by building a release-signed test build and confirming the tooling appears there while a production build hides it.
None of this is exotic. It is mostly noticing that “built in development mode” and “allowed to show developer tools” are different questions that happen to agree on your laptop and disagree everywhere else.
What we would watch for next
The remaining risk is discipline, not code. A config flag is easy to reach for, so the next person could gate something they should not. The mitigation is the written rule and code review, not a cleverer mechanism.
We would also keep an eye on build hygiene. Because the tooling now depends on which environment compiled the bundle, it matters that the release scripts force production for shipping artifacts and only use test or local for internal validation. A test-environment APK is a fine thing to hold on a QA device for an afternoon; it is not the thing you upload to a store. Naming and scripting that difference clearly is what keeps a useful testing affordance from turning into a shipping mistake.
The gate is now honest about what it protects. That is the part worth keeping.