Munchable's phone app ships with a small catalogue of fake products so the scan flow can be exercised on a developer machine with no backend running. Six products and one canned restaurant menu, every call site gated on __DEV__:
/**
* Bundled sample catalog used ONLY in local dev (every call site is gated on
* __DEV__) so the scan → result flow works with no backend. In production,
* products come from the lookup chain, and this catalog is never served.
*/
And in the API module, the claim that made me go and check:
/**
* ... In production the sample fallback is compiled out, so the app never
* serves fabricated data.
*/
The app is also served as a web build at app.munchable.app, which means the production bundle is a file anybody can fetch, including me. So the claim is testable in about four lines.
What is actually in there
const r = await fetch("https://app.munchable.app/_expo/static/js/web/entry-<hash>.js");
const b = await r.text();
b.length; // 3813466
b.split("Hazelnut Cocoa Spread").length-1; // 1
b.split("TRY A SAMPLE MENU").length-1; // 0
Two results, pointing in opposite directions.
TRY A SAMPLE MENU is a label that exists only inside a dev-only branch on the home screen:
const devSamples = useMemo(
() =>
/* Samples: a DEV-only shortcut to exercise a scan without a phone camera.
Compiled out of production so real users never see fabricated data. */
!__DEV__ ? null : menuMode ? (
<>
<Text variant="label" muted>TRY A SAMPLE MENU</Text>
...
Zero occurrences in the bundle. The branch is genuinely gone, along with the route it pushed to (sample=1, also zero). Dead code elimination did its job.
Hazelnut Cocoa Spread is the name of the first fake product, and it is right there in the shipped file, minified and intact:
__d(function(g,r,i,a,m,e,d){"use strict";
Object.defineProperty(e,"SAMPLE_PRODUCTS",{enumerable:!0,get:function(){return s}}),
Object.defineProperty(e,"SAMPLE_BARCODES",{enumerable:!0,get:function(){return o}});
const n=1787e6,t=1723464e3,s={3017620422003:{name:'Hazelnut Cocoa Spread',brand:'Nutino',
product:{barcode:'3017620422003',ingredientsText:'Sugar, palm oil, hazelnuts, ...
That is a Metro module definition, registered in the production bundle. The canned menu is there too. Measured by finding the enclosing __d( call and the next one:
| module | bytes in the production bundle |
|---|---|
| sample products | 2,692 |
| sample menu | 1,730 |
| total | 4,422 of 3,813,466 |
So 0.12 per cent of the bundle is data that no reachable code path can ever read.
Why both things are true at once
These are two different optimisations, and only one of them is running.
Dead code elimination works inside a module. The bundle's own prelude starts like this:
var __BUNDLE_START_TIME__=...,__DEV__=false,process=globalThis.process||{},...
and Metro's transform also inlines __DEV__ as a literal in the modules it processes, so by the time the minifier sees the home screen it is looking at !false ? null : ..., folds it, and the JSX and its strings go with it. Thorough enough that the dev-only UI leaves no trace. Note that the identifier itself is not erased: __DEV__ still appears 27 times across the bundle, because plenty of library code reads the global at runtime.
Tree shaking is the other thing, the one that removes a module nobody uses from the graph. Metro does not do it by default. It resolves import { SAMPLE_PRODUCTS } from '../data/sampleProducts' at the top of the API module, and that import is unconditional, because imports are. The module joins the graph, gets its __d(...) wrapper, and ships. The only thing elimination removed was the code that would have called it.
Which is the general rule worth remembering: eliminating the branch does not remove what the branch referenced. Those are different passes with different scopes, and a bundler can be extremely good at one while doing none of the other.
Moving the import inside the branch does not fix it either, since a static require inside an if is still a static edge in Metro's graph. Actually keeping the bytes out means stopping the module from resolving in a production build: a platform or environment split in the filename that resolves to an empty stub, or a resolver rule in the Metro config. That is real configuration for 4.4 KB, which is why nothing in this post ends with a pull request.
What was wrong was the sentence, not the behaviour
Here is the part I want to be precise about, because "fake product data ships in our production app" is a sentence that deserves a careful reading.
The safety property still holds. Every read of the sample catalogue sits behind __DEV__, there are six of those gates in the app, and the branches are provably folded away in the shipped bundle. No production code path can reach that data, so a real user cannot be shown a verdict computed from a product we invented. That mattered enough to gate in the first place: an app for people managing digestive conditions that occasionally shows a plausible-looking answer about a product that does not exist is not a bug, it is a betrayal.
What was false was the mechanism in the comment. "Compiled out" described the call sites and quietly implied the payload, and I believed it for months because it was written in the file I was reading. The fix is to the comment: say the branches are eliminated and the fixtures are inert, not that the data is absent.
That is a small thing with a general shape. A comment asserting a build-time property is a claim nobody re-tests, because the compiler does not read comments and neither does CI. If the property matters, the check belongs somewhere that runs: a test that fetches the built bundle and greps it is four lines, and it would have either kept the comment honest or told me to delete it.
Try it on your own build
The whole exercise is one fetch away for any Expo web build or any bundle you can download. Pick a string that only exists inside a dev-only branch, pick a string from the fixture that branch uses, and search for both. The pair of answers tells you which passes your bundler is actually running, and no amount of documentation will tell you as reliably.
See the real thing
-
app.munchable.app serves the bundle quoted above. Open the network tab, grab the
entry-*.jsfile, and run the two greps yourself. - munchable.app/answers is the real data surface, 373 pages generated from the curated catalogue, such as is sorbitol low FODMAP. None of this is where the fake products live, which is rather the point.
- munchable.app/recipes is the other half of the catalogue, 38 recipes, which is useful without installing anything.
If you have a fixture file in a mobile app and a comment claiming it is stripped from release builds, that is twenty minutes well spent today.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.