Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions decisions/2026-08-29-router-off-the-hot-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# The router: navigation extracted, and kept out of the per-message path

- Date: 2026-08-29
- Status: accepted
- Implements: MOB-113, fifth step of MOB-108
- Builds on: `2026-08-28-screen-processes-and-supervision.md`

## Context

MOB-112 gave `Mob.Screen` a third job. It was already the behaviour screens
implement and the macro that generates their boilerplate; it became the process
owning navigation and every screen process as well. Nearly 1000 lines, and a
moduledoc that had to describe all three — which is part of why that moduledoc
had been wrong twice.

The epic's remaining constraint on that process is sharper than "tidy it up":
the router must **not** be in the per-message path.

## Decision

`Mob.Router` holds navigation, the screen processes, and the `:mob_screen`
registered name. `Mob.Screen` keeps the behaviour and the macro, and delegates
its public API, so `Mob.Screen.dispatch/3` and friends still work.

This is a move, not a redesign — the process model landed in MOB-112 and is
unchanged here.

### The hot-path property already held; this pins it

Tracing the router's mailbox while a screen handles ordinary messages shows it
receives nothing. That is not new — MOB-111's listener already delivers native
events straight to the owning screen's pid, so a tap goes native → listener →
screen → sender with the router uninvolved.

What is new is that it is now **asserted rather than reasoned about**.
`test/mob/router_hot_path_test.exs` traces `:receive` on the router and asserts
the trace is empty.

It covers both halves of the path, which took a change to make possible. The
callback half (`handle_info` into user code) runs under `:no_render`. The render
half — tree expansion, `Mob.ComponentRegistry.reconcile/2`, the hand-off to
`Mob.Sender` — is skipped entirely by `:no_render`, and `:render` needs a NIF.
The first version of this test therefore proved nothing about the half where a
hop is *most* likely to appear: an "am I still active?" check in the render body
is the obvious shape of one. A router hop added to `paint/3` passed the whole
suite.

`Mob.Screen.Server` now takes its NIF module as an option, defaulting to
`:mob_nif`. That is not test-only scaffolding — `Mob.Renderer` and `Mob.Sender`
already take it as a parameter, and the screen was the outlier that hardcoded
it. With a stub NIF the test drives real renders off-device, and negative
controls confirm both halves bite: a hop in `forward/2` fails the callback
tests, a hop in `paint/3` fails the render tests.

This matters more than a tidy-up. An earlier costing of this architecture
assumed a router in the loop and concluded per-screen processes could not escape
a hop per message. Splitting the router from the sender is what dissolved that,
and a property that load-bearing should fail loudly when someone breaks it —
not be rediscovered by reading the code.

### What still goes through the router, deliberately

* navigation actions a screen produces (`{:nav_action, …}`)
* the back gesture, alert actions, and launch notifications, which native
addresses to `:mob_screen`
* device events and plugin messages sent to `:mob_screen`, forwarded to the
active screen
* `Mob.Screen.dispatch/3`, the programmatic entry point used by tests and
tooling

None is a per-message path for a running screen. The last two are the ones to
watch: they make the router a shared serialisation point, which is MOB-121.

## Consequences

- `Mob.Screen` drops from ~995 lines to ~230, and its moduledoc describes one
thing.
- `decode_file_result/3` stopped being a `@doc false` public function on the
owner and became private to `Mob.Screen.Server`, its only caller.
- Two guides were making claims that were aspirational before MOB-112 and are
now nearly true; they now say what actually happens. `screen_lifecycle.md`
claimed each screen was "a separate, supervised process" — separate is now
right, supervised is still not: the router restarts screens itself because
only it knows where a crashed one sat.
- The `:mob_screen` name still belongs to the router, so no native change and no
generator-template change. The epic said the router would take that name over;
it already had it under a different module name.
6 changes: 3 additions & 3 deletions guides/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Mob takes an unusual position in the mobile framework landscape. To understand w
```mermaid
flowchart TD
A["Your Elixir App<br/>(GenServers, Phoenix, Ecto, whatever you normally use)"]
B["Mob.Screen<br/>(your UI module) — GenServer"]
B["Mob.Screen.Server<br/>(your UI module) — one GenServer per live screen"]
C["Mob.Renderer<br/>serialise + token resolution"]
D1["Compose (JVM)<br/>Android"]
D2["SwiftUI (Swift)<br/>iOS"]
Expand All @@ -20,7 +20,7 @@ flowchart TD

BEAM and OTP run **on the device** — embedded inside the APK and the iOS app bundle. There is no server. Your screen logic, navigation state, and business logic all execute locally in the same BEAM node that the user has installed.

The rendering layer is thin: `render/1` returns a plain Elixir map (the component tree), `Mob.Renderer` serialises it to JSON and passes it to the native side via a NIF call. Compose or SwiftUI diff and display it. UI events travel back as NIF callbacks that send messages to the screen GenServer. The BEAM owns state; the native UI is a thin view.
The rendering layer is thin: `render/1` returns a plain Elixir map (the component tree), `Mob.Renderer` serialises it to JSON and passes it to the native side via a NIF call. Compose or SwiftUI diff and display it. UI events travel back as NIF callbacks; `Mob.Listener` unwraps them and sends them straight to the owning screen's process, without going through the router. The BEAM owns state; the native UI is a thin view.

## Erlang distribution for development

Expand Down Expand Up @@ -81,4 +81,4 @@ The right choice between them depends on your app's connectivity requirements an
- **Native UI.** Components render as Compose and SwiftUI primitives. Animations, accessibility, platform gestures, and dark mode all work because the native layer handles them.
- **No server required.** The app is self-contained. Online features are optional add-ons, not the foundation.
- **Development speed.** `mix mob.connect` + `nl/1` gives you sub-second code push to a running device. The OTP debug toolchain — tracing, observer, remote IEx — is available without any extra infrastructure.
- **OTP reliability.** A crashed screen is a crashed GenServer. OTP can restart it, log it, and keep the rest of the app running. You get fault tolerance on mobile for free.
- **OTP reliability.** A crashed screen is a crashed GenServer. `Mob.Router` restarts it, logs it, and keeps navigation and every other screen running. You get fault tolerance on mobile for free.
2 changes: 1 addition & 1 deletion guides/screen_lifecycle.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Screen Lifecycle

A Mob screen is a GenServer wrapped by `Mob.Screen`. Each screen in the navigation stack is a separate, supervised process. Understanding the lifecycle means understanding when each callback fires and what you can do in it.
A Mob screen is a GenServer — a `Mob.Screen.Server` process holding your module's socket. Each live screen in the navigation stack is a separate process, and `Mob.Router` owns them: it starts them, stops them, and restarts one that crashes. It is not an OTP `Supervisor`, because only the router knows where in the navigation a crashed screen sat; a restarted screen re-mounts and loses its assigns. Understanding the lifecycle means understanding when each callback fires and what you can do in it.

## Callbacks

Expand Down
19 changes: 8 additions & 11 deletions lib/mob/nav.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,13 @@ defmodule Mob.Nav do
## Shape

The *active* stack's current screen is deliberately **not** stored here. It
lives where it always did, in `Mob.Screen`'s `{module, socket}`, and this
struct holds only the active stack's `history` plus the fully parked state of
every inactive stack. Keeping the hot path untouched is the point: an ordinary
message to the active screen reads and writes the same two variables it did
before, and only `switch/3` moves state in or out of `parked`.
lives in `Mob.Router`'s `current`, and this struct holds only the active
stack's `history` plus the fully parked state of every inactive stack. Only
`switch/3` moves state in or out of `parked`.

* `active` — name of the stack the current screen belongs to (`nil` when the
app declares no stacks at all, i.e. a bare `start_root/1` with no layout)
* `history` — the active stack's history, head = most recent, exactly the list
`Mob.Screen` used to hold
* `history` — the active stack's history, head = most recent
* `parked` — `%{name => %{current: entry, history: [entry]}}` for inactive
stacks. Never contains `active`.
* `order` — declared stack order, for tab-index mapping
Expand All @@ -41,7 +38,7 @@ defmodule Mob.Nav do
@typedoc """
Whatever the caller uses to identify a screen. Opaque here.

`Mob.Screen` puts `%{module:, pid:, params:, ref:}` in these slots since
`Mob.Router` puts `%{module:, pid:, params:, ref:}` in these slots since
MOB-112 — this module never looks inside one.
"""
@type entry :: term()
Expand Down Expand Up @@ -111,7 +108,7 @@ defmodule Mob.Nav do

# `navigation/1` is app-supplied and unvalidated. `Nav.Registry` has always
# tolerated a shape it doesn't recognise (`register_nav(_), do: :ok`), and this
# runs inside `Mob.Screen.init/1` — raising here would turn a declaration the
# runs inside `Mob.Router.init/1` — raising here would turn a declaration the
# framework previously ignored into a failure to boot.
def from_layout(_layout, _current_module), do: new()

Expand Down Expand Up @@ -143,7 +140,7 @@ defmodule Mob.Nav do
every entry in its history.

Entries are opaque to this module, so the caller decides what an entry is and
what replacing one means. `Mob.Screen` uses it to substitute a restarted
what replacing one means. `Mob.Router` uses it to substitute a restarted
screen process wherever it was referenced.

The *active* stack's history is not covered: it lives in `history`, which the
Expand Down Expand Up @@ -173,7 +170,7 @@ defmodule Mob.Nav do
all is removed from `parked` entirely, so the next switch to it mounts its
root fresh rather than restoring a screen that is gone.

`Mob.Screen` uses this when a screen crashes and cannot be re-mounted —
`Mob.Router` uses this when a screen crashes and cannot be re-mounted —
leaving the dead entry in place would freeze that tab permanently, since
switching to it would restore a corpse.
"""
Expand Down
Loading
Loading