Description
After disconnecting a Porto passkey wallet via wagmi's disconnect(), subsequent page reloads result in a "ghost-connected" state where:
useAccount() reports isConnected: true and status: "connected"
account.address is undefined
connect({ connector: portoConnector }) is a no-op — no passkey dialog appears, no error is thrown
Users get stuck in a state where the app thinks they're connected but they can't perform any wallet actions.
Steps to Reproduce
- Connect with Porto passkey connector via wagmi
- Call wagmi's
disconnect() (e.g., user clicks logout)
- Reload the page
useAccount() cycles through these states:
isConnected: true, address: "0x68FF...", status: "reconnecting"
isConnected: false, address: undefined, status: "reconnecting"
isConnected: true, address: undefined, status: "connected" ← settles here
- Calling
connect({ connector: portoConnector }) again does nothing — no dialog, no error
Expected Behavior
After disconnect() + page reload, either:
- Successfully restore the session with a valid address, OR
- Report
isConnected: false so the app can show a clean login flow
What I See in Storage
After the ghost state occurs:
wagmi.recentConnectorId still set to "xyz.ithaca.porto" in localStorage
wagmi.store in localStorage has connections Map with empty value: []
- Porto's IndexedDB (
porto database, store object store) still exists with a porto.store entry containing {state: Object, version: 5}
Manually deleting the Porto IndexedDB + wagmi localStorage keys and reloading fixes it.
Possible Root Cause
Looking at Porto's source, I suspect a persistence timing issue:
-
wallet_disconnect in provider.ts calls store.setState((x) => ({ ...x, accounts: [] })) — this updates memory, but the zustand persist write to IndexedDB is async. If the page unloads before the write completes, stale account data survives in IndexedDB.
-
waitForHydration in store.ts has a setTimeout(() => resolve(true), 100) fallback. If IndexedDB hydration is slower than 100ms (common on mobile/cold start), the provider may initially read empty state, then hydration completes later with stale accounts, firing a late connect event that creates the ghost state after wagmi already decided "not authorized."
-
The wagmi connector's connect() with isReconnecting: true returns { accounts: [], chainId } instead of failing when no accounts are found — wagmi stores this as a "connected" connection with zero accounts.
Workaround
We currently work around this by:
- Detecting ghost state (
account.isConnected && !account.address) before calling connect(), and calling disconnectAsync() first
- Clearing Porto's IndexedDB (
indexedDB.deleteDatabase('porto')) during logout
- Also clearing
wagmi.store and wagmi.recentConnectorId from localStorage during logout
Environment
- Porto connector via wagmi v2
- Safari on macOS (Darwin 24.6.0)
- Porto dialog mode with
id.porto.sh
Description
After disconnecting a Porto passkey wallet via wagmi's
disconnect(), subsequent page reloads result in a "ghost-connected" state where:useAccount()reportsisConnected: trueandstatus: "connected"account.addressisundefinedconnect({ connector: portoConnector })is a no-op — no passkey dialog appears, no error is thrownUsers get stuck in a state where the app thinks they're connected but they can't perform any wallet actions.
Steps to Reproduce
disconnect()(e.g., user clicks logout)useAccount()cycles through these states:connect({ connector: portoConnector })again does nothing — no dialog, no errorExpected Behavior
After
disconnect()+ page reload, either:isConnected: falseso the app can show a clean login flowWhat I See in Storage
After the ghost state occurs:
wagmi.recentConnectorIdstill set to"xyz.ithaca.porto"in localStoragewagmi.storein localStorage hasconnectionsMap with emptyvalue: []portodatabase,storeobject store) still exists with aporto.storeentry containing{state: Object, version: 5}Manually deleting the Porto IndexedDB + wagmi localStorage keys and reloading fixes it.
Possible Root Cause
Looking at Porto's source, I suspect a persistence timing issue:
wallet_disconnectinprovider.tscallsstore.setState((x) => ({ ...x, accounts: [] }))— this updates memory, but the zustand persist write to IndexedDB is async. If the page unloads before the write completes, stale account data survives in IndexedDB.waitForHydrationinstore.tshas asetTimeout(() => resolve(true), 100)fallback. If IndexedDB hydration is slower than 100ms (common on mobile/cold start), the provider may initially read empty state, then hydration completes later with stale accounts, firing a lateconnectevent that creates the ghost state after wagmi already decided "not authorized."The wagmi connector's
connect()withisReconnecting: truereturns{ accounts: [], chainId }instead of failing when no accounts are found — wagmi stores this as a "connected" connection with zero accounts.Workaround
We currently work around this by:
account.isConnected && !account.address) before callingconnect(), and callingdisconnectAsync()firstindexedDB.deleteDatabase('porto')) during logoutwagmi.storeandwagmi.recentConnectorIdfrom localStorage during logoutEnvironment
id.porto.sh