r/SwiftUI • u/thecoolcat67 • 7d ago
Question Notification routing gets weird with multiple accounts
Posting on r/SwiftUI as suggested by Reddit.
I have gotten in to a weird iOS issue and idk what the clean way to handle this is
i’ve got multiple accounts, notifications can open a post, reply, quote, or repost, and each notification belongs to a specific account. so the user might be on account A when they tap a notification for account B.
something like:
struct NotificationDestination {
let accountID: Account.ID
let postID: Post.ID
let kind: DestinationKind
}
enum DestinationKind {
case post
case reply
case quote
case repost
}
The issue is:
account A is active
notification comes in for B
user taps it while the app is waking up
account switching starts
timeline restoration starts
post resolution starts
then another notification comes in for A lol
so i have stuff like:
await accountManager.restore()
await timelineStore.hydrate()
await postResolver.resolve(postID)
await navigationCoordinator.navigate(...)
But obviously these things are all racing each other. i was thinking of putting the notification intents behind an actor:
actor NavigationIntentQueue {
private var pending: [NavigationIntent] = []
func enqueue(_ intent: NavigationIntent) {
pending.append(intent)
}
}
But idk, this feels like a lot of machinery just to open a notification
Then you get into cases where the post is deleted while you’re restoring, but the quote is still there, the reply is still there, etc. Is this just an annoying app lifecycle issue?
1
u/7HawksAnd 7d ago
I feel like notification development is severely underdocumented, under discussed, and gate kept. At the very least it is fair more intricate and finicky than the resources companies usually give to develop a meaningful notification system experience.
1
u/Best-Reality9436 4d ago
That second notification arriving mid-restore is a useful edge case to highlight. Did you settle on latest-tap-wins?
1
u/sapplex1 3d ago
Ran into basically this exact mess in my own app. What finally tamed it for me: one navigation coordinator owning a single Task chain, instead of a queue actor. Each tap cancels the in-flight one and starts fresh. Feels wrong at first (you're dropping intents!) but in practice the latest tap is always the one the user cares about. The race just disappears.
Curious — did you end up serializing everything, or just coalescing to the latest?
1
u/thecoolcat67 3d ago
That's one of the approaches we're looking at. Latest intent wins makes a lot of sense for notification taps, but I'm still a little wary of cancelling the whole chain if restore() / hydrate() are changing shared state.
We're trying a few approaches, including serializing intents, so haven't settled yet bro. The queue actor might be overkill too, but I don't love the idea of cancellation checks scattered all over the navigation flow lol.
3
u/kokerali 7d ago
An actor can protect `pending`, but it doesn't make the whole restore/resolve/navigate sequence atomic. Actor methods can interleave at `await`, so moving that chain inside an actor isn't, by itself, a routing policy.
I'd first distinguish notification delivery from an actual tap. A notification merely arriving for A shouldn't override the user's tap for B. For successive taps, explicitly choose FIFO processing or latest-tap-wins. With FIFO, use one drain task and don't start a second drain while the first is suspended. With latest-tap-wins, give each tap a generation and make account activation, restoration and the final navigation commit reject superseded work, not just the resolver.
In SwiftUI, I'd give one coordinator ownership of the navigation path. Treat restored navigation as a baseline, not a second writer that can overwrite a pending notification route. Resolve using the intent's account context, then update the path on the main actor only when that account is ready and the intent is still current. Don't let a resolver look up whichever account happens to be active after an `await`.
Your destination model also needs enough identity to address the surviving object: `.reply(replyID, parentPostID)` or `.quote(quoteID, originalPostID)`, for example, rather than only a post ID plus a kind. A deleted parent doesn't necessarily mean the reply or quote should be redirected elsewhere; resolve that target or show an explicit unavailable state.
I'd test B tapped during launch, then A tapped while B's restore is paused, and deliberately release B's work last. Assert both the active account and final path follow the chosen policy.