Same URL, right meaning, from anywhere
Async Digital Ltd Cardiff, UK
In a URL scheme worth reading, every destination has a name. The complication is that the right way to arrive often depends on where the user already is: settings should present as a sheet over the conversations list and push onto the stack from inside a thread. Encoding that difference into the URL keeps the resolver simple and ruins the grammar. Deep Link Kit takes the other path. Every destination keeps exactly one URL form, and at the moment a URL arrives the kit freezes a three-field snapshot of the navigation state and hands both to a pure parse function, which settles what the URL means right now. This case study walks the state-aware hosts in the kit’s demo consumer, shows the same URL resolving two different ways on camera, and names what the design pays for in return: meaning you can only read with the state in hand, tests that grow per branch arm, and a three-edit cost for every new state-aware decision.
How do you keep URLs short and readable when the right destination depends on where the user already is? Deep Link Kit’s answer is to give every destination one URL form and let a frozen snapshot of the app’s current state settle how to arrive. The grammar stays clean, and the same short URL means the right thing from anywhere.
A destination, not a routing instruction
“Open the settings screen” is a destination, and botmessages://settings is its name. But the right way to arrive depends on context. From the conversations list, settings belongs in a sheet over the list. From inside a conversation thread, it belongs on that thread’s navigation stack, pushed like any other screen. Same destination, two right arrivals, and the user never asked to choose between them.
There are two conventional ways out, and both leak. You can mint a URL form per arrival, a sheet form and a push form, and make every caller pick. Or you can keep one form and bolt the choice on with a ?presentation=sheet query parameter or a path segment. Either way, the URL stops being a destination and starts being a destination plus a routing instruction. Every caller (a test, a Shortcut, a support reply, an agent driving the phone) now has to know the app’s state before it can choose the right string, which is exactly the knowledge a URL was supposed to spare them.
This series has already measured the kit from the outside: the surface-area piece counted how much of a consumer’s codebase URL handling is allowed to touch. This piece is about a judgement made inside that boundary. If URLs are the input language for any visible state in the app, then a URL has to know the state it is entering. Deep Link Kit is how I keep that promise without making the URL carry its own context.
One URL, two outcomes
Three state-aware hosts. Five branch arms. One snapshot type. That is the entire state-aware footprint in the demo messaging app built as the kit’s consumer, and it covers every place where one URL legitimately means two things.
botmessages://settingsresolves as a settings sheet at the conversations root, and as a push inside a thread. One snapshot field decides: what sits on top of the navigation stack.botmessages://search?q=deliverybecomes a search scoped to the conversation the user is reading, and a global search from anywhere else. The same field decides.botmessages://attachment/<id>opens a full attachment detail screen when the app is shallow, and a Quick Look overlay when the user is deep in a stack or already has a sheet up. Two fields decide: stack depth, and whether a sheet is present.
A fourth host is state-aware only as a fallback. Image attachments expand the same way wherever the URL lands; everything else falls through to the attachment host’s shallow-versus-deep branching. Five branch arms in total, and not one of them visible in the grammar.
The pair of recordings below shows the settings case live: the same URL fired twice, once from the conversations root and once from inside a thread.
From the conversations root
From inside a thread
botmessages://settings, fired from two starting points. A sheet over the conversations list on the left, a push onto the thread’s stack on the right.A frozen snapshot feeding a pure function
At the moment a URL arrives, the coordinator takes a snapshot of where the app is. Three read-only fields: what sits on top of the navigation stack, how deep that stack is, and which sheet, if any, is presented. In code the snapshot is a small value type (stackTop, stackDepth, sheetTop), constructed once and handed to the parser together with the URL.
Given that snapshot, parse is a pure function. It reads nothing else, changes nothing, and never asks the navigator a question while it runs. The state-awareness is one frozen read into pure logic, not a routing tree interrogating live state. That shape is what keeps the cleverness testable: a test constructs any snapshot it likes, with no app running, and asserts the intent that comes out. It is also what keeps the answer stable, because the app cannot shift underneath a parse that has already begun.
Emit goes the other way, and it is always state-independent. Every destination round-trips to exactly one URL form, however parse arrived at it. There is no sheet-specific settings URL and no push-specific one; ask the kit for the settings URL and you get botmessages://settings, full stop. That asymmetry is the whole bargain.
The URL is the destination’s name; the snapshot is what fills in how we get there from here.
Short grammar, non-local meaning
Nothing here is free. The grammar gets its brevity by moving the meaning somewhere else, and it is worth being precise about where the bill lands.
Meaning is non-local. A URL alone no longer tells you what it does. To answer “what will this open?” you need the URL plus the current navigation state. For botmessages://settings the answer is one of two, never harder, but it is two.
Tests grow per branch arm. A state-independent destination takes one test. A state-aware host takes one per arm, at every snapshot shape that matters, so the test count grows linearly with the arms.
A new state-aware decision is three coordinated edits: the branch arm in the resolver, the entry in the gate that says the URL resolves at all, and the row in the round-trip table that gives the intent its one URL form. The state-independent path needs only the last.
Stateless URLs are the boring kind, and most of the kit’s grammar stays boring on purpose: one URL, one destination, no surprises. Stateful URLs put context somewhere, and the only real question is where. Deep Link Kit’s answer is a pure function reading three frozen fields. The grammar stays short, the parse stays testable, and the URL a user copies out of a screenshot is the same URL a test fires.
The snapshot settles how a URL arrives. What a URL is allowed to change once it has arrived (search queries, filters, scroll targets, expansion overlays) is its own closed set, and the next piece in this series names it from the consumer’s side: one apply switch that taps, URLs, tests, and recordings all converge on. The full map lives on the deep-linking hub.