Root Cause Analysis · RealSkill Mobile
Two separate defects sat on top of each other in the same flow. One stopped the player from ever being mapped; the other hid the error on iOS so the screen looked frozen. Neither was a backend data problem. A sweep for the same two patterns then found four more instances of the second, and a third defect that was quietly creating duplicate accounts.
Bryce reported two things. First, after adding a new player the app froze and had to be force-closed. Second, when he tried to add jeremiah.hawkins, the app recognised the account and showed a green check mark, but nothing happened after that.
The server log for that attempt looked like this:
/membership/get-player-by-username 404 66.159 ms - 46
A 404 on a lookup for a player who plainly existed. That was the thread we pulled.
Before accusing any code, we closed off every environmental explanation against the live production database and the running production build.
| Hypothesis | Verdict | Evidence |
|---|---|---|
| We queried a stale or wrong database | Ruled out | Newest user row was same-day; 91,316 users |
| The endpoint was never deployed | Ruled out | Unauthenticated probe returned 401, not 404 |
| Production runs older code | Ruled out | Deployed dist matched repo source exactly |
| Player record missing or inactive | Ruled out | User 50731, is_active=1, is_deleted=0 |
| Trainer lacked a licence | Ruled out | Plans 280384 and 317498 both active |
| Trainer out of seats | Ruled out | 36 players against 10,000 seats |
Every environmental explanation failed. The database was healthy, the licence was valid, the endpoint was live, and the deployed code was current. That left the client.
Adding an existing player by username is a two-call sequence. The first call finds the person; the second call actually maps them to the trainer.
trainer_player_map and grants the all-access plan. Never ran.The two endpoints signal success in different shapes. One sends a numeric status; the other sends a boolean success and no status field at all.
// add-existing-player — success
{ msg: "Player mapped successfully", status: 200, ... }
// get-player-by-username — success
{ success: true, player_id: 50731, email: "...", ... }
// ^ no `status` key anywhere in this response
The client scored success from status alone:
// workoutRepository.ts — parseExistingPlayerResponse
success: isSuccessStatus(status) // status === undefined → false
So a perfectly good 200 OK carrying the player's record was classified as a failure. The guard on step 3 never opened, the mapping call was never sent, and the code fell through to its catch-all message.
The important consequence
The trainer saw “Unable to add player.” That message was never the backend's. It was the client's own fallback string, produced without a single failed request. The mapping API was not rejecting the write — it was never being called.
The check mark comes from a different endpoint, search-player, which matches loosely with ILIKE and returns its own shape. That call succeeded and rendered the tick. The strict lookup that followed was the one being misread — so the UI confirmed the person existed and then refused to move.
We wrote a regression test using the exact response shapes production returns, then removed the fix and ran it again:
— without the fix —
✕ username path: looks up, then maps
Unable to map existing player
— with the fix —
✓ username path: looks up, then maps using the email returned by lookup
✓ username path: does not swallow a genuine lookup failure
The failing run reproduces the trainer's exact error message. That is the proof, not an inference.
A helper that reads the boolean flag, parseSuccessFlag(), already existed in the same file — it simply was not being called here. The parser now prefers it and falls back to the numeric status.
const explicitSuccess = parseSuccessFlag(root);
success: explicitSuccess ?? isSuccessStatus(status),
Two gaps, and the second is the one worth remembering.
addExistingPlayerOnly — the function containing the whole two-call sequence — had no test at all.{ status: 200, message: 'Player found' }: a shape the backend has never returned. The test asserted against an imagined API, so it passed while the real integration was broken.A contract test that invents its own contract is worse than no test, because it reports safety. The new test is pinned to the real response shapes.
The Add Player sheet is itself a <Modal>. Its result dialogs were routed through QuickAlert, which renders in GlobalDialogHost — mounted at the application root, outside that modal.
On Android a modal is just a view in the window hierarchy, so a second one draws on top without complaint. On iOS a modal is a native view-controller presentation, and a controller mounted outside the presented modal cannot present above it. The dialog was created, marked visible, and never appeared.
Why “it works on Android” confirmed the diagnosis
Both platforms hit Defect 1 and both produced the same error. Only the delivery differed: Android showed the alert, so the trainer read “Unable to add player.” iOS swallowed it, so the trainer saw a sheet that had stopped responding. One defect, two very different bug reports.
The Skill Lab path was unaffected because it passes mode='skilllab' and uses a dialog nested inside the sheet's own modal. Neither of the two screens that call this widget passes that prop, so both took the broken branch.
| Check | Result |
|---|---|
| TypeScript compile | 0 errors |
| App test suites | 107 / 107 pass |
| Backend compile and build | 0 errors, clean dist |
| New regression test, real shapes | Fails without fix, passes with it |
| iOS build and install on device | Succeeded |
| Runtime confirmation on device | Not yet done |
| Backend deployed to production | Merged, not deployed |
The device build installed and launched, but the app cannot reach the Metro bundler over USB, so no JavaScript-level runtime logs have been captured yet. Defect 1 is proven by test. Defect 2 is proven by code path and by the Android/iOS behavioural split, but has not yet been watched working on hardware. It is being called fixed-pending-verification, not verified.
Defect 3 is fixed and merged but not yet live. Production is still serving a build from before these changes, so the reporting trainer will see no difference until the backend is rebuilt and restarted.
Three endpoints in this one flow disagreed about how to match a person:
| Endpoint | Match | Trims input |
|---|---|---|
search-player | ILIKE, partial | Yes |
get-player-by-username | Exact, case-sensitive | No |
add-existing-player | Exact, case-sensitive | No |
A trainer who types an address in lower case when the stored value carries capitals — JeremiahJahree.2017@gmail.com is a real address on this account — passed the loose search, earned the green tick, then failed the strict lookup.
This one was not just a wrong message
In add-existing-player the branch immediately below the failed lookup creates a user. A case mismatch did not merely report “player not found” — it silently registered a second account, and a second Shopify customer, for a person who already existed. This flow has been quietly manufacturing duplicates.
Both lookups now try the exact match first and fall back to a case-insensitive one. The tempting one-line version — dropping mode: 'insensitive' onto a findFirst — was rejected. With 91,316 users and known duplicate accounts (the reporting trainer has three), two records differing only by case would let the database return either one, mapping the wrong player to the trainer. That is a worse failure than the bug being fixed.
The fallback therefore accepts a single match and refuses to choose between two, returning an explicit message instead. Both lookups also trim their input, which neither did before.
Defect 2 is a pattern, not a one-off, so we swept for it: any component that is itself a modal and also raises a root-level dialog. Four more live instances turned up.
| Screen | What broke on iOS |
|---|---|
| Schedule Workout sheet | A failed schedule showed no error whatsoever |
| Skill Lab · Players | The failure path never dismissed the sheet before alerting — unlike the success path written directly beside it |
| Skill Lab · Players | The “select a trainer first” warning never appeared |
| Skill Lab · Trainer list | Raised its dialog before closing its own menu, racing the dismissal |
Rather than patch each call site and wait for the next one, the dispatcher itself changed: showAppDialog now delivers to the most recently mounted host instead of every host. A host rendered inside an open modal mounts after the root host and therefore wins. With no nested host mounted — every other screen in the app — the root host is still the only listener and nothing changes.
One further hit, MyPlayers.tsx, carries the same defect but is imported nowhere. It was fixed for consistency; no user could reach it.
An unrelated finding worth acting on. On the production backend the checked-out commit was created the same day, while the compiled dist was three days old and the process had not restarted since. Someone pulled without rebuilding.
Nothing is broken today, but the next routine restart would silently promote three days of unreviewed code. Worth fixing as a deploy-process issue rather than waiting for it to become an incident.