Search, filters, pending scrolls, expansion overlays. None of these is “push a screen”, yet all of them are places a URL should be able to take you. Iris’s answer is a closed set of named state changes applied through one shared step that every caller (taps, URLs, tests, captures) drives.
Useful destinations are often not screens at all. A search query, a filter, a scroll target, an expanded avatar (these are pieces of view state). Iris treats them as a fixed, named vocabulary of effects. They run in order through one apply step, interleaved with structural navigation.
The result is a single programmable surface. Taps, URLs, tests, and scripted captures all converge on the same code. Every change is traced with the state it left behind. This article names the full vocabulary, shows the four entry points, and is honest about the costs.
Two earlier pieces set this up. The surface-area piece counted dozens of view reads of shared coordinator state (the honest cost of ephemeral state that multiple screens care about). The state-aware piece showed how URLs can reach that state without leaking context into the grammar.
This piece names the state itself, from the app’s perspective: a closed vocabulary of named changes.
When “navigate” means more than push and present, where does the rest live? A search in the conversation list. A filter on the inbox. A scroll target that must wait for the list to exist. An avatar overlay. None of these is “push a new screen”. All are pieces of observable state that views read.
Most navigation systems stop at the screen boundary and scatter ad-hoc setters through the rest of the code. Iris takes the opposite view. A navigation plan is an ordered list of steps. Each step is either structural (push, pop, present, dismiss) or an effect (a named state change the app declares and implements in one place). Iris guarantees ordering, cancellation points between steps, and what happens when a newer URL arrives. It does not need to know what the effects mean.
The app’s half of the contract is that set of changes. Each one names a single piece of ephemeral state, and a plan can interleave structural steps and these effect steps in one ordered list. The one place those changes are actually made is the apply step. In the demo messaging app this series measures its claims against, the full set is ten changes writing seven observable properties.
The ten are ordinary app state, named once each: set or clear an in-thread search; clear the list filter; queue a scroll to an avatar row or to a conversation row; expand or collapse an avatar; expand or dismiss an image attachment; and one composite that selects a bot’s tab and its filter together.
The shape of the set is as telling as its size. The changes come in symmetric pairs that share a property: set-and-clear for search, expand-and-collapse for the avatar, expand-and-dismiss for the attachment. The one composite change 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 packaged copy of the app’s observable state that is stamped onto every change the trace records.
Iris deliberately owns none of these names. I left the changes to the app, because only the app knows what its ephemeral state is; what Iris owns is the guarantee that the set stays closed. If a piece of state is worth driving from a URL, it earns one named change, one entry in the apply step, and nothing else.
What makes the set a control surface rather than a routing table is who gets to use it. Four callers converge on the same apply step.
simctl openurl drives 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, and the apply step neither knows nor cares.
Effects shine when combined with structural moves. For botmessages://avatar/aria-7/expanded, Iris produces one ordered plan:
case .avatarExpanded(let id):
return [
.effect(.dismissAttachmentExpansion),
.effect(.clearInThreadSearch),
.effect(.clearListFilter),
.nav(.popToRoot),
.nav(.dismissSheet),
.effect(.scrollToAvatarRow(id)),
.effect(.expandAvatar(id))
]
Clear the screen, reset the stack, then scroll and expand. Iris executes the list step-by-step, checking for cancellation between each one. The apply step sees only one change at a time.
Before writing, apply does three things:
[cancelled] and stop.[skipped].Every write touches one observable property. Views update, the trace records the post-state snapshot. Tests and agents rely on this: the trace says what actually happened, not just that the URL was “handled”.
botmessages://avatar/aria-7/expanded 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.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.
The composite change hides internal ordering. It 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.
Skip detection is listed by hand. The four clears are listed by hand, each with a small check naming the property it tests. Iris cannot work out on its own whether a change would do nothing. A new clear-style effect needs a new entry, or the trace records writes that did not really happen.
Runtime guards skip rather than crash. Two changes look their argument up in the demo’s fixture data, and an unknown id marks the step skipped rather than stopping the run. The surface stays open to invalid input; the app 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.
When tap entry and URL entry share one apply step, “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.