When navigate means more than push
Async Digital Ltd Cardiff, UK
An app’s most useful destinations are often not screens. A search query, a list filter, a pending scroll, an expanded avatar: each is state a view reads, not a view to push. Deep Link Kit factors that state into a closed set of effect cases the consumer declares, and applies them through one switch, in order, between structural navigation steps. The payoff is a programmable input surface. A tap, a URL, a test, and a scripted screen capture all drive the app through the same code path, and every write lands in a trace stamped with the state it produced. This article walks the demo app’s full set of cases, follows the four entry points to the one switch they share, and names what the design pays for.
Search, filters, pending scrolls, expansion overlays. None of them is a screen to push, and all of them are places a URL should be able to take you. The kit’s answer is a closed set of writable cases and one switch that applies them, whoever is asking.
The state a screen router cannot reach
Two earlier pieces set this one up. One counted the reads: views consulting shared coordinator state, dozens of times, and treated that as the honest cost of ephemeral state that several screens care about. The other showed how URLs reach that state without leaking context into the URL’s grammar. This piece names what the state actually is, seen from the app’s side: a closed set of writable cases.
The question that motivates it: when “navigate” means more than push and present, where does the rest live? A conversations search. A list filter. A scroll that has to wait for its list to exist. An avatar expansion overlay. An attachment opened full screen. None of those is a screen to push. All of them are state a view reads.
Most navigation layers stop at the screen boundary and leave that state to ad-hoc setters scattered through the app. Deep Link Kit takes the opposite position. A navigation plan is a sequence of steps, and a step is either structural (push, pop, present, dismiss) or an effect: one case of an enum the consumer declares, applied through one method the consumer overrides. The kit does not know what an effect means. It guarantees when effects run, in what order, and what happens when a newer URL interrupts them.
A closed set of writable cases
The consumer’s half of the contract is that enum. The kit calls the arm Step.effect, and each flow binds its own effect type to it, so a plan can interleave structural steps and effect steps in one ordered list. Each case names one writable piece of ephemeral state, and the apply switch is the one place those writes happen. In the demo messaging app this series measures its claims against, the full set is ten cases writing seven observable properties.
| Effect case | Writes |
|---|---|
applyInThreadSearch(query:) | activeSearchQuery |
clearInThreadSearch | activeSearchQuery = nil |
clearListFilter | activeListFilter = nil |
scrollToAvatarRow(_:) | pendingAvatarScroll |
scrollToConversationRow(_:) | pendingConversationScroll |
expandAvatar(_:) | expandedAvatar (resolves a bot to its one-to-one conversation) |
collapseAvatar | expandedAvatar = nil |
expandImageAttachment(_:) | pendingAttachmentExpansion |
dismissAttachmentExpansion | pendingAttachmentExpansion = nil |
selectConversationsBot(_:) | selectedTab and activeListFilter in one pass |
The shape of the set is as telling as its size. Symmetric pairs share a property: the search pair writes one, the avatar pair writes one, the attachment pair writes one. One composite case, selectConversationsBot(_:), writes two properties in a single pass so the interface never shows the halfway state. Views read these properties directly. Tests and automation read them through a snapshot: a value type that packages the coordinator’s observable state, stamped onto every effect event the trace records.
The kit deliberately owns none of these names. I left the cases to the consumer because only the consumer knows what its ephemeral state is; what the kit owns is the guarantee that the set stays closed. If a piece of state is worth driving from a URL, it earns a case, one switch arm, and nothing else.
Four entry points, one implementation
What makes the set a control surface rather than a routing table is who gets to use it. Four callers converge on the same switch.
- Taps. The search icon’s tap handler does not write
activeSearchQueryitself. It constructs a URL and opens it, so the same state-aware decision applies as for a link arriving from outside. Parity by construction, not by code review. - URLs. The open path parses an incoming URL against a frozen snapshot of current state, builds a plan, and runs it. This is the path everything else borrows.
- Tests. The test driver fires URLs at the open path and asserts on the recorded trace. It reads the same observable properties the views do, through the same snapshot type.
- Scripted capture.
simctl openurldrives the open path from a shell. The recordings in this series were staged that way: a script firing URLs at the running app until the screen shows the state worth capturing.
The four entry points are not parallel implementations of the same write; they are one implementation, reached four ways.
A tap is a URL the user didn’t see. A test is a URL with assertions. A capture session is a URL with a camera running. Remove any one of them and the switch neither knows nor cares.
One intent, one ordered list
Effects earn their keep when they compose with structural navigation. When a URL like botmessages://profile/aria-7 asks for an expanded avatar, the flow returns one ordered list:
case .avatarExpanded(let id):
return [
.effect(.dismissAttachmentExpansion),
.effect(.clearInThreadSearch),
.effect(.clearListFilter),
.nav(.popToRoot),
.nav(.dismissSheet),
.effect(.scrollToAvatarRow(id)),
.effect(.expandAvatar(id))
]
Five effect steps and two nav steps for one intent. Clear whatever might be covering the screen, walk the stack back to the root, then scroll and expand. The kit runs the list in order and checks for cancellation between steps; the consumer’s switch only ever sees one case at a time.
The override wrapped around the switch does three things before any write:
- Checks whether a newer URL has already overtaken this run, and if so records the case in the trace with a
[cancelled]suffix instead of applying it. - Asks whether the case is a no-op against current state. The idempotent clears (collapse the avatar, clear the search, clear the filter, dismiss the attachment) skip the write and record a
[skipped]event. - Defers the trace record itself, so even the runtime-guarded cases land in the trace with the right suffix.
Inside the switch, every write is a single assignment to an observable property. The view layer reacts, the trace stamps a post-state snapshot, and the next step runs. The trace is the part tests and agents lean on: not “the URL was handled”, but “these writes happened, in this order, and this is the state each one left behind”.
botmessages://profile/aria-7 is seven steps run in order, five effect and two nav interleaved, with cancellation checked between steps. Each step passes through one apply that guards before it writes; every write stamps a post-state snapshot into the trace.What the design pays for
None of this is free. Four costs are worth naming, because each one bounds how far the trace can be trusted and how the surface behaves at its edges.
Composite cases hide internal ordering. selectConversationsBot(_:) writes the tab and the filter in one pass so the user never sees an unfiltered tab in between. The cost is granularity: the trace shows one event, not two. Diagnosing a regression where the tab switched but the filter did not means reading the code, not the trace.
No-op detection is hand-enumerated. The four idempotent clears are listed by hand, each with a predicate naming the property it checks. The kit cannot infer no-op-ness from a case. A new clear-style effect needs a new entry, or the trace records spurious writes.
Runtime guards skip rather than crash. Two cases look their argument up in the demo fixture data, and an unknown ID marks the step skipped rather than stopping the run. The surface stays open to invalid input; the consumer absorbs the failure at apply time. Parse stays pure; apply carries the guards.
Trace recording is unconditional. Every apply records an event with a fresh snapshot, and the event list grows until the consumer resets it. In production that is bounded by the trace’s lifecycle; in long-running sessions, resetting is the consumer’s job.
Parity as a structural fact
When tap entry and URL entry share an apply switch, “you can drive the app by URL” stops being a claim and becomes a property of the architecture. A test fires a URL because URLs are the input language. A demo recording fires a URL for the same reason. An agent positioning the app for a screenshot fires a URL because there is nothing else to fire. No special path for tests, no separate hook for the recording, no parallel API for automation to drift out of date.
The same surface answers a harder question: how much can one URL hand the user, ready to continue rather than start over? Priming a draft is an effect case like any other, and a primed, mid-edit compose state two sheet layers deep turns out to be one URL’s worth of work. The final piece in this series shows that end to end. The full map lives on the deep-linking hub.