Part 4 ended with a payload small enough to travel. This part is about where it lands. An activity that reaches your app has not yet reached anything a user recognises: on a Mac with four windows open, or an iPad running two scenes side by side, the system still has to pick one of them. SwiftUI picks by a documented four-step algorithm, and the property it matches on is the one most Handoff tutorials never set.
Arriving in the app is not arriving in the right window
Here is the shape of the bug. You build an activity on the iPhone, set a userInfo dictionary, mark it eligible for Handoff, and make it current. You tap the icon on the Mac. The Mac app comes forward. It opens an empty new window, next to the window that was already showing the very thing you handed off.
Nothing failed. Transport worked, the Team ID matched, the activity type was declared, the payload arrived. What went wrong is selection: SwiftUI was asked which scene should take this continuation, found nothing in the activity it could match a scene against, and fell through to the last step of its algorithm, which is to create a new instance of the first scene declaration.
On a single-scene iPhone build this bug is structurally invisible. Step one of the algorithm sends the activity to the one open scene, so the app you are testing on is the app that cannot show you the defect. It ships, and the first report comes from a Mac or an iPad user with two scenes open.
Apple's four-step algorithm, in order
The algorithm is documented on SwiftUI's Scene.handlesExternalEvents(matching:) page. The steps are evaluated in order until one of them succeeds:
- On single-scene platforms, send it to the one open scene. Done.
- An open scene that prefers, or can handle, the event. That is what the View modifier .handlesExternalEvents(preferring:allowing:) declares. This is the only step that can route a continuation into a window that is already on screen.
- The first scene declaration whose .handlesExternalEvents(matching:) conditions match. Note what this means: a matching scene declaration, not a matching open scene. A new instance is created.
- The first scene declaration without the modifier at all. Again, a new instance is created.
Two things are worth sitting with. First, steps three and four both open something new; only step two reuses a window the user is already looking at. Second, step four is unconditional: it is the fallback that fires when you have configured nothing, and it is why doing nothing produces a new window rather than an error you could have noticed.
What actually gets matched
The same page says, verbatim, what SwiftUI compares against the scene's conditions:
For an NSUserActivity, like when your app handles Handoff, SwiftUI uses the activity's targetContentIdentifier property, or if that's nil, its webpageURL property rendered as an absoluteString.
Apple, Scene.handlesExternalEvents(matching:)
So there are exactly two inputs, and a defined order between them. If you set targetContentIdentifier, that string is the match key. If you do not, the match key is your webpageURL spelled out in full as an absolute string. The conditions you write into the scene then have to be URLs. If you set neither, there is no key, nothing can match, and selection walks to step four.
targetContentIdentifier's own reference page describes the same job from the UIKit side, and adds a recommendation:
When the system delivers an NSUserActivity object to an app with multiple scenes, it chooses the UIScene whose UISceneActivationConditions have the best match… This property is optional but is highly recommended.
Apple, NSUserActivity.targetContentIdentifier
Availability, read from the machine-readable header on Apple's page on 2026-09-23: iOS 13.0 and later, macOS 10.15 and later, watchOS 6.0 and later. The property is as old as scenes themselves, which is the point. It was added when multi-scene became possible, because multi-scene is what makes it necessary. Scene.handlesExternalEvents(matching:) carries iOS 14.0 and later, macOS 11.0 and later, and no tvOS availability.
Setting the identifier, and the needsSave trap
Setting it is one line. The line after it is the one that gets missed:
func advertise(documentID: UUID) {
let activity = NSUserActivity(
activityType: openDocumentActivityType
)
activity.title = String(
localized: "Open document"
)
// The string SwiftUI matches a scene against.
activity.targetContentIdentifier =
ContentID.document(documentID)
activity.userInfo = [
"documentID": documentID.uuidString
]
activity.isEligibleForHandoff = true
// Setting the identifier does NOT do this.
activity.needsSave = true
activity.becomeCurrent()
self.currentActivity = activity
}The comment is Apple's, near enough: the targetContentIdentifier page states plainly that "Setting this property doesn't automatically set needsSave to true." On a fresh activity that has not been saved yet it makes no difference. On an activity you are updating as the user navigates (the case part 4 argued for, where the payload is built lazily in userActivityWillSave behind needsSave), it is the difference between advertising the document the user is on and advertising the one they opened first.
Note also the strong reference on the last line. That is not decoration: an activity with no owner can be released before the system is finished with it, and Apple states the requirement explicitly for search-eligible activities. Keeping one property on the object that advertises the activity costs nothing and removes a class of intermittent bug.
Declaring which scene wants which events
Scene-level conditions, for the sets you can enumerate
A scene declaration states the conditions it handles. This is step three of the algorithm: it selects which declaration gets instantiated, so it is the right tool for "a continuation of this kind belongs in this kind of window", not for "this document belongs in that open window".
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup(id: "document") {
DocumentRootView()
}
.handlesExternalEvents(
matching: [ContentID.documentScheme]
)
// Never the target of a continuation.
WindowGroup(id: "preferences") {
PreferencesRootView()
}
.handlesExternalEvents(matching: [])
}
}The empty set on the second scene is deliberate, though read it as an inference from the wording of the algorithm rather than as an Apple statement: a scene with no modifier is a candidate for step four, while a scene with an empty condition set is not a declaration without the modifier at all, so it stops being the accidental destination for everything you forgot to route. Ordering matters too, since steps three and four both say "the first". Declare the scene you would least regret opening before the others.
View-level conditions, for the sets you cannot
Per-document identifiers cannot be enumerated at declaration time; you do not know the document's identifier until a window is showing it. That is exactly what the View modifier is for. It is evaluated against scenes that are already open, which makes it the only route into step two.
struct DocumentRootView: View {
@State private var documentID: UUID?
var body: some View {
DocumentDetail(documentID: documentID)
.handlesExternalEvents(
preferring: handledIdentifiers,
allowing: handledIdentifiers
)
}
private var handledIdentifiers: Set<String> {
guard let documentID else { return [] }
return [ContentID.document(documentID)]
}
}The set is computed, not literal, so it tracks the window's state. A window showing document A advertises document A's identifier; open document B in a second window and that window advertises B's. A continuation for B now has an open scene that says it can handle it, and step two fires before the algorithm ever reaches the steps that create windows.
An identifier scheme that survives part 6
The identifier is a routing key, and it is also a string that leaves the device. Part 6 is about what must never cross a Handoff; the scheme below is written so that nothing said there forces a redesign here. Two rules do most of the work: a scheme prefix that says what kind of thing this is, and an opaque identifier that says which one. Never a name, a subject line, an email address or anything else a human would recognise over someone's shoulder.
enum ContentID {
static let documentScheme = "document"
/// "document:6F9619FF-8B86-D011-B42D-…"
static func document(_ id: UUID) -> String {
"\(documentScheme):\(id.uuidString)"
}
/// Nil for anything this build does not route.
static func documentID(
from raw: String
) -> UUID? {
let parts = raw.split(
separator: ":",
maxSplits: 1,
omittingEmptySubsequences: false
)
guard parts.count == 2,
parts[0] == documentScheme else {
return nil
}
return UUID(
uuidString: String(parts[1])
)
}
}Three properties come out of writing it this way. It round-trips, so the receiving side resolves the identifier with the same code that produced it rather than a second, subtly different parser. It is a pure function over a string, so it is unit-testable with no device and no second machine. That matters more than it sounds, because part 9's finding is that almost nothing else about Handoff is. And it fails closed: an identifier from an older or newer build that this one does not understand returns nil, and the receiving code can decline the continuation instead of routing it somewhere wrong.
A UUID is the easy case. When the natural key is a server-side identifier, the same rule applies: send the identifier, resolve it locally, and let the receiving device fetch what it is allowed to fetch. The activity is a pointer, not a copy.
DocumentGroup does not play
One exemption to remember before you spend an afternoon on it: DocumentGroup ignores handlesExternalEvents entirely. It matches on the document types it supports instead. If your app is document-based, scene selection for those windows is decided by the type system Apple already built for documents, and adding the modifier to a DocumentGroup does not change the outcome.
Where Apple's documentation stops
The honest boundary of this part, because a reader on a Mac will walk straight into it.
- No page found in this research pass documents openWindow consuming an NSUserActivity. The SwiftUI environment action can open a window by id and by presented value; taking a continuation is not part of its documented surface, and we are not going to assert that it is.
- There is no macOS-specific multi-window routing rule beyond the shared four-step algorithm. AppKit's app-delegate continuation seam hands you an activity and a restoration handler; which of your existing windows should receive it is a question Apple's pages do not answer for you.
- Which means: a reader who wants a Handoff to land in one specific, already-open Mac window is at the edge of what Apple documents. Step two is the mechanism, the View modifier is the lever, and past that you are testing on two machines and writing down what you observe.
- Related, and covered properly in part 3: multiple community forum threads report that .onContinueUserActivity never fires in a SwiftUI macOS app. No Apple engineer replied in any of them, and no root cause was ever posted. That is developer experience, not an API contract. It is still a reason to verify your macOS arrival path before blaming your scene conditions for a continuation that never showed up at all.
What is settled is the part that costs one line. Set targetContentIdentifier, remember needsSave, and give at least one scene an explicit condition set. That moves you from step four, where the system invents a window, to a step where it has something to reason about.
Previously (Part 4): How Big Should a Handoff Payload Be in 2026
Next up (Part 6): What Must Never Cross a Handofflands in 2 days
Comments
Loading comments…