DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our web app is the phone app: 103 files, 14 web branches, one .web.tsx

Munchable is a gut health scanner: scan a barcode, get one verdict weighed against the conditions you manage. The app is Expo Router and React Native, built for iOS and Android through EAS. It also runs in a browser at app.munchable.app, signed in, camera and all, from the same 103 TypeScript files, and while the store listings are still ahead of us the browser build is live now.

Here is the whole size of the web-specific surface:

$ grep -rn "Platform.OS" src app | wc -l
19
$ grep -rn "Platform.OS === 'web'" src app | wc -l
14
$ grep -rn "Platform.select" src app | wc -l
0
$ find . -name "*.web.*" -not -path "./node_modules/*"
./src/components/Stack.web.tsx
Enter fullscreen mode Exit fullscreen mode

Fourteen branches and one file. This post is about which fourteen, because the interesting thing is not that react-native-web works, it is that the branches cluster in exactly three places: navigation, the browser's own APIs, and app store policy.

The config is three lines and a rewrite

// app.json
"web": { "bundler": "metro", "output": "single" }
Enter fullscreen mode Exit fullscreen mode

output: "single" means one index.html and a JS bundle, no per-route HTML. That needs a catch-all rewrite so a deep link does not 404, which on Vercel is:

{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
Enter fullscreen mode Exit fullscreen mode

Two header rules go with it, and the first one is a deliberate decision rather than a default:

{ "source": "/(.*)", "headers": [{ "key": "X-Robots-Tag", "value": "noindex, nofollow" }] },
{ "source": "/_expo/static/(.*)", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }
Enter fullscreen mode Exit fullscreen mode

The app is noindex on purpose. Every URL in it renders the same empty div until JavaScript boots, so anything a crawler managed to index would be a blank page competing with the marketing site, which is statically rendered and is the surface we actually want in search results. The fingerprinted bundle under /_expo/static/ is immutable for a year; everything else must revalidate, so a shell that boots the wrong bundle version cannot be cached.

One consequence of "single" caught us out. Expo's +html.tsx document shell is only honoured for static and server output, so with "single" there is no way to colour the document from it. The app's pages are cream, and the untouched document behind them is white, which shows during overscroll and under any screen shorter than the viewport. The fix is four lines at module scope in the root layout:

if (Platform.OS === 'web' && typeof document !== 'undefined') {
  document.documentElement.style.backgroundColor = '#FFF8EC';
  document.body.style.backgroundColor = '#FFF8EC';
}
Enter fullscreen mode Exit fullscreen mode

The typeof document guard is not decoration. The same module gets evaluated in Node by our test runner.

The one file: the navigator

Stack.web.tsx exists because the native stack has no transitions on web. It renders every route as a pile of absolutely positioned views and swaps which one is visible. That flat layout is actually what we want, because the app's scroll views behave correctly inside it, so we kept it and added the motion on top: a wrapper view per screen that slides in from the right when pushed, and holds its own removal until it has slid back out.

Expo Router's vendored JavaScript stack was the obvious alternative and we rejected it. It sizes card content for body scrolling and toggles pointer events from animation listeners, which on web produced pages that would not scroll to the bottom, or would not scroll at all until a reload. A stack that animates nicely and cannot scroll is not a trade.

Three details in that file are the kind of thing you only find by shipping:

Every route is a transparent modal. The native stack hides every route except the focused one, unless the route above it is transparent. A slide-in has to reveal the page underneath, so the page underneath has to still be painted. Each screen paints its own background, so nothing shows through.

contentStyle: { overflow: 'hidden' }. Without it, a page translated one viewport to the right is still part of the document, so the browser grows a scrollable area to reach it and leaves the app in the top-left quarter of a page twice its size.

Blur before the accessibility tree is rebuilt. The stack marks unfocused routes aria-hidden. At the moment of a push, the browser's focused element is still the button that was tapped, inside the page that is about to be hidden, and Chrome refuses to apply aria-hidden to an ancestor of the focused element. So a page losing focus drops any focus it holds, in a layout effect, in the same task as the DOM update:

useLayoutEffect(() => {
  if (focused || typeof document === 'undefined') return;
  const node = host.current as unknown;
  const active = document.activeElement;
  if (node instanceof HTMLElement && active instanceof HTMLElement && node.contains(active)) {
    active.blur();
  }
}, [focused]);
Enter fullscreen mode Exit fullscreen mode

The branches that are really about the browser

Four of the fourteen are a browser doing something a phone does not:

  • Auth storage. supabase-js gets a SecureStore adapter on native and storage: undefined on web, which leaves it on its own localStorage handling.
  • Google sign-in. On web a popup is unreliable between blockers and COOP, so we do a full-page redirect and consume the returned #access_token fragment on boot, before the account gate renders, so nobody sees a flash of the sign-in screen. The same fragment mechanism carries a session from the marketing site into the app, which I wrote up in The session rides in the URL fragment.
  • Data export. Native shares the JSON through the share sheet. Web builds a Blob, makes an object URL and clicks an anchor, which is the only "download a file" primitive the platform has.
  • Camera autofocus. autofocus="on" in a browser, "off" on native, which reads like a typo until you know that on native the continuous mode is the one that keeps hunting and blurring the label. That story is here.

The branch that pays for the build

The paywall:

{Platform.OS === 'web' ? (
  // In a browser the user can pay directly with Stripe. Native builds must
  // NOT show a checkout button/link (Apple/Google anti-steering); they
  // get the informational note below instead.
Enter fullscreen mode Exit fullscreen mode

Apple and Google's anti-steering rules mean a native build cannot show a button that takes you to our own checkout. A browser can. So the web build is not a nice-to-have demo of the phone app, it is the surface where somebody can actually buy a subscription without a platform taking a cut, and the price it quotes comes from the same shared package the marketing site and the Checkout Session use.

That is the argument for paying the react-native-web tax at all. One product, three runtimes, and the one place the runtimes genuinely disagree is about who is allowed to take your money.

Open app.munchable.app in a desktop browser and it is the real thing: sign in, search a product, push a screen and watch it slide in from the right rather than appear. Open the paywall and there is a Get Premium button on it. The native build of that same screen, from that same file, does not have one.

Top comments (0)