Every React Native codebase has a MOCK_VIEW somewhere. A hardcoded object that lets you build a screen before the backend is ready, or render a component in isolation without standing up a socket connection.

Ours could deal cards.

This is a write-up of an adversarial review we ran on the client half of Seep Ghar, a social card-game app we are building for the Indian market on a shared, server-authoritative backend. Adversarial review means the reviewer’s job is not to check the code works — CI already does that — but to find the case where it does something it should not. We found five. The first one is the reason this post exists.

Prefer to watch? The eight-minute version walks through all five findings.

Finding M1: the placeholder that could act

The table screen renders live game state from a socket. State arrives, gets mapped into a view model, and the UI draws a hand of cards.

The problem was in the fallback. When a real snapshot arrived but could not be mapped — during a round transition, a seat swap, or an empty hand — the screen fell back to MOCK_VIEW. A player would see a fake board mid-game.

That alone is a display bug. What made it a correctness bug is that MOCK_VIEW carries myTurn: true, because that is what you want when you are building the screen in isolation. So the fallback did not just show a fabricated board. It enabled the action controls, and let the player emit a card that was never in their hand.

The fix has three parts, and only the first is obvious:

  1. Derive live separately from state, rather than treating one as a fallback for the other.
  2. When state is present but unmapped, render a “syncing” overlay instead of the mock. The user sees an honest “hold on”, not a wrong board.
  3. Gate canAct on live.myTurn — the real view — never on !!state.

That third point is the general lesson. !!state answers “did something arrive?” It does not answer “is this thing meaningful yet?” Those are different questions, and a truthiness check quietly collapses them into one.

Honest state versus permissive mock, side by side. Left: the syncing overlay, which gates the action controls and shows honest progress while state is present but unmapped. Right: the permissive mock, which fakes active permissions and lets a player play phantom cards.

Finding M2: a fire-and-forget logout

Guest sign-in called AuthService.clear() without awaiting it, then mounted the app. The game hook read the token before the clear had finished and connected as the previous account.

This is the classic async-in-lifecycle bug and it is almost invisible in testing, because on a fast device the clear usually wins the race. It surfaces on a cold device, a slow filesystem, or a debug build — which is to say, on a user’s phone rather than yours.

The fix is one keyword: await the clear before mounting.

The async logout race condition. Without awaiting the storage clear, the client connects using the previous token — the sign-out appears to complete while the game socket is still authenticated as the account that just left.

Finding M7: a token from the right user, for the wrong app

The backend is shared across tenants. Each app sends an app identifier, and the server stamps a vouched claim into the JWT so rooms and entitlements are scoped per tenant. Cross-tenant reads are a 404, enforced server-side.

The client, though, accepted any valid token on boot. A token issued for a sibling app on the same backend would open a session that joined the wrong tenant’s rooms. The server would have refused the sensitive operations — that boundary held — but the client had no business trying.

Now the client rejects a token whose vouched claim is not its own tenant, at boot.

The general principle: server-side enforcement is what makes a boundary real, but a client that ignores the boundary produces confusing failures, wasted round-trips, and support tickets that look like server bugs.

Findings L2, L3, L5: the small ones that matter on a real phone

  • L2 — cancelled-guards on state setters, and clearing the connect timeout on game-error and connect_error. Without these, a component that unmounts mid-connection sets state on a dead component and leaks a timer.
  • L3 — the connecting scrim had no exit. If the socket never connected, the user sat in a 12-second dead wait with no way back. It now has a back-to-lobby escape. A spinner without an exit is a trap, not a loading state.
  • L5 — the unencrypted AsyncStorage token fallback is now __DEV__ only. A production build without the secure keystore refuses to persist a plaintext token rather than silently downgrading.

L5 is worth dwelling on. The original code was defensive in the wrong direction: if secure storage was unavailable, fall back to plaintext so the user stays logged in. That trades a security property for a convenience one, silently, in production. The corrected behaviour is to fail the convenience and keep the property.

What this says about mock data generally

Mock objects are written to make a screen render. That is their whole purpose, so they are written permissively — every flag set to whatever makes the happy path draw. myTurn: true. isReady: true. hasSubscription: true.

Then they sit in the bundle next to production code, one fallback away from being rendered to a real user with all those permissions switched on.

Three habits that would have caught ours earlier:

  • Never use a mock as a runtime fallback. If real state is unusable, say so in the UI. A skeleton or a syncing state is honest; a mock is a lie the user cannot detect.
  • Make mocks fail closed. If a mock must exist, set its permission-ish flags to the restrictive value. myTurn: false still renders the board.
  • Gate actions on the derived view, not on the raw payload. Presence of data is not readiness of data.

What we did not fix here

Six findings from the same review sit on the backend — pre-existing auth surface shared with the flagship app — and are tracked in that repository rather than this one. Splitting the fix by repository boundary rather than shipping a client patch that pretends to address a server issue is deliberate. A client-side workaround for a server-side auth gap is a second bug wearing the first one’s clothes.


Frequently Asked Questions

What is an adversarial code review?

A review where the reviewer’s goal is to find the input or state that makes the code misbehave, rather than to confirm it works. Standard review asks “is this correct?” Adversarial review asks “what would I do to break this if I wanted to?”

Why didn’t CI catch the mock fallback?

Because nothing was broken by the ordinary definition. The bundle validated, the tests passed, and the screen rendered. The defect only appears when a real snapshot arrives in a shape the mapper does not handle — a state the test suite did not produce. Green CI means “no known failure”, not “no failure”.

Is this specific to React Native?

No. The pattern — a permissive placeholder used as a runtime fallback — appears in any framework with a component-isolation workflow. React, Vue and SwiftUI previews all encourage the same habit. React Native just makes it easier to ship the mock, because the mock is in the same bundle as the app.

How long should an adversarial review take?

For a focused surface like an auth and realtime path, budget a day. The output here was eleven findings across client and backend from one pass. The cost is small relative to shipping a game where a player can emit a card they do not hold.

Do you do this for client projects?

Yes — it is a standard step before we ship anything with money, identity, or multiplayer state in it. It is also the least glamorous line item on any quote and the one that most often pays for itself.


For the infrastructure side of shipping something like this — the server that enforces the boundary rather than trusting the client — Cloud Geeks handles cloud architecture and security review for Australian businesses.

Ash Ganda writes on what AI-assisted engineering does and does not catch, including why a green build is not the same as a working product.

Part of the Ganda Tech Services family, Awesome Apps builds iOS and Android applications for Australian businesses from Bella Vista, NSW.

Ready to build your app?

Talk to a Sydney app developer — free.

30 minutes. We'll tell you what your app needs, how long it takes, and what it costs. Real answers, no sales pitch.

Book Free App Strategy Call →

Free · 30 minutes · No obligation