Third polish pass, and this one has a theme without trying: every item is a trap that only appears at the edges — on Android rather than iOS, on a real device rather than the simulator, at cold start rather than warm, in a store listing rather than the app. The weeks around a launch are where those edges get found, because that's when strangers start holding the thing.
TL;DR — Android's native alert renders three buttons and silently drops the fourth (Cancel vanished; use a sheet, act on dismiss). A single bottom-sheet prop,
adjustResize, makes the sheet trust a window resize that never happens under edge-to-edge — omit it. The web dev server started answering HTTP 431 to everything because Cognito tokens in cookies grew past Node's header limit. A transient session-refresh failure was treated as "signed out" while the tokens stayed — users landed on sign-in and were told they were already signed in. Scanned items now carry brand and pack size (with the pack unit deliberately not stored as the item unit). Plus: the iOS back button that said "index", per-document legal dates, and "CannyCart" becoming "Canny Cart" everywhere — because the store cross-checks your listing against your deletion page.
(Part 39 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
Android's fourth button doesn't exist
The profile-photo sheet offered four options once a photo was set: camera, library, remove, cancel. On Android, Alert.alert has three button slots and silently discards the rest — and the one it discarded was Cancel, leaving a dialog with no way out.
Replaced with a proper bottom sheet, which has three escape routes by nature (Cancel, the backdrop, hardware back). One subtle rule inside it: actions run on dismiss, not on press. The image picker is itself a modal, and opening a modal over a closing sheet races on both platforms — sometimes the picker appears, sometimes it's swallowed. Let the sheet finish closing, then act on what was chosen.
The one prop that hides the keyboard trap
Part 20 explained why React Native's core keyboard-avoiding view measures zero under Android's mandatory edge-to-edge: the window doesn't resize, it receives insets. The bottom-sheet library has the same trap wearing a different hat. Pass android_keyboardInputMode="adjustResize" and the sheet skips its own keyboard lift and zeroes its scroll inset, trusting a window resize that never comes — so the keyboard simply covers the sheet's input. Omit the prop and the library's own keyboardBehavior does the lifting correctly.
The tell that finally pointed at it: two sheets had always worked (the amount and list sheets) and two never had (the review sheets). The difference was that one prop, copied from a snippet that predated edge-to-edge.
HTTP 431 from cookies
The web dev server began answering 431 Request Header Fields Too Large to everything. Cause: the auth library was configured for server-side rendering, which stores Cognito tokens in cookies. Localhost cookies are shared across every port; refresh tokens run 4–6 KB each; a few accumulated sessions from different local apps pushed request headers past Node's 16 KB limit.
The fix was to notice the flag bought nothing — every guard in the console is client-side (Part 37), nothing does server-side auth — so tokens went back to localStorage and an unused adapter dependency left with the flag. One-time cost: cookie-stored sessions aren't readable from local storage, so signed-in admins re-authenticated once. A configuration that "enables" something you don't use is a liability with a delay on it.
"There is already a signed in user"
A cold-start bug from the v1.0.3 release: the auth gate treated any error from fetching the session as "signed out". But the auth library keeps the stored tokens on a transient refresh failure — no signal at cold start, a brief identity-provider hiccup — and only clears them on real auth errors. So users with perfectly valid sessions landed on the sign-in screen, entered their password, and were refused with "There is already a signed in user."
Two fixes. The gate now falls back to the stored id token when verification throws — a transient failure is not a sign-out. And the login flow honours an existing session for the same email (or signs the old account out first for a different one) instead of surfacing the library's message. The lesson is about error taxonomy: couldn't verify and not signed in are different states, and collapsing them punishes exactly the users with the worst signal.
A sibling bug shipped in the same release: announcement dismissals lived in a per-user key that sign-out wiped, so signing back in replayed every takeover, dialog and strip you'd already seen. They're now device-scoped and keyed by user id — the same account on the same phone stays dismissed; a different account gets its own entry.
Brand and pack size, and the unit that isn't
Scanned products now carry brand and pack size as first-class fields — and the display name folds the brand in at staging time: "Still Water" doesn't tell you which one to pick up, and the price-history key is the name, so a stripped brand would merge two different waters into one swinging price. (Guarded against providers that already include it — no "Buxton Buxton Still Water".)
One deliberately not-done thing: the pack unit is not stored as the item's unit. Quantity means "how many to buy", so "415 g" plus quantity 2 would render as "2 g". The pack size lives in its own field and the row reads "× 2". A unit that means two different things is worse than no unit.
Four small ones
- The iOS back button said "index". The native stack labels the back button with the source route's name when that screen sets no title, and every push from a tab's index screen rendered "‹ index". A minimal back-button display mode on all three header-bearing stacks leaves just the chevron.
- Per-document legal dates. Terms and privacy previously shared one updated-date, so amending either restamped both — a false claim about the other. Now two constants.
- "Canny Cart", with a space. The home-screen label said "Canny Cart"; every other surface said "CannyCart". The store title would be the two-word form, and Google Play cross-checks the listing against the deletion page — so legal docs (both renderers), permission strings, web metadata and wordmark all now agree. Identifiers — bundle ids, scheme, table prefixes — untouched.
- The sandbox prints its env vars. A one-line startup log listing which environment variables the sandbox script loaded, because "is the key set?" was the first question in every AI-feature bug report.
What I took away
- Count the buttons on Android. Three is the limit; a sheet has infinite escape routes.
- Old snippets carry old assumptions. One prop from the pre-edge-to-edge era hid the keyboard on half the sheets.
- Unused capabilities aren't free — cookie storage nobody read produced a 431 nobody expected.
- "Couldn't verify" is not "signed out." Transient failures deserve the stored token, not the login screen.
- A field means one thing. Pack size and purchase quantity don't share a unit.
- Stores read your app more carefully than your users do — even the space in the name.
The series, at 39
This closes the fourth batch: the money arc, the vision features, the release, the account move, deletion, languages, the admin console. The next posts will come from whatever ships next — the plan is a living document, and the app keeps outrunning it.
What's the edge your app only breaks on — the platform, the device, the cold start — and when did a real user find it before you did?
Top comments (0)