fix(mobile): keep the logged-in screens mounted through an account switch - #29622
fix(mobile): keep the logged-in screens mounted through an account switch#29622chrisnojima wants to merge 3 commits into
Conversation
04c4ae4 to
97e1ad5
Compare
efdcab3 to
332a2a8
Compare
332a2a8 to
1c21068
Compare
…itch A switch flaps config.loggedIn false and back to true. The mobile root stack followed it, so every switch swapped to the logged-out stack, back, and then remounted the navigator, three native rebuilds in about 130 ms. RNS logged unbalanced appearance transitions, and could leave the torn-down navigator's screens on top. Every touch was then dropped and the app looked frozen. It also sometimes logged an unhandled POP for the root 'loggedIn' screen. Hold the mobile logged-in screens through a switch that started logged in (showLoggedInScreens). A switch that starts logged out, e.g. a notification tap on the login screen, keeps the logged-out screens until it lands. Desktop keeps its loggedIn || userSwitching gate. Holding the logged-in screens means userSwitching must clear whenever a switch ends without the remount: - login() now clears it when it cancels one of its own prompts, and when it fails without an RPCError. Otherwise the app stayed on the old account's screens with reset stores. - The provisioning hand-off clears it, then pushes 'username' through the new navigateAppendOnceRootHas once the logged-out stack has mounted. A push dispatched before then was dropped. - When a switch started by a notification tap ends, the push store drops that tap's pending notification. A successful switch has already consumed it. Left behind, it would re-run the failed switch on the next account-list refresh. A notification parked for an account that isn't configured yet is left alone. - A switch that lands on the navigator that's already mounted (same account, or the first switch after launching logged out) gets no remount and so no onReady. useUserSwitchNavKey now ends it when the arriving username is the switch's recorded target. Matching the target, not just "no remount", keeps a stale username mid-switch from ending a switch still in flight. Before ending it, the hook marks the mounted navigator ready for the account: a logout's store reset clears navigation readiness and only onReady restored it, so after a re-login without a remount every deep link and notification intent stayed queued.
…ng to it On cold start the native tab controller selects the first tab when it gets its children, then moves to the startup tab, sliding the iOS 26 glass pill across. Patch react-native-screens to make that first selection without animation. An account switch remounts the navigator on the first tab and jumped to the remembered tab after onReady. Start the remounted navigator on that tab via the linking initial URL instead; onReady still consumes it as a fallback.
1c21068 to
43c4711
Compare
|
Findings
shared/stores/config.tsx lines 598-604 Once the service’s loggedOut has set loggedIn to false, a second setUserSwitching(true, …) writes userSwitchingFromLoggedIn = false. That is exactly the group swap this PR is trying to prevent. Header long-press and push taps bail if a switch is already running. The account switcher only disables rows while waitingKeyConfigLogin is set, so it is mostly safe today. Still, the flag should be latched: if a switch is already in progress, keep
The new catch clears the flag for non-RPC failures and self-cancels. StatusCode.scalreadyloggedin only calls setLoggedIn(true). If that returns while the username is already the current one, useUserSwitchNavKey does not remount and does not run again, so the flag If that status is reachable with doUserSwitch: true, clear the flag there too, or end the switch the same way as the no-remount username path. |
Problem
Switching accounts on iOS (long-pressing the header avatar, or picking an account in the switcher) could leave the app frozen. The screen still drew, but every tap went nowhere. The service and the JS thread were both idle and healthy. The native log showed each touch being dropped:
The visible views belonged to React components that had already unmounted. Switches also sometimes logged a navigation error, where native-stack reported the root
loggedInscreen as dismissed while it was still the only route in JS state:Cause
During a switch, the service sends
loggedOutand thenloggedIn, soconfig.loggedInflaps false and back to true. On mobile, the root stack'sloggedIn/loggedOutgroups followed that flag directly. So every switch rebuilt the native root stack three times in about 130 ms:loggedIn→ false: the logged-in screens are swapped for theloginstack.loggedIn→ true, before the new username arrives: the logged-in screens mount again under the old navigator.useUserSwitchNavKeychanges the key, and the whole navigator remounts.react-native-screens logged
Unbalanced calls to begin/end appearance transitions for <RNSScreen>on nearly every switch. That's the native side starting a transition before the previous one finished. The navigator it tore down could be left on screen, over the live one, which is the freeze. Its screens could also report a dismissal after React had already let go of them, which is thePOP.Desktop already avoids this: its groups use
s.loggedIn || s.userSwitchingto keep the app mounted through the gap.Fix
1. Keep the logged-in screens mounted through a switch that started logged in. Mobile's
useIsLoggedInNative/useIsLoggedOutNativenow useshowLoggedInScreens(router-v2/account-switch.tsx): logged in, or a switch in progress that started while logged in. That switch's logged-in screens stay mounted until the navigator remounts for the new account, so each switch does one native rebuild instead of three.setUserSwitchingrecords whether the switch started logged in (userSwitchingFromLoggedIn), and that survives the mid-switch store reset.loggedIn || userSwitchinggate.2. Clear
userSwitchingwhen a switch's login ends without logging in. Until now it was cleared only byonNativeReady(after the remount) and bysetLoginError.login()swallows two outcomes without calling either:RPCError.In both cases the service has already logged the old account out, so
loggedInis false.userSwitchingstayed true forever. With (1), a switch that started logged in would then stay on the old account's screens with the stores reset, unable to reach the logged-out or provisioning screens. Desktop's routers already hold the logged-in screens while the flag is set, so desktop had the same hole. Both branches now clearuserSwitching, and a failed switch falls through to the logged-out flow on both platforms.3. Land the provisioning hand-off after the logged-out stack mounts. When a switch reaches an account that needs a new device,
login()cancels the prompt and pushesusername. That route lives in the logged-out stack. With (1), that stack isn't mounted yet when the push is dispatched, so the push was dropped, and clearing the flag afterwards only mounted the stack onlogin. Desktop already dropped it the same way.PromptNewDeviceNamehandler now clearsuserSwitchingfirst. It then pushesusernamethrough a newnavigateAppendOnceRootHas('loggedOut', …)inconstants/router.tsx.navigateAppendOnceRootHaspushes right away if the root stack already has that route, which is what happens for a normal login from the logged-out screen. Otherwise it waits for the navigator's nextstateevent where the route exists.4. Drop a notification tap's pending notification when its switch ends without landing. Tapping a notification for another account starts a switch and stores the notification as pending. Once (2) and (3) clear
userSwitchingon a failed switch, that notification could stay pending. The account-list replay inpush-listenerwould then re-run the switch to that account the next time the accounts refresh, for example after the user logs into a different account.The push store now remembers which account a notification tap is switching to. When a switch ends, its config subscription drops the pending notification only if it's that tap's, the same way it already drops it on a login error. A successful switch has already consumed the notification by then:
push-listenerreplays it as soon as the current user'suidchanges, and the router clears the flag afterwards inonNativeReady. A notification parked for an account that isn't configured yet belongs to no switch, so an unrelated switch ending leaves it for the account-list replay.5. End a switch that lands on the navigator that's already mounted. On success, only the new navigator's
onReadycleareduserSwitching.useUserSwitchNavKeyremounts the navigator only when the username changes from the last account's. So a switch that ends on the same account left the flag set for good. For example: log out, which keeps the stored secret, then tap a notification for that same account. So did the first switch after launching logged out.A stuck flag drops every deep link (
linking.tsx), skips later notification taps as "switch already in progress", and keeps background FS RPCs off. On desktop, whose routers hold the logged-in screens while the flag is set, a later logout also kept the logged-in screens.setUserSwitching(true, username)now records the switch's target (userSwitchingTo), and it survives the mid-switch store reset. All four places that start a switch pass it: the account switcher, the header avatar, the desktop tab-bar quick switch and a notification tap.useUserSwitchNavKeyisn't going to remount, it ends the switch if the username is the switch's target.onReady, so a replayed notification can't be handled by the old navigator.navigationReadyto false, and onlyonReadyset it back. So after logging out and back in on the same navigator, every deep link and notification intent stayed queued (linking.tsx). The switch above depends on that working, and plain logout and re-login to the same account already had the gap. When a username arrives after the user was blank and there's no remount,useUserSwitchNavKeynow marks the mounted navigator ready for that account. It does that before ending the switch, so the intent the switch replays can run. The first render still leaves readiness toonReady.Testing
Unit tests:
stores/tests/config.test.ts:RPCErrorfailure, or an RPC error leavesuserSwitchingcleared.usernamethroughnavigateAppendOnceRootHas('loggedOut', …), withuserSwitchingalready cleared at that moment.router-v2/account-switch.test.tsx:showLoggedInScreensfollowsloggedInwith no switch running. It holds the logged-in screens through the flap of a switch that started logged in, and keeps the logged-out screens for a switch that started logged out.constants/navigate-append-once-root-has.test.ts: the helper pushes right away when the route exists. Otherwise it waits for the route to mount and pushes once, and it gives up after the timeout.stores/tests/push.test.ts:handlePush, drops that notification when it ends without landing.router-v2/use-user-switch-nav-key.test.tsx:onReady.onReady.stores/tests/config.test.ts:setUserSwitchingrecords the target and whether the switch started logged in. It clears both with the flag and keeps them across the mid-switch reset.showLoggedInScreensignoring where the switch started, only the "switch that started logged out" test fails.setUserSwitchingnever recording a logged-in start, only its config test fails.setUserSwitching(false)calls in thecatchremoved, both of their tests fail.push.tsx, only the notification-drop test fails.stores/tests/as-mobile.ts). Jest defaults to desktop, where the push store's dispatches are no-ops, so tests run that way would pass without exercising anything.The provisioning path was not exercised in the simulator; only the unit tests cover it.
Simulator: rapid back-and-forth account switching in the iOS simulator by long-pressing the header avatar. The runs were instrumented with temporary logging of navigator mounts and native dismissals.
Unbalanced calls to begin/end appearance transitionsloginstack mounted mid-switch[NAV] Unhandled action: POP