← Deep linking

English Cymraeg

Case study ·

Deep-link-driven navigation: one resolver for taps and URLs

Async Digital Ltd Cardiff, UK

Abstract

Apps that accept deep links usually implement each destination twice: once in the handler for an incoming URL, and once in the control that performs the same navigation on a tap. When where a destination lands depends on app state, the two copies are free to drift apart, and over the life of a codebase they tend to. This study describes a demo messaging app, built on Deep Link Kit, that avoids the split by making the deep-link URL the canonical form of a state-dependent navigation. A tap does not navigate on its own; it builds the same URL an external link would carry and routes it through a single resolver, open. Navigation is, in this sense, deep-link-driven: the URL grammar is the one place a contextual destination is resolved, for taps and external links alike. The design is deliberately partial. Navigations that carry no state-dependent decision bypass the resolver and navigate directly. This study sets out the principle, the five tap controls that follow it, the resolver that makes it work, the boundary where it stops, and what it costs.

A destination you can reach both by tap and by URL is, in the usual implementation, two separate pieces of code. This study describes a design that makes it one by treating the deep-link URL as the canonical way to navigate: a tap builds the URL an external link would carry, and both resolve through the same code. The result is navigation that is deep-link-driven for every destination whose outcome depends on state.


§1·Background

The divergence problem

Most destinations in a deep-linked app can be reached two ways. A URL handler interprets an incoming link; a button or a row reaches the same place when the user taps it. Each is an entry point: a place in the code where navigation to a destination begins. Most destinations have at least two.

They don't always agree, because where a destination lands can depend on state. Settings is the clear case. From the root of the navigation stack it opens as a sheet; from inside a conversation thread the same link pushes onto the stack instead. The right outcome depends on the current navigation state, and that decision has to be made at every entry point that can reach Settings.

When each entry point makes that decision on its own, nothing holds the two in step. They match the day they're written. Later one is revised and the other isn't, and the app behaves differently depending on whether you arrived by tap or by link. The divergence was never a choice; it accumulates out of duplication. The fix here is structural, not a matter of discipline: if both entry points run the same decision in the same place, there is no second copy left to drift. This app makes the deep-link URL that single place. Every state-dependent navigation, tap or link, is expressed as a URL and resolved once.

Fig 1 The conventional shape: one destination implemented twice, once per entry point. Each copy of the state-dependent decision is free to drift, and over time they do. Deep Link Kit removes the second copy (see Fig 2).
§2·Findings

Every state-dependent tap is a URL

In the app studied, navigation belongs to a single object, the coordinator. It holds the current navigation state and does all the routing, and it exposes exactly one entry point for URLs, a method this study calls open. Given a URL, open parses it against the current state and, if it resolves to a known destination, routes the resulting intent. Every URL the app acts on goes through this one method.

External links reach open the obvious way: the system hands the URL to the app, which forwards it unchanged. The design extends that path to taps. Where a destination’s outcome depends on state, the control that triggers it does not decide for itself; it builds the canonical URL for that destination, the same one an external link would carry, and calls open. The tap and the link become the same request.

Five tap controls follow this rule, and they are all the same move. A control that opens settings and a control that runs a search each build their destination’s URL and call open. So do three more: a tap on a file attached to a message, a tap on a message reached from someone’s profile, and a tap on a search result. In every case the tap is reformulated as a URL before it enters, and none of them resolves a destination on its own.

Fig 2 Six entry points, one resolver. An external link and five tap controls reach the same open method; the state-dependent decision is evaluated there and nowhere else.

The shape is one resolver with six callers: the external-link path and five controls that each turn a tap into a URL. The state-dependent decision lives in the resolver and nowhere else, so a tapped settings control and the matching external link land in the same place, decided by the same code. There is no second copy to disagree. This is what deep-link-driven navigation means in practice: the URL is not only how the outside world reaches the app, it is how the app navigates itself. It also sets the order of work. A new state-dependent destination begins with its URL, and the control that triggers it is written to build that URL.

§3·Mechanism

The resolver

The shared entry point is small. open takes the URL and the current navigation snapshot, parses them, and gets back either a canonical intent or nothing. If the parse succeeds, the intent is routed. The method has no branch separating a tap-originated call from a URL-originated one. By the time a call arrives, that difference is already gone: every caller presents a URL.

Parsing also checks existence. A URL whose identifier does not resolve against the app’s data is rejected right there, so routing never receives a destination that is not there. A tap that builds a URL gets this guard for free: the check that catches a malformed external link also catches a stale identifier captured in a view.

The payoff for the divergence problem is direct. The settings control does not re-decide sheet-versus-push. It builds the settings URL and hands resolution to the same check an inbound link would hit, run against the live snapshot. The state-dependent behaviour is written once; the tap points at it instead of copying it.

Fig 3 One URL, two outcomes. botmessages://settings resolves to a sheet from the root and a push from inside a thread. The state-dependent decision is made once, in the resolver.
§4·Scope

Where taps navigate directly

Not every tap goes through the resolver. Several tap controls navigate directly and never enter it. A tap that opens a conversation pushes it onto the stack. A tap that opens a profile pushes a profile. A tap that begins composing presents the compose sheet. None of them builds a URL. The control-surface study looks at what a resolved navigation does once it lands, the closed set of effects it can carry.

That is deliberate, and the rule behind it is simple: these destinations always behave the same way. A conversation opens as a push from any state. There is no sheet-or-push question to get wrong, so there is nothing two copies of the decision could disagree about. The resolver earns its place by keeping a tap and a link in step on decisions that can drift; where a destination cannot drift, routing it through a URL would add a step and protect nothing.

So the rule is: a tap goes through the resolver when its destination behaves differently depending on the current state, and navigates directly when it always behaves the same way. The first kind is where a separate tap implementation would, in time, drift away from the URL one. The second kind has nothing to keep in sync.

Fig 4 Where taps navigate directly. Some destinations behave the same way from any state, so the controls that reach them skip the resolver. A tap goes through the resolver only when its destination behaves differently depending on the current state.
§5·Cost

Costs of the single path

Routing a tap through a URL is not free, and the costs are worth stating plainly.

Fig 5 The round trip. A converging tap builds a URL that parsing immediately takes apart, a structured value serialised and re-read within one call. The overhead is small and paid on purpose, so a tap gets no special treatment once inside the resolver.
Caveat

The converging taps take a round trip through serialised form. The settings control builds a URL that parsing immediately takes apart again. The overhead is a structured value serialised and re-read inside a single call. It is small, but paid on purpose, so that a tap gets no special treatment once it is inside the resolver.

The line between deep-link-driven and direct navigation is held by convention, not by the type system. Anyone adding a new tap control has to decide which side it is on: state-dependent destinations are expressed as URLs and re-enter the resolver, fixed ones navigate directly. The existing controls show the distinction but do not enforce it, and a convention can be applied wrongly.

The existence gate cuts both ways. Because parsing rejects URLs whose identifiers do not resolve, a tap that builds a URL for a record deleted since the view was drawn is dropped at the gate rather than handled on its own terms. Usually that is the right outcome, but it does mean a tapped control can resolve to nothing in the window where its target no longer exists.

Whether the round trip is worth it comes down to how much you value tap and URL behaviour staying provably identical for the decisions that are hard to keep in sync. For a destination with no contextual outcome, the design is overhead for nothing. For settings, which really does resolve two ways, the alternative is two implementations and the slow drift between them.

The resolver this study keeps pointing at is the same component that decides sheet-versus-push from context. The state-aware resolver study looks at that decision head-on: one URL grammar resolving to different intents according to current navigation state. The full index lives on the deep-linking hub.