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
127 changes: 127 additions & 0 deletions decisions/2026-08-28-multi-stack-nav-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Multi-stack navigation state: where the active screen lives

- Date: 2026-08-28
- Status: accepted
- Implements: MOB-109, first step of MOB-108
- Builds on: `2026-08-27-screen-process-architecture.md`

## Context

`Mob.Screen` carried a single `nav_history` list. `Mob.App.tab_bar/1` and
`drawer/1` have been public API in `Mob.App`'s moduledoc for far longer, and
`Mob.Socket.switch_tab/2` has been callable the whole time — but
`apply_nav_action/3` handled `{:switch_tab, _}` by clearing the action with the
comment "Tab switching is handled renderer-side". Nothing handled it anywhere.
One history cannot hold two stacks, so the runtime could not back the API it
shipped.

MOB-108 moves to per-screen processes eventually. Multi-stack state is required
at every level of that design and is independently valuable, so it lands first.

## Decision

### The active stack's current screen stays out of the struct

`%Mob.Nav{}` holds the active stack's `history` plus the fully parked state of
every *inactive* stack. The active screen itself remains where it always was, in
`Mob.Screen`'s `{module, socket}` slots.

The alternative — moving the current screen into `stacks[active].current` — is
tidier on paper and was rejected. Every `handle_call`/`handle_info` clause in
`Mob.Screen` destructures `{module, socket, _, _}`; routing the hot path through
a map lookup and write would touch all of them, for a state shape that MOB-112
replaces with real processes anyway. Only `switch/3` moves state in or out of
`parked`, so an ordinary message to the active screen reads and writes exactly
the two variables it did before. The change is confined to the navigation code.

### Stacks materialize on first visit

A declared stack has no socket and has never mounted until it is first switched
to. This matches `UITabBarController`, which does not instantiate a tab's view
controller until selected. After the first visit its state is retained for the
app's lifetime.

The cost is that "preserves state" is only true from the second visit onward,
which is what the platforms do and what users expect.

### A tab switch renders with `:none`, not `:push`

`Mob.Renderer.render/4` passes the transition to `nif.set_transition/1`, and
native understands `:push`, `:pop`, `:reset`, `:none`. A switch is a swap, not a
move along a stack — rendering it as `:push` would slide the incoming tab in
from the right on iOS. Using `:none` also keeps to atoms native already accepts,
so this lands with no `.m`, `.zig`, or template change, as the epic requires.

### An unmatched root gets a private orphan stack

`Mob.Nav.from_layout/2` makes active the stack whose `:root` is the mounted
module. When no stack declares it — `start_root/1` on a splash, login, or
deep-link target — the screen goes under a reserved `:__mob_root__` stack that
is absent from both `order` and `roots`.

Two alternatives were rejected. Leaving `active` as `nil` means there is nowhere
to park the running screen, so the first `switch_tab` discards its socket and
history outright. Falling back to the *first declared stack* preserves the state
but is worse in a way that is easy to miss: switching to the stack you are
already on is a `:noop`, so the squatting screen makes that stack's real root
permanently unreachable from the tab bar. An app declaring
`tab_bar([stack(:home, root: HomeScreen), ...])` but booting on `SplashScreen`
would never be able to reach `HomeScreen` again.

The orphan stack preserves the screen's state *and* leaves every declared root
reachable. It is not itself a switch target, which is correct: no tab
corresponds to it.

### Back at a secondary stack's root returns to the first stack

`Mob.Nav.back_target/1` returns `{:switch, first}` when the active stack is a
declared stack other than the first, and `:exit` otherwise.

Once `history/1` means *the active stack's* history, the old back handler —
"empty history, therefore exit the app" — would kill the app from the root of
any tab, discarding every parked stack. That is both the Android convention
violated (back returns to the first tab, and only then exits) and a direct
contradiction of the feature: the parked state exists precisely so it survives.

### An unrecognised `navigation/1` return is ignored, not a raise

`from_layout/2` has a catch-all returning the empty state. `navigation/1` is
app-supplied and unvalidated, and `Nav.Registry.register_nav/1` has always
tolerated an unrecognised shape with `defp register_nav(_), do: :ok`. Since
`from_layout/2` runs inside `Mob.Screen.init/1`, raising would turn a
declaration the framework previously ignored (`def navigation(_), do: []` is the
obvious spelling) into a failure to boot, with no supervision to absorb it until
MOB-112 lands.

### Unknown stacks are a no-op, not a raise

`Mob.Socket.switch_tab/2` takes any atom and offers no compile-time check.
A typo leaves navigation untouched rather than crashing the screen — which,
until MOB-112 lands per-screen supervision, would take the whole app with it.

## Consequences

- `Nav.Registry` now records two things: the flat route table that backs
`push_screen/2,3`, and a per-platform layout preserving the declaration tree.
The route table alone could not distinguish sibling stacks from unrelated
routes. A second ETS table holds the layouts; `layout/1` returns `nil` when
the registry was never started, which is the case in tests that drive a
screen directly.
- Parked sockets keep the `:safe_area` they had when parked. A device rotated
while a tab was inactive restores a stale inset — the same behaviour `pop`
already has, so this is consistent rather than new. Worth fixing for both
paths at once, not for this one alone.
- Pop, `pop_to_root`, and `pop_to` operate on the active stack only. Nothing
can pop across a stack boundary, which is what makes the histories genuinely
independent.
- **Known gaps, filed against the epic rather than fixed here.** `reset_to/2`
still clears the active stack's history without re-deriving which stack the
destination belongs to, so resetting to another stack's root leaves two live
instances of that screen in two stacks. Parked screens receive neither
`terminate/2` nor `Mob.ScreenState` sync, so a `persist: true` screen on an
inactive tab loses its assigns on exit. Re-selecting the active tab is a
no-op rather than popping that stack to its root, which is what both
platforms do. All three want the per-screen processes of MOB-112 to fix
cleanly.
- `Mob.Test.inspect/1`'s `:nav_history` key and `Mob.Screen.get_nav_history/1`
both keep their shape, now reporting the *active* stack's history.
216 changes: 216 additions & 0 deletions lib/mob/nav.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
defmodule Mob.Nav do
@moduledoc """
Multi-stack navigation state.

Replaces the single `nav_history` list that `Mob.Screen` used to carry. One
`Mob.App.stack/2` declaration becomes one independent stack here: each keeps
its own history *and* its own current screen, so switching away from a stack
and back restores exactly where you were rather than re-mounting the root.

That is what makes `Mob.App.tab_bar/1` and `drawer/1` representable. Both have
been public API in `Mob.App`'s moduledoc for a long time while the runtime
behind them could only hold one history — see
`decisions/2026-08-27-screen-process-architecture.md`.

## 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`.

* `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
* `parked` — `%{name => %{current: entry, history: [entry]}}` for inactive
stacks. Never contains `active`.
* `order` — declared stack order, for tab-index mapping
* `roots` — `%{name => root_module}`, used to mount a stack on first visit

## Lazy stacks

A stack materializes on first visit. Until you switch to it, it has no socket
and has never mounted — matching UIKit's `UITabBarController`, which does not
instantiate a tab's view controller until it is first selected. After the
first visit its state is retained for the lifetime of the app.
"""

alias Mob.Socket

@type entry :: {module(), Socket.t()}
@type stack_name :: atom()
@type parked_stack :: %{current: entry(), history: [entry()]}

@type t :: %__MODULE__{
active: stack_name() | nil,
history: [entry()],
parked: %{stack_name() => parked_stack()},
order: [stack_name()],
roots: %{stack_name() => module()}
}

defstruct active: nil, history: [], parked: %{}, order: [], roots: %{}

# Holds a screen that is not the root of any declared stack. It is deliberately
# absent from `order` and `roots`, so it is never a switch target — see
# `from_layout/2`.
@orphan_stack :__mob_root__

@doc """
An empty single-stack navigation state.

Equivalent to the old `nav_history = []`. Used when no navigation layout has
been declared, or in tests that start a screen directly.
"""
@spec new() :: t()
def new, do: %__MODULE__{}

@doc """
Build navigation state from a declared layout, with `current_module` as the
screen that is already mounted.

`layout` is the map returned by `Mob.App.stack/2`, `tab_bar/1`, or `drawer/1`
(or `nil`/unrecognised when the app declares none).

The active stack is the one whose `:root` is `current_module`. When no stack
declares that module — `start_root/1` on a splash, login, or deep-link target
— the screen is filed under a private orphan stack rather than under the first
declared one. Its state is still parked and preserved across a switch, but it
does not occupy a declared stack's slot: squatting `:home` would leave the
real `HomeScreen` unreachable from the tab bar for the process lifetime, since
switching to the stack you are already on is a no-op. The orphan is not a
switch target, because no tab corresponds to it.
"""
@spec from_layout(map() | nil, module()) :: t()
def from_layout(nil, _current_module), do: new()

def from_layout(layout, current_module) when is_map(layout) do
case declared_stacks(layout) do
[] ->
new()

declared ->
roots = Map.new(declared)
order = Enum.map(declared, fn {name, _root} -> name end)

active =
Enum.find_value(declared, @orphan_stack, fn {name, root} ->
if root == current_module, do: name
end)

%__MODULE__{active: active, history: [], parked: %{}, order: order, roots: roots}
end
end

# `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
# framework previously ignored into a failure to boot.
def from_layout(_layout, _current_module), do: new()

@doc "The active stack's history — head is the most recent entry."
@spec history(t()) :: [entry()]
def history(%__MODULE__{history: history}), do: history

@doc "Replace the active stack's history."
@spec put_history(t(), [entry()]) :: t()
def put_history(%__MODULE__{} = nav, history) when is_list(history) do
%{nav | history: history}
end

@doc """
Name of the active stack.

`nil` when no layout was declared. `:__mob_root__` when the mounted screen is
not the root of any declared stack — see `from_layout/2`.
"""
@spec active(t()) :: stack_name() | nil
def active(%__MODULE__{active: active}), do: active

@doc "Declared stack names, in declaration order."
@spec stacks(t()) :: [stack_name()]
def stacks(%__MODULE__{order: order}), do: order

@doc """
Switch the active stack to `name`, parking `current_entry` under the stack it
belongs to.

Returns one of:

* `{:switched, nav, entry}` — the target has been visited before; `entry` is
the `{module, socket}` to make current again, with no re-mount
* `{:mount_root, nav, root_module}` — first visit; the caller mounts
`root_module` and makes it current
* `:noop` — `name` is already active, or is not a declared stack

`:noop` on an unknown stack is deliberate: `Mob.Socket.switch_tab/2` takes any
atom, and a typo should leave navigation untouched rather than crash the
screen or strand it on a stack that does not exist.
"""
@spec switch(t(), stack_name(), entry()) ::
{:switched, t(), entry()} | {:mount_root, t(), module()} | :noop
def switch(%__MODULE__{active: active}, name, _current_entry) when active == name, do: :noop

def switch(%__MODULE__{} = nav, name, current_entry) when is_atom(name) do
case Map.fetch(nav.roots, name) do
:error ->
:noop

{:ok, root} ->
parked = park_current(nav, current_entry)

case Map.fetch(parked, name) do
{:ok, %{current: entry, history: history}} ->
nav = %{nav | active: name, history: history, parked: Map.delete(parked, name)}
{:switched, nav, entry}

:error ->
{:mount_root, %{nav | active: name, history: [], parked: parked}, root}
end
end
end

@doc """
What the platform back gesture should do when the active stack has nothing
left to pop.

Returns `{:switch, name}` when the active stack is a declared stack other than
the first, and `:exit` otherwise. This is the Android convention: back at the
root of a secondary tab returns to the first tab, and only back at the root of
the *first* tab leaves the app.

Without this, back at the root of any tab would exit — discarding every parked
stack, which is exactly the state this module exists to keep.
"""
@spec back_target(t()) :: {:switch, stack_name()} | :exit
def back_target(%__MODULE__{order: [first | _] = order, active: active})
when active != first do
if active in order, do: {:switch, first}, else: :exit
end

def back_target(%__MODULE__{}), do: :exit

# An app with no declared layout has nowhere to park its screen. That state
# belongs to no stack, so it is left where it is rather than filed under a
# name that was never declared.
defp park_current(%__MODULE__{active: nil, parked: parked}, _current_entry), do: parked

defp park_current(%__MODULE__{active: active, history: history, parked: parked}, current_entry) do
Map.put(parked, active, %{current: current_entry, history: history})
end

# Flatten a layout declaration into [{stack_name, root_module}] preserving
# declaration order. Unlike Nav.Registry's route table this keeps the stacks
# distinct — that table records only that a name exists.
defp declared_stacks(%{type: :stack, name: name, root: root}), do: [{name, root}]

defp declared_stacks(%{type: type, branches: branches})
when type in [:tab_bar, :drawer] and is_list(branches) do
Enum.flat_map(branches, &declared_stacks/1)
end

defp declared_stacks(_), do: []
end
Loading
Loading