Munchable's confirm dialogs used to be Alert.alert. On react-native-web that export is literally class Alert { static alert() {} }, so every confirmation in the browser build did nothing at all: sign out, delete account, the report flow. I wrote that one up already. The replacement is a small store plus a host component, and it works on all three platforms.
This post is about the bug the replacement is capable of, which has the same symptom as the one it fixed.
A promise is a debt
The API that replaced Alert.alert is an awaitable:
const ok = await confirm({
title: 'Delete your account?',
message: '...',
confirmLabel: 'Delete',
destructive: true,
});
if (!ok) return;
There are six of these in the app and six plain showAlert calls. The awaitable form is what makes the call sites readable, and it is also the whole problem: confirm() resolves from inside an action handler. If a dialog goes away without running one of its handlers, the await never finishes. The screen does not crash and nothing is logged. A button was pressed and nothing happened, which is exactly the failure this module was written to delete.
So the store's contract is not "show a dialog". It is: one dialog at a time, and every request is settled exactly once.
Five ways a dialog ends, and all of them settle
-
A button.
select(index)clearscurrentand then runs that action's handler. -
The backdrop, or hardware back.
dismiss()runs the cancel action's handler, so a backdrop tap behaves exactly like tapping Cancel rather than being a third outcome. - Eviction. Opening a second dialog replaces the first, so the first's cancel handler runs as it goes:
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. The host settles anything still open as it tears down. Closing the screen that raised a confirm would otherwise leave that
awaitpending for the rest of the session, with no dialog on screen to resolve it.
Because every one of those paths can reach the cancel handler, the handler has to be able to run twice without being believed twice:
let settled = false;
const answer = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
confirm() resolving false for cancel, dismissal and eviction alike is what makes if (!(await confirm(...))) return correct at every call site, and what makes it safe to put the await inside a try/finally that clears a busy flag. The flag is not defensive programming for its own sake. It is the thing that lets five independent exits share one cancel handler.
The registry exists because UIKit says no
The second half of the module is stranger, and it is iOS specific.
A react-native <Modal> presents from its nearest view controller. Munchable's capture, menu and paywall screens are declared presentation: 'modal', so when one of them is open, the root view controller is already presenting something. A dialog host mounted only at the app root would be asking that controller to present a second time, and UIKit refuses. The visible result was the two buttons on the result screen doing nothing, which is the same no-op symptom again, arriving by a third route.
The fix is that hosts register themselves and only the innermost one renders:
hosts: number[], // mount order
// ...
const isActive = hosts[hosts.length - 1] === id;
Mount order means the last entry is the host nearest the screen the user is looking at. The root host stays mounted as the fallback for ordinary screens. The capture screen mounts three, one in each of its render branches, because the branch that is rendering is the one whose tree the dialog has to appear in.
Two of the sheet screens, menu and paywall, currently mount no host of their own. Neither raises a dialog today, which is precisely why the degradation matters: a modal screen that forgets to mount a host gets a dialog that may not appear, rather than a crash or a hang. That is a deliberately boring failure mode for a mistake that is easy to make, and I would rather have it than a cleverer arrangement that fails interestingly.
The unmount path has to respect the same ordering:
const wasActive = state.hosts[state.hosts.length - 1] === id;
unregisterHost(id);
if (wasActive && state.current) state.dismiss();
Only the host that was actually rendering the dialog settles it on the way out. Without that check, a background host unmounting would cancel a dialog the user is currently looking at.
One rendering detail that cost two attempts
The modal's visible prop flips off the moment current becomes null, but the fade-out takes a few hundred milliseconds. Reading the title, message and buttons straight from current therefore blanked all three for the length of the animation, and faded an empty card over the backdrop on the app's most consequential confirmations. The host keeps the last request in a ref and renders current ?? lastRequest.current, so the dialog that is leaving still has words in it.
Reduced motion is honoured by jumping the entrance to its end state rather than by skipping the dialog, and the card animates opacity plus a translate rather than a scale, so a message that wraps to four lines is the same size on the first frame as on the last.
Seeing it
The browser build is at app.munchable.app, which is the same code as the phone app. Sign in and the confirmations you meet there, including the two destructive ones in Profile, are this store rather than the operating system's dialogs. What those two destructive buttons actually do is written up on the privacy page: an export, and a deletion that mostly happens on the device because that is where the sensitive data already lives.
Top comments (0)