Stephen

Hackett-Delaney

Full Stack Software Engineer

ArticlesDebugging

iOS Simulator Gotchas

debuggingreact-nativeios-simulatorxcodeexpo

February 16, 2026

The login form was submitting itself three times before I touched anything. Two different email addresses — one from a deleted test account — were being injected into the form fields. No user interaction required.

The iOS simulator's Keychain had saved credentials from previous test runs and was auto-filling them into the login form, which triggered onSubmitEditing and submitted the form automatically.

One of those saved accounts had been deleted by our database reset script. So the simulator was automatically attempting to log in with credentials for a user that no longer existed.

How Did We Get Here?

While developing Maura in React Native with Expo, we were cycling through the brand onboarding flow repeatedly: reset the database, activate the invite, log in, test the flow. Each cycle created new credentials, and the simulator faithfully saved every one.

The mental model most developers have of the iOS simulator is: "It's like a fresh phone." It's not. The simulator persists state across app rebuilds, across Metro reloads, and even across Xcode restarts. Unless you explicitly reset it, data accumulates.

Gotcha 1: Keychain Auto-Fill Hijacks Forms

The iOS Keychain stores passwords and offers to auto-fill them. In the simulator, this means every email/password you've ever entered during development is saved and offered back.

The problem gets worse when the auto-fill triggers form submission. In React Native, onSubmitEditing on a TextInput fires when the field is "submitted" — including when auto-fill populates it.

What we saw in Metro:

LOG  data {"email": "stephen+b1@comediadesign.com", "password": "Maura123!"}
LOG  data {"email": "stephen+bison.ca3@comediadesign.com", "password": "Maura123!"}
ERROR  Login error: Invalid login credentials

The first login succeeded (no error logged). The second failed because that user was deleted. But the failed login set the form error state, which is what we saw on screen.

The fix — disable auto-fill on test forms:

<TextInput
  textContentType="none"    // Tells iOS: don't auto-fill this field
  autoComplete="off"        // Additional signal to disable auto-complete
  placeholder="Email"
/>

The nuclear option — reset the simulator's Keychain:

# Find your booted simulator
xcrun simctl list devices booted

# Reset its keychain
xcrun simctl keychain <DEVICE-UUID> reset

This wipes all saved passwords. No more phantom logins.

Gotcha 2: AsyncStorage Survives Everything

AsyncStorage in React Native persists data to disk. On iOS, this lives in the app's Application Support directory inside the simulator. This data survives:

  • Metro reloads (press R)
  • App rebuilds (Cmd+R in Xcode)
  • Expo dev client restarts
  • Even Xcode restarts

It does not survive:

  • xcrun simctl erase <DEVICE-UUID> (full simulator reset)
  • Deleting the app from the simulator home screen
  • Deleting the storage files manually

This means auth sessions, cached data, and app state persist across your development sessions. If you're debugging a "first launch" experience, you're not seeing first launch — you're seeing "nth launch with cached state."

To clear AsyncStorage safely (see Part 3 for why "safely" matters):

# Find the storage directory
find ~/Library/Developer/CoreSimulator/Devices/<DEVICE-UUID> \
  -name "RCTAsyncLocalStorage_V1" -type d

# Clear contents, keep the directory
rm -rf "<path>/RCTAsyncLocalStorage_V1/*"

Gotcha 3: The Simulator Is Not a Clean Room

Here's a non-exhaustive list of what persists in the iOS simulator between builds:

WhatWhereClears On
Keychain (passwords)Keychain databasexcrun simctl keychain reset or erase
AsyncStorageApp container Application Support/Manual delete or erase
UserDefaultsApp container Library/Preferences/App uninstall or erase
CookiesShared cookie storagexcrun simctl erase
Network cacheShared cache directoryApp uninstall or erase
Permissions (camera, notifications)Simulator settingsxcrun simctl erase

If your debugging assumes a clean state, you need to explicitly create one. "Rebuild the app" is not enough.

Gotcha 4: The Settings App Doesn't Show Everything

When we tried to clear saved passwords through the simulator's Settings app (Settings > Passwords), the entries weren't there. The Keychain stores items at different access levels, and not all of them show up in the Settings UI.

xcrun simctl keychain reset is the reliable way to clear everything. The Settings UI is a partial view.

The Process Shortcomings

We assumed the simulator resets between builds. It doesn't. Every test cycle accumulates state, and that state affects subsequent test runs in non-obvious ways — like auto-filling deleted credentials.

We didn't have a "clean slate" script. For a development workflow that requires repeated testing from a clean state, we should have had a single command that resets everything: database state, simulator keychain, AsyncStorage. Instead, we were manually clearing things one at a time and missing some.

We didn't disable auto-fill from the start. For development and testing, textContentType="none" should be the default on auth forms. Auto-fill is a production UX feature, not a development aid. It actively interferes with testing when your credentials keep changing.

A Clean Slate Script

Here's what we should have had from the beginning:

#!/bin/bash
# reset-dev.sh — Clean slate for development testing

DEVICE_UUID=$(xcrun simctl list devices booted -j \
  | jq -r '.devices[][] | select(.state == "Booted") | .udid' \
  | head -1)

if [ -z "$DEVICE_UUID" ]; then
  echo "No booted simulator found"
  exit 1
fi

echo "Resetting simulator $DEVICE_UUID..."

# 1. Reset keychain (clears saved passwords)
xcrun simctl keychain "$DEVICE_UUID" reset
echo "[ok] Keychain reset"

# 2. Clear AsyncStorage (contents only, keep directory)
STORAGE_DIR=$(find ~/Library/Developer/CoreSimulator/Devices/"$DEVICE_UUID" \
  -name "RCTAsyncLocalStorage_V1" -type d 2>/dev/null | head -1)

if [ -n "$STORAGE_DIR" ]; then
  rm -rf "$STORAGE_DIR"/*
  echo "[ok] AsyncStorage cleared"
else
  echo "[skip] AsyncStorage directory not found"
fi

# 3. Reset database state
npx tsx scripts/reset-brand-onboarding.ts "$BRAND_ID" --reset
echo "[ok] Database reset"

echo "Done. Ready for a clean test cycle."

One command. Full reset. No leftover state.

What I Learned

The simulator is a stateful environment. Treat it that way. Every test cycle leaves artifacts, and those artifacts will eventually interfere with the next cycle.

xcrun simctl is your friend. It's the CLI for simulator management, and it can do things the GUI can't: keychain reset, privacy reset, push notifications, openurl. Learn the subcommands.

Disable auto-fill in development. It's a production feature. In development, it's a source of bugs that don't exist in real usage.

Build a reset script early. If your development workflow involves repeated testing from a known state, automate the state reset from day one. It'll save you more time than almost any other tooling investment.


This is Part 4 of the Debugging series. Final part: Auth Debugging Layer by Layer — when the login succeeds and the app does nothing.

© 2026. All rights reserved