Mobile architecture
A UIKit scene migration can launch and still lose its first link
The migrated app opens from its icon and handles links while it runs. Then a link arrives while the process is stopped and the app shows its home screen. The cause is one omitted line, and the test that catches it.
By Mike Salari. Staff mobile engineer · 500+ technical interviews from the hiring side.
5 min read
As a staff mobile engineer with 500+ technical interviews from the hiring side, I ask about deep links in iOS interviews. The answer shows whether a candidate has migrated a real app. Everyone knows the parser. Few people remember where the first link arrives after the app moves to scenes.
Here is the failure, in a small sample. An orders app opens correctly from its icon. Its scene delegate creates a window, installs the root view controller and makes the window visible. A review sees a working screen and moves on.
Then someone taps a link to order 42 while the process is stopped. The app shows its home screen. Opening a second link after launch works. The parser is fine and the destination screen is fine. The migration left out the event carried by the connecting scene.
It hides easily in code review and deserves a regression test. A sample shows it: a legacy UIKit app and a scene-based app built from the same route model. Custom URLs open through the simulator, and the displayed text is checked in each.
Why you are migrating at all
Apple's TN3187 says the UIScene lifecycle is required for apps built with the SDK that follows iOS 26. The SDK that follows iOS 26 is the iOS 27 SDK. Read the technote for the exact wording that applies to your Xcode. It also separates adopting the scene lifecycle from supporting several windows. Multiple windows are optional.
The sample targets an older iOS deployment target and runs on a current iPhone simulator. Before you change a production target, record its SDK, deployment target and scene configuration. Check the built Info.plist and the application delegate. Searching for UIApplicationSceneManifest alone does not tell you whether the runtime receives a valid scene configuration from code.
Give the route a value before you give it a window
The fixture accepts a deliberately small URL language: salari-scene://orders/42. It rejects query parameters, fragments, credentials, ports, extra path components and non-numeric IDs. Those limits belong to this example, not to every deep-link policy.
import Foundation
struct OrderRoute: Equatable, Codable {
let id: String
static func parse(_ url: URL, scheme: String) -> OrderRoute? {
guard let c = URLComponents(url: url, resolvingAgainstBaseURL: false),
c.scheme == scheme, c.host == "orders", c.user == nil,
c.password == nil, c.port == nil, c.query == nil, c.fragment == nil else { return nil }
let parts = c.path.split(separator: "/", omittingEmptySubsequences: false)
guard parts.count == 2, parts[0].isEmpty,
!parts[1].isEmpty, parts[1].allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil }
return OrderRoute(id: String(parts[1]))
}
}
struct SceneRouteState {
private(set) var route: OrderRoute?
private(set) var transitions = 0
mutating func apply(_ next: OrderRoute) -> Bool {
guard next != route else { return false }
route = next
transitions += 1
return true
}
}Each scene owns its own SceneRouteState. There is no global selectedOrderID and no search for a key window. Applying order 43 while 43 is already showing adds no transition. That is destination equality, not exactly-once delivery. If a link starts a purchase or sends a message, it needs its own authorisation and idempotency.
Parsing an ID does not authorise access to the order either. A real coordinator keeps the destination pending through sign-in, loads authorised data, and handles an order that was deleted. Keep tokens out of URLs and do not log full incoming payloads.
Consume the first event after you build its owner
This is the scene delegate from the fixture. DROP_CONNECTION is the negative control. Leave it undefined for the correct build.
@MainActor
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var router: SceneRouter?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let ws = scene as? UIWindowScene else { return }
record("willConnect", session: session.persistentIdentifier,
detail: "urls=\(options.urlContexts.count)")
let r = SceneRouter(session: session.persistentIdentifier)
router = r
let w = UIWindow(windowScene: ws)
w.rootViewController = r.screen
window = w
w.makeKeyAndVisible()
#if DROP_CONNECTION
record("droppedConnectionOptions", session: session.persistentIdentifier)
#else
for context in options.urlContexts { r.receive(context.url, source: "connectionOptions") }
#endif
}
func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
record("sceneOpen", session: scene.session.persistentIdentifier)
for context in contexts { router?.receive(context.url, source: "openURLContexts") }
}
func sceneDidBecomeActive(_ scene: UIScene) { record("active", session: scene.session.persistentIdentifier) }
func sceneDidEnterBackground(_ scene: UIScene) { record("background", session: scene.session.persistentIdentifier) }
}The first link is in options.urlContexts, and it is consumed after the router and window exist. Later links arrive through openURLContexts. Both callbacks feed one scene-owned router, so a stopped process and a running one validate and navigate the same way.
This destination can be applied at once. If your root coordinator becomes ready asynchronously, you need an explicit pending-route handoff with ownership and replacement rules. An early callback must not vanish because an optional coordinator was nil.
What the simulator should deliver
One XCTest UI test covers five scenarios in each app, ten steps in total. A host-side check reads the logs and requires three applied destinations, one repeated destination and one rejected input per app.
| Scenario | Screen shows | Scene log |
|---|---|---|
| Stopped process, open order 42 | Order 42 | connectionOptions, transitions = 1 |
| Existing scene, open order 43 | Order 43 | openURLContexts, transitions = 2 |
| Repeat order 43 | Order 43 | repeat, transitions stays 2 |
| Order 43 with an unsupported query | Order 43 stays | rejected, nothing applied |
| Terminate process, open order 99 | Order 99 | connectionOptions, new router at 1 |
The logs are worth reading for one more thing. The scene's persistent session identifier can stay the same before and after the process is terminated, while the router's in-memory count restarts. A new process does not mean a new scene session. Order 99 came from the incoming URL, not from restored state.
Callback order seen in logs holds for those scenarios. Do not read it as a promise about every OS version. Assert the relationship you depend on: the destination owner exists before it consumes its event.
Make the omission fail on purpose
The DROP_CONNECTION build keeps the window, the parser and the warm callback intact and discards the connection options. The cold-start test asks for order 42 and waits for that text. The negative build must fail because the screen assertion fails. The corrected build must pass.
An icon launch would not catch this. A suite that only opens links in a running app would not catch it either, because the broken build still implements that callback. Put the regression at a destination your users open, including any sign-in or loading screen on the way.
Two harness traps: a bare simctl openurl can return success while a system confirmation dialog blocks delivery, and a scheme opened before the app is installed goes nowhere. Install both apps first and use XCTest's system URL API. A command that submits a URL has not delivered a route.
What this does not cover
The sample covers custom URLs on an iPhone simulator. Universal links, notification taps, state restoration and two iPad windows are separate rows in your own matrix. A cold universal link hands its activity to the scene coordinator. A notification response can arrive while the scene is still being built. Do not copy the custom-URL result into those rows and mark them passed.
For iPad, open two windows on different orders, send an event to one, and check the other window's screen and saved route did not change.
Next steps
Write the cold-start test first, against the app you ship today. If it passes on the legacy lifecycle, it is your safety net for the migration. If you are preparing for a senior iOS loop, the iOS preparation route puts this routing case beside concurrency and system design. For the live round, Share Your Screen covers how to narrate a fix like this while the interviewer watches.
If the decision in this essay is the one in front of you, Controlled Change takes it through the cost of changing an app that is already live.