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
61 changes: 51 additions & 10 deletions guides/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,23 +136,38 @@ Mob.Socket.pop_to_root(socket)

### `reset_to/2,3,4`

Replace the entire navigation stack with a new root. No back button, no history. Used for auth transitions:
Replace the current navigation stack with a new root. No back button, no history:

```elixir
# After login — go to home with no way to navigate back to the login screen
# Replace only the active stack
def handle_info({:tap, :logged_in}, socket) do
{:noreply, Mob.Socket.reset_to(socket, MyApp.HomeScreen)}
end
```

In an app with tabs, authentication transitions normally need to discard every
stack so no screen or state from the prior session remains parked:

```elixir
Mob.Socket.reset_to(socket, MyApp.LoginScreen, %{}, scope: :all)
```

The default scope is `:stack`, which preserves the other tab stacks. `:all`
stops every screen in every stack and starts fresh. If the destination is a
declared root, its stack becomes active; login and other undeclared roots use
the private orphan stack. If the destination cannot mount, the existing stacks
remain intact. An all-stack reset is also a persistence boundary: it clears all
stored screen snapshots, suppresses final writes from the discarded screens,
and does not restore a snapshot into the replacement screen.

The reset always replaces the stack. Its animation can be overridden when the
same stack operation represents directional movement, such as custom tabs:

```elixir
Mob.Socket.reset_to(socket, MyApp.PortfolioScreen, %{}, transition: :push)
```

### `switch_tab/2`
### `switch_tab/2,3`

Switch to a named stack in a tab bar or drawer layout:

Expand All @@ -162,8 +177,29 @@ Mob.Socket.switch_tab(socket, :settings)

The first switch to a stack mounts its declared `:root`; later switches restore
the stack exactly as you left it. Switching to the stack you are already on, or
to a name no stack declares, is a no-op. A tab switch is a swap, not a move
along a stack, so it renders with no push/pop animation.
to a name no stack declares, is a no-op. By default a tab switch is an
unanimated swap. Apps with ordered tabs can supply a directional transition:

```elixir
Mob.Socket.switch_tab(socket, :portfolio, transition: :push)
Mob.Socket.switch_tab(socket, :home, transition: :pop)
```

The transition changes only the animation; each tab still retains its own
screen and history stack.

When a tab root needs session or launch data, pass it on the first switch:

```elixir
Mob.Socket.switch_tab(socket, :home,
transition: :push,
mount_params: %{session: session}
)
```

`mount_params` must be a map. They are used only when the target stack has not
yet mounted. Switching back to an existing stack restores its original screen
and state; later `mount_params` do not replace the params it mounted with.

## Tabs and multi-stack state

Expand Down Expand Up @@ -191,8 +227,7 @@ history and its own live screens (`Mob.Nav` holds this state). The rules:
state is preserved, every declared root stays reachable, and the orphan is
never itself a switch target.

Known gaps, tracked on the epic: `reset_to/2` does not re-derive which stack
its destination belongs to (MOB-115); parked screens miss `terminate/2` and
Known gaps, tracked on the epic: parked screens miss `terminate/2` and
persisted-state sync (MOB-116); re-selecting the active tab is a no-op rather
than popping that stack to its root (MOB-117).

Expand All @@ -202,16 +237,22 @@ The framework automatically picks the right animation based on the navigation ac
- **Push** — slide in from right (iOS) / slide up (Android)
- **Pop** — reverse slide
- **Reset** — cross-fade (no directional animation, no back history)
- **Tab switch** — none (a swap, not a move along a stack)
- **Tab switch** — none by default; `switch_tab/3` can request push, pop, or reset

`reset_to/4` can override only the animation with `transition: :push` or
`transition: :pop`; it still discards navigation history. Any other transition
`reset_to/4` can override the animation with `transition: :push` or
`transition: :pop`, and the reset boundary with `scope: :stack` or `scope: :all`.
Any other transition
value raises `ArgumentError` — including `:none`, which native would treat as
"not navigation" and diff the incoming tree into the outgoing screen's view
identities. A navigation's animation survives coalescing: an ordinary re-render
(a timer tick, a component update) queued behind a push cannot swallow the
push's animation.

`switch_tab/3` accepts the same validated transition values (`:push`, `:pop`,
or `:reset`) and an optional `mount_params` map. Unlike `reset_to/4`, its
legacy `switch_tab/2` form deliberately uses `:none`, preserving the
unanimated tab-swap behavior.

## Passing data on pop

Mob's navigation is process-based. When you pop back to a previous screen, that screen's process is still running with its original state. To pass data back, send a message to the parent's pid.
Expand Down
3 changes: 2 additions & 1 deletion guides/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ Mob.Test.navigate(node, MyApp.DetailScreen, %{id: 42})
Mob.Test.navigate(node, :detail, %{id: 42}) # by registered name
Mob.Test.pop_to(node, MyApp.HomeScreen) # pop back to a specific screen
Mob.Test.pop_to_root(node) # pop all the way back
Mob.Test.reset_to(node, MyApp.HomeScreen) # replace the entire stack
Mob.Test.reset_to(node, MyApp.HomeScreen) # replace the active stack
Mob.Test.reset_to(node, MyApp.LoginScreen, %{}, scope: :all) # discard every stack

# System back gesture (fire-and-forget — same as hardware back / edge-pan)
Mob.Test.back(node)
Expand Down
46 changes: 27 additions & 19 deletions lib/mob/component_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,24 @@ defmodule Mob.ComponentRegistry do
end

@doc "Remove a component registration (called from ComponentServer.terminate)."
@spec deregister(pid(), atom(), module()) :: :ok
def deregister(screen_pid, id, module) do
key = {screen_pid, id, module}

case :ets.lookup(@table, key) do
[{_, pid}] ->
:ets.delete(@table, key)
:ets.delete(@table, pid)

[] ->
:ok
@spec deregister(pid(), atom(), module(), pid()) :: :ok
def deregister(screen_pid, id, module, component_pid) do
if :ets.whereis(@table) != :undefined do
key = {screen_pid, id, module}

case :ets.lookup(@table, key) do
[{_, ^component_pid}] ->
:ets.delete(@table, key)
:ets.delete(@table, component_pid)

_ ->
:ok
end
end

:ok
rescue
ArgumentError -> :ok
end

@doc """
Expand All @@ -67,18 +71,22 @@ defmodule Mob.ComponentRegistry do
"""
@spec reconcile(pid(), MapSet.t()) :: :ok
def reconcile(screen_pid, active_keys) do
pattern = {{screen_pid, :_, :_}, :_}
entries = :ets.match_object(@table, pattern)

for {{^screen_pid, id, module}, pid} <- entries do
unless MapSet.member?(active_keys, {id, module}) do
:ets.delete(@table, {screen_pid, id, module})
:ets.delete(@table, pid)
Process.exit(pid, :shutdown)
if :ets.whereis(@table) != :undefined do
pattern = {{screen_pid, :_, :_}, :_}
entries = :ets.match_object(@table, pattern)

for {{^screen_pid, id, module}, pid} <- entries do
unless MapSet.member?(active_keys, {id, module}) do
:ets.delete(@table, {screen_pid, id, module})
:ets.delete(@table, pid)
Process.exit(pid, :shutdown)
end
end
end

:ok
rescue
ArgumentError -> :ok
end

# ── GenServer ──────────────────────────────────────────────────────────────
Expand Down
29 changes: 15 additions & 14 deletions lib/mob/component_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,14 @@ defmodule Mob.ComponentServer do

@impl GenServer
def init(opts) do
# MOB-100: Mob.ComponentRegistry.reconcile/2 stops a component that has
# left the tree via Process.exit(pid, :shutdown). A GenServer that isn't
# trapping exits terminates immediately on that signal WITHOUT running
# terminate/2 — the native handle (and, before this fix, the registry
# entry) leaked on every single screen navigation, not just for slot 0.
# Trapping exits turns that signal into a regular {:EXIT, _, reason}
# message (handled below) that goes through the normal {:stop, ...}
# path instead, so terminate/2 — and its deregister_component call —
# actually runs.
# A component is isolated from its screen, but still needs graceful
# termination so its native handle and user state are released.
Process.flag(:trap_exit, true)

module = opts[:module]
id = opts[:id]
screen_pid = opts[:screen_pid]
screen_monitor = Process.monitor(screen_pid)
props = opts[:props]
platform = opts[:platform]
nif = opts[:nif] || @default_nif
Expand All @@ -74,7 +68,8 @@ defmodule Mob.ComponentServer do
screen_pid: screen_pid,
id: id,
handle: handle,
nif: nif
nif: nif,
screen_monitor: screen_monitor
}}

{:error, reason} ->
Expand Down Expand Up @@ -165,9 +160,15 @@ defmodule Mob.ComponentServer do
{:noreply, %{state | socket: new_socket}}
end

# Trapping exits (see init/1) turns Mob.ComponentRegistry.reconcile/2's
# Process.exit(pid, :shutdown) into this message instead of an untrappable
# kill — route it through the normal stop path so terminate/2 runs.
def handle_info(
{:DOWN, monitor, :process, screen_pid, reason},
%{screen_monitor: monitor, screen_pid: screen_pid} = state
) do
{:stop, reason, state}
end

# Preserve graceful termination for callers that deliberately link a
# component process despite ComponentServer.start/1 itself being unlinked.
def handle_info({:EXIT, _from, reason}, state) do
{:stop, reason, state}
end
Expand Down Expand Up @@ -240,7 +241,7 @@ defmodule Mob.ComponentServer do
handle: handle,
nif: nif
}) do
Mob.ComponentRegistry.deregister(screen_pid, id, module)
Mob.ComponentRegistry.deregister(screen_pid, id, module, self())
if handle >= 0, do: nif.deregister_component(handle)
module.terminate(reason, socket)
end
Expand Down
18 changes: 18 additions & 0 deletions lib/mob/nav.ex
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,24 @@ defmodule Mob.Nav do
%{nav | history: history}
end

@doc """
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.
"""
@spec reset(t(), module()) :: t()
def reset(%__MODULE__{} = nav, current_module) do
active =
Enum.find_value(nav.order, @orphan_stack, fn name ->
if Map.get(nav.roots, name) == current_module, do: name
end)

%{nav | active: active, history: [], parked: %{}}
end

@doc """
Name of the active stack.

Expand Down
Loading
Loading