Offline money actions that spun forever
In Homies, the bilingual React Native app I build privately on the side, you settle a debt by tapping Settle on a sheet. With no connection, the button went into its loading state and stayed there. No error, no timeout, no toast. The only way out was to dismiss the sheet or kill the app, and neither told you whether the money had moved.
It looks like an optimistic-UI bug, and it was not one: the comment in the fix says "No optimistic paths run here yet, so nothing to unwind." A paused TanStack Query mutation never settles, so the handler's finally never runs. The same thing happens in any app that awaits a TanStack Query mutation while the library believes it is offline.
A paused promise
TanStack Query's default networkMode is 'online'. In that mode a mutation started while the library believes the device is offline does not fail. It pauses. mutateAsync returns a promise that neither resolves nor rejects until connectivity comes back.
The library only believes what it is told, and I had told it. QueryProvider.tsx wires onlineManager to NetInfo so that queries stop retrying when the radio is off and refetch on reconnect. That wiring is correct and I would keep it. It is also what armed this bug: without it, the mutation would have gone out, failed at the network layer, and hit the onError toast every handler already had.
The handlers were written for two outcomes:
// settle-debt.tsx before the fix,
// condensed
setIsSettling(true);
try {
// offline: never settles
await createSettlement.mutateAsync(input);
// ...success path
} catch (e) {
showToast({
title: t("debts.settlementError"),
tone: "error",
});
} finally {
// never reached
setIsSettling(false);
}There is a third outcome, and in it finally does not run. The client config also sets retry: false on mutations, with a comment about double-charging. That is the right instinct and it does not apply here, because a paused mutation has not made a first attempt yet. TanStack Query resumes paused mutations when it next sees a connection. So the full behavior was a spinner with no explanation, and a settlement that could land some time after the user had stopped expecting one.
The guard
The fix checks connectivity before the handler enters its loading state:
const isOnline = useIsOnline();
// ...
if (!isOnline) {
haptic.error();
showToast({
title: t("network.offlineSubmit"),
tone: "error",
});
return;
}The toast tells the user they are offline and to reconnect to save. useIsOnline reads NetInfo with the same expression the onlineManager wiring uses, isConnected && isInternetReachable !== false, so the guard and the library cannot disagree about what offline means. If they did, the guard would pass and the mutation would pause anyway.
settleDebt.offlineGuard.test.tsx came with it. The first test is named for the property that matters:
// settleDebt.offlineGuard.test.tsx,
// condensed
it(
"offline tap does NOT call the settlement mutation and shows an error toast",
async () => {
mockIsOnline = false;
const renderer = await mount();
const onPress = confirmOnPress(renderer);
await act(async () => {
await onPress();
});
expect(mockMutateAsync).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith({
title: "network.offlineSubmit",
tone: "error",
});
},
);The second asserts the online path still submits, so the guard cannot quietly become a wall.
One idea, typed by hand in every handler
That commit guarded two handlers, settle-debt and add-expense. The same paused promise was waiting behind every other write in the app, and it took two more commits to reach them, both two days later: one for deleting an expense, undoing a delete and settle-all, and one for chore completion. A later feature added its own.
Each guard is a check typed by hand into a handler. There is no shared wrapper and nothing that fails when a new mutation is written without one. Five of the seven screens that write directly have the guard. The two that do not are settings screens, chore settings and notification settings, where a paused write costs a stuck toggle and no money. They are still the same bug.
Two alternatives would make this structural, and I have taken neither. Setting networkMode: 'always' on mutations would make an offline write fail fast into the error path that already exists. Wrapping mutateAsync once, in the service layer, would put the check where a new screen cannot forget it.
What guards it, and the screen it never ran on
Four test files: settleDebt.offlineGuard, expenses.offlineGuard, groupDebts.offlineGuard and chores.offlineGuard. Each mocks useIsOnline to false, fires the action, and asserts the mutation mock was never called.
Add-expense is not among them. Its guard shipped in the first commit, whose message says every fix in it carries a regression test. Every add-expense test file in the repository mocks useIsOnline as () => true. The guard on the screen where expenses are created and edited has never executed in a test, and deleting it would turn nothing red.
None of this is an end-to-end check. No Maestro flow turns the network off, so the claim that the real onlineManager and the real NetInfo agree on a real device rests on the two expressions being the same line of code.
The short version is exhibit 8 in the bug museum.