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 {
1010 @State private var selectedTab = Tabs.dashboard
1111 @State private var dashboardPath = NavigationPath()
1212
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
1322 var body: some View {
1423 if let client = session.client, let account = session.current {
1524 TabView(selection: $selectedTab) {
@@ -46,31 +55,63 @@ struct ContentView: View {
4655 }
4756 }
4857 }
49 .id(account.id) // fresh screens on account switch
50 .task(id: router.pending) { await routePendingNotification() }
58 .id(account.id) // a fresh tab bar and stacks per account
59 .task(id: DrainKey(account: account.id, target: router.pending)) {
60 await routePendingNotification(account)
61 }
5162 } else {
5263 NavigationStack {
5364 SignInView()
5465 }
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 = "" }
5569 }
5670 }
5771
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
5986 /// belongs to another one.
6087 ///
61 /// A switch changes `account.id`, which rebuilds this whole view.
62 /// That is why the target is left pending across it: the rebuilt
63 /// tree runs this again and finds the account already active, so the
88 /// A switch changes `account.id`, which rebuilds the tab bar. That
89 /// is why the target is left pending across it: this runs again
90 /// against the new account and finds it already active, so the
6491 /// second pass does the navigating. The drain is the mechanism, not
65 /// a workaround for it.
66 private func routePendingNotification() async {
92 /// a workaround for it — and it is also where the path is reset,
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 }
67101 guard let target = router.pending else { return }
68 if session.current?.id != target.accountID {
69 guard let account = session.accounts.first(where: { $0.id == target.accountID }) else {
102 if account.id != target.accountID {
103 guard let other = session.accounts.first(where: { $0.id == target.accountID }) else {
70104 router.pending = nil // signed out of it since the tap
71105 return
72106 }
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 }
74115 return // the rebuilt tree drains it
75116 }
76117 selectedTab = .dashboard
@@ -241,6 +282,11 @@ extension View {
241282}
242283
243284#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()
244288 ContentView()
245 .environment(SessionStore())
289 .environment(session)
290 .environment(PushRouter())
291 .environment(PushRegistrar(session: session))
246292}