Munchable is an Expo app that ships to iOS, Android and the browser from one codebase. The browser build is not a marketing shell, it is the actual app served as a single page app at app.munchable.app, so everything the phone can do, the web build can do. The marketing site next to it, munchable.app, is a separate Next.js app.
That arrangement produces a specific category of bug: something that works in the simulator and is silently inert in the browser. The worst one we shipped was every confirmation dialog in the app.
The empty class
React Native's Alert.alert is a no-op on react-native-web. Not a warning, not a thrown error, not a console message. Its Alert export is, more or less, this:
class Alert {
static alert() {}
}
Which means every button built on it worked perfectly on a phone and did precisely nothing in a browser. Sign out. Delete account. Report incorrect data. Discard unsaved label photos. A user clicks, the app considers the matter closed, and nothing moves.
A do-nothing button is the nastiest class of failure I know, because there is nothing to find. No stack trace, no failed request, no red text. Every test that exercised the handler passed, because the handler ran fine. It was the dialog it was waiting on that never existed.
The second bug, on iOS this time
The obvious fix is to stop using Alert and render your own dialog: a small zustand store holding the current request, plus a <DialogHost> that renders it. Mount one host at the app root and you are done.
You are not done. On iOS, a React Native <Modal> presents from its nearest view controller, and several of our screens (result, capture, menu, paywall) are themselves native modal screens. A host mounted at the app root is asking the root controller to present while it is already presenting one of those, and UIKit refuses. So the same two buttons on the product result screen, "No, it's different" and "Report incorrect data", went straight back to doing nothing, this time only on iOS.
The fix is a host registry rather than a single host:
/**
* Mounted hosts, in mount order. Only the last renders.
*
* On iOS a react-native <Modal> presents from its nearest view controller, and
* `result`, `capture`, `menu` and `paywall` are native modal screens: a host
* mounted at the app root would be asking the ROOT controller to present while
* it is already presenting one of those, which UIKit refuses. So each modal
* screen mounts its own host and, being innermost, wins. The root host stays as
* the fallback for ordinary screens, which also means a future modal screen
* that forgets to mount one degrades to a dialog that may not appear rather
* than to a crash.
*/
hosts: number[];
Every modal screen mounts its own host, hosts register in mount order, and only the last entry renders:
const isActive = hosts[hosts.length - 1] === id;
The last sentence of that comment is the design decision I would defend hardest. There will be a future modal screen where somebody (me) forgets to mount a host. Keeping the root host as a fallback means that screen's dialogs might not appear on iOS, which is bad, rather than crashing the app, which is worse.
A confirm is a promise, and promises have to be settled
Once dialogs are your own code, the API you actually want is not a callback:
if (!(await confirm({ title: 'Delete account?', confirmLabel: 'Delete', destructive: true }))) return;
The moment you write that, you have taken on an obligation. Every single way a dialog can leave the screen has to settle that promise, or the caller waits forever. In an app with a busy flag in a finally, waiting forever means a button that is disabled for the rest of the session.
We found five exit paths.
- A button. Easy.
- The backdrop. Tapping outside runs the cancel action's handler, so a backdrop tap behaves exactly like tapping Cancel rather than being a separate third answer.
- Hardware back on Android. Same as the backdrop.
- Eviction. A second dialog opens while the first is up.
- Unmount. The screen that raised the confirm goes away while the dialog is open.
Four and five are the interesting ones, and both were real bugs before they were comments.
Eviction:
show: (request) => {
// Settle the dialog being replaced before it is gone, so its awaiting
// caller is released instead of being left pending forever.
const evicted = cancelOf(get().current);
set({ current: request });
evicted?.();
},
Unmount, in the host's effect cleanup:
return () => {
// Settle anything still open as this host goes away. Closing the screen
// that raised a confirm would otherwise leave its `await` pending for the
// rest of the session, with the dialog nowhere on screen.
const state = useDialog.getState();
const wasActive = state.hosts[state.hosts.length - 1] === id;
unregisterHost(id);
if (wasActive && state.current) state.dismiss();
};
Note the shape of the module contract this produces, written at the top of the file so nobody has to infer it:
* CONTRACT: one dialog at a time, and every request is settled exactly once.
* Opening a second dialog replaces the first AND cancels it, so the `confirm()`
* awaiting the first resolves false rather than hanging.
"Settled exactly once" needs enforcing in both directions, because now that five paths can settle a request, two of them can race. The confirm wrapper keeps its own flag:
// Guarded because every exit path calls the cancel handler, and a caller
// must never see a second answer to one question.
let settled = false;
const answer = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
Extra resolve calls on a promise are ignored by the runtime, so strictly this guard is belt and braces for the promise itself. It is not redundant for the handlers, which are ordinary functions that would otherwise run twice, and it documents the invariant where a reader will look for it.
The animation bug at the end
One more, because it only shows up once the dialog is pretty.
The modal's visible prop flips off the instant the current request becomes null, but the card takes a couple of hundred milliseconds to fade out. Render the title and buttons straight from current and you get an empty card fading over the backdrop, on the most consequential confirmations in the app. So the host remembers the last request for exactly as long as the animation:
const lastRequest = useRef<DialogRequest | null>(null);
if (current) lastRequest.current = current;
const shown = current ?? lastRequest.current;
The enter animation is opacity plus a small translateY, never a scale, so a dialog whose message wraps to four lines is the same size on the first frame as on the last. It also respects useReducedMotion by jumping straight to the settled state.
What this bought
One themed dialog, identical on iOS, Android and the web, styled like the rest of the app instead of like the operating system. That last part matters more than I expected for an app about food and health: a system alert in Helvetica arriving in the middle of a warm cream coloured screen reads as an error message, even when it is asking a perfectly ordinary question.
If you want to see it, sign in at app.munchable.app, open the avatar menu and tap Sign out. The dialog you get in the browser is the same component, from the same store, as the one on the phone. Before this work, that button was one of several that did nothing at all on the web, and nothing in our tooling told us.
Top comments (0)