Commit 887f90d8f2

887f90d8f2245c8d288cb3c6dee84e7a0c505884

parent: 20e869ceb2

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 23:57 UTC

push: reset the dashboard path on an account switch

.id(account.id) is on the TabView inside ContentView's body, so
ContentView's own @State never resets. dashboardPath therefore
outlived a switch, and the rebuilt NavigationStack re-pushed the
previous account's route against the new account's client. Track which
account the path was built for and clear it from the drain, which
every switch reaches whether a notification is pending or not.

The drain's task id now carries the account as well as the target, so
it no longer depends on .id() propagating identity outward through the
.task modifier attached after it: pending is left set across a
cross-account switch on purpose, so the target alone does not change.

A failed activate no longer strands the target. It removes the account
and leaves current alone, so nothing rebuilds to drain it, and a later
identical tap would not change the key either.

Ref krz/gitbay#89

Layout: unified · split

gitbay/ContentView.swift +58 −12
@@ -10,6 +10,15 @@ struct ContentView: View {
10 @State private var selectedTab = Tabs.dashboard 10 @State private var selectedTab = Tabs.dashboard
11 @State private var dashboardPath = NavigationPath() 11 @State private var dashboardPath = NavigationPath()
12 12
13 /// Which account `dashboardPath`'s routes were resolved against.
14 ///
15 /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so
16 /// this view's own state outlives an account switch while the stack
17 /// it feeds is rebuilt. A path carried across the switch would be
18 /// re-pushed against the new account's client — someone else's
19 /// issue number, usually a 404. Empty while signed out.
20 @State private var pathAccount = ""
21
13 var body: some View { 22 var body: some View {
14 if let client = session.client, let account = session.current { 23 if let client = session.client, let account = session.current {
15 TabView(selection: $selectedTab) { 24 TabView(selection: $selectedTab) {
@@ -46,31 +55,63 @@ struct ContentView: View {
46 } 55 }
47 } 56 }
48 } 57 }
49 .id(account.id) // fresh screens on account switch 58 .id(account.id) // a fresh tab bar and stacks per account
50 .task(id: router.pending) { await routePendingNotification() } 59 .task(id: DrainKey(account: account.id, target: router.pending)) {
60 await routePendingNotification(account)
61 }
51 } else { 62 } else {
52 NavigationStack { 63 NavigationStack {
53 SignInView() 64 SignInView()
54 } 65 }
66 // Signing out leaves the stale path behind in this view's
67 // state; disowning it makes the next sign-in clear it.
68 .task { pathAccount = "" }
55 } 69 }
56 } 70 }
57 71
58 /// Routes a tapped notification, switching accounts first when it 72 /// What makes the drain run again.
73 ///
74 /// The account is in here because the cross-account pass leaves
75 /// `pending` set on purpose, so the target alone does not change
76 /// across the switch it performs. Keying on both means the drain
77 /// does not depend on whether `.id()` propagates its identity
78 /// outward through the `.task` modifier attached after it.
79 nonisolated private struct DrainKey: Equatable, Sendable {
80 let account: String
81 let target: PushTarget?
82 }
83
84 /// Resets the navigation state an account switch invalidated, then
85 /// routes a tapped notification, switching accounts first when it
59 /// belongs to another one. 86 /// belongs to another one.
60 /// 87 ///
61 /// A switch changes `account.id`, which rebuilds this whole view. 88 /// A switch changes `account.id`, which rebuilds the tab bar. That
62 /// That is why the target is left pending across it: the rebuilt 89 /// is why the target is left pending across it: this runs again
63 /// tree runs this again and finds the account already active, so the 90 /// against the new account and finds it already active, so the
64 /// second pass does the navigating. The drain is the mechanism, not 91 /// second pass does the navigating. The drain is the mechanism, not
65 /// a workaround for it. 92 /// a workaround for it — and it is also where the path is reset,
66 private func routePendingNotification() async { 93 /// because every switch comes through here whether a notification
94 /// is pending or not.
95 private func routePendingNotification(_ account: Account) async {
96 if pathAccount != account.id {
97 dashboardPath = NavigationPath()
98 selectedTab = .dashboard
99 pathAccount = account.id
100 }
67 guard let target = router.pending else { return } 101 guard let target = router.pending else { return }
68 if session.current?.id != target.accountID { 102 if account.id != target.accountID {
69 guard let account = session.accounts.first(where: { $0.id == target.accountID }) else { 103 guard let other = session.accounts.first(where: { $0.id == target.accountID }) else {
70 router.pending = nil // signed out of it since the tap 104 router.pending = nil // signed out of it since the tap
71 return 105 return
72 } 106 }
73 _ = session.activate(account) 107 // A token gone from the Keychain drops the account and
108 // leaves `current` alone, so nothing would rebuild and
109 // drain; the target would outlive every later tap, which
110 // cannot change the key either.
111 guard session.activate(other) else {
112 router.pending = nil
113 return
114 }
74 return // the rebuilt tree drains it 115 return // the rebuilt tree drains it
75 } 116 }
76 selectedTab = .dashboard 117 selectedTab = .dashboard
@@ -241,6 +282,11 @@ extension View {
241} 282}
242 283
243#Preview { 284#Preview {
285 // Every environment value the signed-in branch reads, so the
286 // preview does not depend on the machine's Keychain being empty.
287 let session = SessionStore()
244 ContentView() 288 ContentView()
245 .environment(SessionStore()) 289 .environment(session)
290 .environment(PushRouter())
291 .environment(PushRegistrar(session: session))
246} 292}