In most navigation code the choice between presenting a sheet and pushing onto the stack is made wherever navigation begins, so it is made in many places, and copies drift. Iris makes it once. A flow turns an intent into a fixed list of steps, and the coordinator applies them. The same intent lands the same way, from any caller.
Present a sheet, push onto the stack, pop to root: the right structural move for a destination often depends on where the user already is. That is a real decision, and the danger is not making it, but making it in more than one place. Every entry point that can reach a destination tends to keep its own copy of the rule, and copies fall out of step over time.
Iris removes the copies. A NavigationFlow exposes one static function that maps an intent to an ordered list of Step values. Each step is either a structural NavTarget the library dispatches (present, push, pop to root, dismiss) or a consumer side effect. Because that function reads only the intent, the same intent produces the same steps everywhere, and a RouteCoordinator applies them the same way, under latest-wins cancellation, whatever fired them. This piece explains the mechanism at the library level, not the demo consumer that measures it.
Whether a destination arrives as a sheet or a push is genuinely state-dependent. Settings opens as a sheet from the root of the stack and as a push from inside a thread. Something has to make that call, and there is nothing wrong with the call itself. The problem is where it gets made.
If each entry point decides for itself, the app ends up with as many copies of the sheet-versus-push rule as it has ways in. A tap handler holds one copy, a URL handler holds another, a test helper a third. They agree the day they are written. Later one is revised and the others are not, and the same destination arrives differently depending on how the user got there. The divergence was never a design choice; it accumulates out of duplication, quietly, the way duplicated logic always does.
The fix is structural rather than a matter of care. Give the decision exactly one home and route every entry point through it, and there is no second copy left to disagree with the first. Iris is the layer that provides that home and makes routing through it the path of least resistance. What follows is how it does that: one function that names the structural moves, and one coordinator that carries them out.
In Iris a consumer’s flow conforms to NavigationFlow, and its whole obligation is a single static function that takes an intent and returns an ordered list of Step values: the operations that realise the intent. Nothing else on the type decides navigation. If you want to know what an intent does, there is one function to read.
A Step is one of two things. Either a structural NavTarget that the library carries out on the consumer’s behalf, or a consumer-defined side effect. That split is deliberate: it keeps the library’s structural moves apart from the consumer’s own state changes, so the structural cases never need hand-written handling at each screen. Push and present are the library’s to run, not boilerplate the consumer re-types.
NavTarget is a closed vocabulary of exactly four structural moves: present a sheet, push a route, pop to root, and dismiss a sheet. Those are the arrivals, and the list is short on purpose. The sheet-versus-push question this piece is named for is simply which NavTarget case a step carries: choosing a sheet over a push is choosing .present over .push. That choice is written in exactly one place, the flow’s step function, and nowhere else in the library or the app.
Because the function is static and its only input is the intent, it behaves as a pure function of the intent. Feed it the same intent and you get the same list of steps every time, with no dependence on which object called it or what the app happens to be doing at that instant. The state-dependent part of the decision has already been folded into the intent upstream, where a URL was parsed against a snapshot of the app, or a tap named its intent directly. From the intent onward the expansion is fixed.
NavTarget inside each .nav step, decided once inside the step function; the coordinator only applies what the list says.Choosing a sheet over a push is choosing one NavTarget case over another, and it is written in exactly one function.
Producing the list is half the pattern; applying it is the other half, and it is just as centralised. A RouteCoordinator owns the navigation state and the navigators. Its routing method takes the intent, carried on a baton, asks the flow for its steps, and walks them in order.
For each step it does one of two things. A structural NavTarget is handed to the library’s dispatcher, which turns .present into a sheet presentation, .push into a stack push, .popToRoot into a return to the root, and .dismissSheet into a dismissal. A side effect is handed to the consumer’s own apply method. The consumer’s apply never sees the structural cases at all; its switch covers only its own effects, which is what keeps the four navigation moves out of consumer code entirely.
So there are two single places, not one. The step function decides which structural moves realise an intent, and the coordinator’s dispatch turns each move into a real navigator call. Neither is duplicated across entry points. A control that wants to reach a destination produces the intent and stops; it does not present or push anything itself. That is what lets a tapped settings control and an inbound settings link land in exactly the same place: they produce the same intent, and everything after the intent is shared.
Navigation can be driven faster than the interface settles, in bursts of URLs or a test firing rapidly, so the coordinator runs each flow inside a latest-wins executor. When a newer baton arrives it cancels the flow in progress, and the walk checks for cancellation between steps, so two racing intents never interleave their steps. The winner’s list applies cleanly and the loser leaves nothing half-applied. Each baton also carries a flow identifier so a single navigation event can be traced end to end, but cancellation itself is the executor’s job, not the identifier’s.
Determinism from anywhere is not a hope; it rests on three properties that reinforce each other. The step function is a pure function of the intent, so the list never varies by caller. NavTarget is a closed set, so there is no open-ended way to invent an arrival that some entry point handles and others miss. And latest-wins serialisation means concurrency cannot produce an interleaving that no single intent would have asked for. Same intent in, same steps out, applied the same way, whatever fired it and however fast.
The decision has to be expressible as data. A step is a value, not a closure that runs some navigation at the call site, and the structural arrivals are limited to the four NavTarget cases plus whatever side effects the flow defines. A navigation that genuinely cannot be described as an ordered list of those steps does not fit the shape; it would need a new side-effect case or a change to the vocabulary. In practice the four structural moves plus consumer effects cover an app’s navigation, which is the whole reason for keeping the set small.
Indirection has a price when you are reading the code. To see what an intent does you read the flow’s step function, not the control that fired it. That is one hop away from the tap, and it is deliberate: the call site is precisely where you did not want the decision to live.
The bargain is worth it because the alternative is the divergence the pattern exists to remove. A sheet-versus-push rule copied at every entry point is a rule that will, in time, be edited in one copy and not the others. Naming the move once, as a value, in one function, and dispatching it from one coordinator, is what makes a tap and a link two names for the same request.
This is the library underneath the consumer-side view. Where the state-aware resolver study shows how a demo app folds current state into one intent, and the parity study shows a tap and a URL reaching one entry point, this piece is the mechanism they both stand on. The full map is on the deep-linking hub.