Mob.Nav (mob v0.7.39)

Copy Markdown View Source

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 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
  • 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.

Summary

Types

Whatever the caller uses to identify a screen. Opaque here.

t()

Functions

Name of the active stack.

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

Remove every parked entry for which fun returns true.

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

The active stack's history — head is the most recent entry.

Apply fun to every parked entry — each inactive stack's current screen and every entry in its history.

An empty single-stack navigation state.

Names of the stacks currently parked, in declaration order.

Replace the active stack's history.

Clear every materialized stack and select the stack for current_module.

Declared stack names, in declaration order.

Switch the active stack to name, parking current_entry under the stack it belongs to.

Types

entry()

@type entry() :: term()

Whatever the caller uses to identify a screen. Opaque here.

Mob.Router puts %{module:, pid:, params:, ref:} in these slots since MOB-112 — this module never looks inside one.

parked_stack()

@type parked_stack() :: %{current: entry(), history: [entry()]}

stack_name()

@type stack_name() :: atom()

t()

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

Functions

active(nav)

@spec active(t()) :: stack_name() | nil

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.

back_target(nav)

@spec back_target(t()) :: {:switch, stack_name()} | :exit

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.

drop_parked(nav, fun)

@spec drop_parked(t(), (entry() -> boolean())) :: t()

Remove every parked entry for which fun returns true.

Dropping is not mapping: a stack whose current entry goes away has to collapse. The head of its history is promoted; a stack left with nothing at 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.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.

from_layout(layout, current_module)

@spec from_layout(map() | nil, module()) :: t()

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.

history(nav)

@spec history(t()) :: [entry()]

The active stack's history — head is the most recent entry.

map_parked(nav, fun)

@spec map_parked(t(), (entry() -> entry())) :: t()

Apply fun to every parked entry — each inactive stack's current screen and 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.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 caller already holds and can rewrite with put_history/2.

new()

@spec new() :: t()

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.

parked_stacks(nav)

@spec parked_stacks(t()) :: [stack_name()]

Names of the stacks currently parked, in declaration order.

put_history(nav, history)

@spec put_history(t(), [entry()]) :: t()

Replace the active stack's history.

reset(nav, current_module)

@spec reset(t(), module()) :: t()

Clear every materialized stack and select the stack for current_module.

The declared roots and their order are preserved. When current_module is one of those roots, its declared stack becomes active; otherwise the fresh screen belongs to the private orphan stack used for login and deep-link screens. No previous history or parked screen survives the reset.

stacks(nav)

@spec stacks(t()) :: [stack_name()]

Declared stack names, in declaration order.

switch(nav, name, current_entry)

@spec switch(t(), stack_name(), entry()) ::
  {:switched, t(), entry()} | {:mount_root, t(), module()} | :noop

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
  • :noopname 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.