From 8bfb5c46e85a390ee18e53e1e6e86ac53dab1a1c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 28 Aug 2026 13:29:03 -0600 Subject: [PATCH 1/2] =?UTF-8?q?MOB-109:=20multi-stack=20navigation=20state?= =?UTF-8?q?=20=E2=80=94=20make=20tab=5Fbar/1=20representable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mob.App.tab_bar/1 and drawer/1 have been public API in Mob.App's own moduledoc, and Mob.Socket.switch_tab/2 callable, while the runtime behind them could hold exactly one nav_history. apply_nav_action/3 handled {:switch_tab, _} by clearing the action with the comment "Tab switching is handled renderer-side" — nothing handled it anywhere, so switching tabs did nothing at all. Adds Mob.Nav: one independent stack per declared branch, each owning its own history and its own live screen. Switching parks the current screen — socket and history both — under the stack it belongs to, so an inactive tab keeps its state and returning to it restores rather than re-mounts. The active screen deliberately stays in Mob.Screen's {module, socket} slots rather than moving into the struct: only switch/3 moves state in or out of `parked`, so an ordinary message to the active screen touches the same two variables it did before. MOB-112 replaces this state shape with real processes; routing the hot path through a map first would be churn. Nav.Registry now records the declaration tree per platform alongside the flat route table. The route table alone could not tell a sibling stack from an unrelated route, which is why nothing downstream could back tab_bar/1. No .m, .zig, or template change: a tab switch renders with the :none transition, an atom native already accepts. Rationale, including the first-declared-stack fallback and why unknown stacks are a no-op rather than a raise, in decisions/2026-08-28-multi-stack-nav-state.md. Tests: 31 new (19 unit on the pure state, 12 driving Mob.Screen end to end). Verified as a negative control — 7 of the 12 integration tests fail against the previous code, all 12 pass now. Suite 1150 passed, format and credo --strict clean. Co-Authored-By: Claude Opus 5 (1M context) --- decisions/2026-08-28-multi-stack-nav-state.md | 90 +++++++ lib/mob/nav.ex | 177 +++++++++++++ lib/mob/nav/registry.ex | 39 +++ lib/mob/screen.ex | 182 ++++++++------ test/mob/nav/multi_stack_test.exs | 232 ++++++++++++++++++ test/mob/nav_test.exs | 158 ++++++++++++ 6 files changed, 804 insertions(+), 74 deletions(-) create mode 100644 decisions/2026-08-28-multi-stack-nav-state.md create mode 100644 lib/mob/nav.ex create mode 100644 test/mob/nav/multi_stack_test.exs create mode 100644 test/mob/nav_test.exs diff --git a/decisions/2026-08-28-multi-stack-nav-state.md b/decisions/2026-08-28-multi-stack-nav-state.md new file mode 100644 index 00000000..f7b73d88 --- /dev/null +++ b/decisions/2026-08-28-multi-stack-nav-state.md @@ -0,0 +1,90 @@ +# 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 falls back to the first declared stack + +`Mob.Nav.from_layout/2` makes active the stack whose `:root` is the mounted +module. When no stack declares it — an app that calls `start_root/1` with a +module that is not any stack's root — the first declared stack is used rather +than leaving `active` as `nil`. + +`nil` was the more honest answer and is worse in practice: with no active stack +there is nowhere to park the running screen, so the first `switch_tab` would +discard its socket and history outright. The fallback misattributes a label; the +alternative loses user state. The screen is preserved either way, and the +misattribution only occurs in a configuration that is already unusual. + +### 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. +- `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. diff --git a/lib/mob/nav.ex b/lib/mob/nav.ex new file mode 100644 index 00000000..afd43ec6 --- /dev/null +++ b/lib/mob/nav.ex @@ -0,0 +1,177 @@ +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: %{} + + @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` when the app declares none). The active stack is the one whose root + is `current_module`; when no stack declares that module as its root the first + declared stack is used, so the running screen still belongs somewhere and is + preserved across a switch. Its state is never discarded — only its label is a + guess, and only in that fallback case. + """ + @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 + declared = declared_stacks(layout) + + case declared do + [] -> + new() + + [{first_name, _} | _] -> + roots = Map.new(declared) + order = Enum.map(declared, fn {name, _root} -> name end) + + active = + Enum.find_value(declared, first_name, fn {name, root} -> + if root == current_module, do: name + end) + + %__MODULE__{active: active, history: [], parked: %{}, order: order, roots: roots} + end + end + + @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, or `nil` when no layout was declared." + @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 + + # 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 diff --git a/lib/mob/nav/registry.ex b/lib/mob/nav/registry.ex index 546565a9..e74773a7 100644 --- a/lib/mob/nav/registry.ex +++ b/lib/mob/nav/registry.ex @@ -8,11 +8,26 @@ defmodule Mob.Nav.Registry do `register/2` is available for runtime additions: A/B testing, library screens, or dynamic feature flags. + + ## Two things are recorded, not one + + Walking `navigation/1` produces a flat **route table** (`name -> {module, + params}`) that backs `push_screen/2,3` and friends, and a per-platform + **layout** that preserves the declaration tree — which stacks a `tab_bar/1` + or `drawer/1` contains, and in what order. + + The route table alone records only that a stack name exists. That was the + whole of what this module kept, which is why `Mob.App.tab_bar/1` could be + declared but not backed by the runtime: nothing downstream could tell that + `:home` and `:settings` were sibling stacks rather than two unrelated routes. + `Mob.Nav.from_layout/2` consumes the layout to build one independent stack per + branch. See `decisions/2026-08-27-screen-process-architecture.md`. """ use GenServer @table __MODULE__ + @layout_table Module.concat(__MODULE__, Layouts) @doc """ Start the registry, seeding it from the given App module. @@ -72,11 +87,34 @@ defmodule Mob.Nav.Registry do :ok end + @doc """ + Return the navigation layout declared for `platform`, or `nil`. + + The layout is the raw map returned by the app's `navigation/1` — a + `Mob.App.stack/2`, `tab_bar/1`, or `drawer/1` declaration. `nil` when the + registry has not been started (tests that drive a screen directly) or when + the app declared nothing for this platform. + """ + @spec layout(atom()) :: map() | nil + def layout(platform) when is_atom(platform) do + case :ets.whereis(@layout_table) do + :undefined -> + nil + + _tid -> + case :ets.lookup(@layout_table, platform) do + [{^platform, layout}] -> layout + [] -> nil + end + end + end + # ── GenServer ────────────────────────────────────────────────────────────── @impl GenServer def init(app_module) do :ets.new(@table, [:named_table, :public, read_concurrency: true]) + :ets.new(@layout_table, [:named_table, :public, read_concurrency: true]) populate(app_module) {:ok, app_module} end @@ -84,6 +122,7 @@ defmodule Mob.Nav.Registry do defp populate(app_module) do for platform <- [:android, :ios] do nav = app_module.navigation(platform) + :ets.insert(@layout_table, {platform, nav}) register_nav(nav) end diff --git a/lib/mob/screen.ex b/lib/mob/screen.ex index 5ebe7719..3f7b2ffa 100644 --- a/lib/mob/screen.ex +++ b/lib/mob/screen.ex @@ -262,7 +262,14 @@ defmodule Mob.Screen do end if screen_module.__mob_persist__(), do: schedule_state_sync() - {:ok, {screen_module, socket, [], render_mode}} + + # Seed the stacks this app declared. The screen we just mounted becomes + # the active stack's current screen; every other declared stack stays + # unmounted until first visited. With no declaration (or no registry, as + # in tests) this is an empty single-stack state — the old behaviour. + nav = Mob.Nav.from_layout(Mob.Nav.Registry.layout(platform), screen_module) + + {:ok, {screen_module, socket, nav, render_mode}} {:error, reason} -> {:stop, reason} @@ -270,11 +277,11 @@ defmodule Mob.Screen do end @impl GenServer - def handle_call({:event, event, params}, _from, {module, socket, nav_history, render_mode}) do + def handle_call({:event, event, params}, _from, {module, socket, nav, render_mode}) do case module.handle_event(event, params, socket) do {:noreply, new_socket} -> - {module, new_socket, nav_history, transition} = - apply_nav_action(module, new_socket, nav_history) + {module, new_socket, nav, transition} = + apply_nav_action(module, new_socket, nav) new_socket = if render_mode == :render do @@ -283,11 +290,11 @@ defmodule Mob.Screen do new_socket end - {:reply, :ok, {module, new_socket, nav_history, render_mode}} + {:reply, :ok, {module, new_socket, nav, render_mode}} {:reply, _response, new_socket} -> - {module, new_socket, nav_history, transition} = - apply_nav_action(module, new_socket, nav_history) + {module, new_socket, nav, transition} = + apply_nav_action(module, new_socket, nav) new_socket = if render_mode == :render do @@ -296,7 +303,7 @@ defmodule Mob.Screen do new_socket end - {:reply, :ok, {module, new_socket, nav_history, render_mode}} + {:reply, :ok, {module, new_socket, nav, render_mode}} end end @@ -312,11 +319,11 @@ defmodule Mob.Screen do - `{:pop_to_root}` — pop to the root of the current stack - `{:reset, dest, params}` — replace the entire nav stack """ - def handle_call({:navigate, nav_action}, _from, {module, socket, nav_history, render_mode}) do + def handle_call({:navigate, nav_action}, _from, {module, socket, nav, render_mode}) do socket = Mob.Socket.put_mob(socket, :nav_action, nav_action) - {new_module, new_socket, new_history, transition} = - apply_nav_action(module, socket, nav_history) + {new_module, new_socket, new_nav, transition} = + apply_nav_action(module, socket, nav) new_socket = if render_mode == :render do @@ -325,39 +332,39 @@ defmodule Mob.Screen do new_socket end - {:reply, :ok, {new_module, new_socket, new_history, render_mode}} + {:reply, :ok, {new_module, new_socket, new_nav, render_mode}} end - def handle_call(:get_socket, _from, {_module, socket, _nav_history, _mode} = state) do + def handle_call(:get_socket, _from, {_module, socket, _nav, _mode} = state) do {:reply, socket, state} end - def handle_call(:inspect, _from, {module, socket, nav_history, _mode} = state) do + def handle_call(:inspect, _from, {module, socket, nav, _mode} = state) do tree = module.render(socket.assigns) info = %{ screen: module, assigns: socket.assigns, - nav_history: Enum.map(nav_history, fn {mod, _} -> mod end), + nav_history: Enum.map(Mob.Nav.history(nav), fn {mod, _} -> mod end), tree: tree } {:reply, info, state} end - def handle_call(:get_current_module, _from, {module, _socket, _nav_history, _mode} = state) do + def handle_call(:get_current_module, _from, {module, _socket, _nav, _mode} = state) do {:reply, module, state} end - def handle_call(:get_nav_history, _from, {_module, _socket, nav_history, _mode} = state) do - {:reply, nav_history, state} + def handle_call(:get_nav_history, _from, {_module, _socket, nav, _mode} = state) do + {:reply, Mob.Nav.history(nav), state} end # Notification that launched the app from a killed state. # Decoded from JSON and re-dispatched as the standard {:notification, map} message. # Hot-reload trigger sent by mob_dev after a dist push. Re-render with current code. @impl GenServer - def handle_cast(:__mob_hot_reload__, {module, socket, nav_history, render_mode}) do + def handle_cast(:__mob_hot_reload__, {module, socket, nav, render_mode}) do new_socket = if render_mode == :render do do_render(module, socket) @@ -365,13 +372,13 @@ defmodule Mob.Screen do socket end - {:noreply, {module, new_socket, nav_history, render_mode}} + {:noreply, {module, new_socket, nav, render_mode}} end @impl GenServer - def handle_info({:mob_launch_notification, json}, {module, socket, nav_history, render_mode}) do + def handle_info({:mob_launch_notification, json}, {module, socket, nav, render_mode}) do notif = decode_notification_json(json) - handle_info({:notification, notif}, {module, socket, nav_history, render_mode}) + handle_info({:notification, notif}, {module, socket, nav, render_mode}) end # Android file/camera/photo/scan results arrive as {:mob_file_result, event, sub, json_binary}. @@ -441,17 +448,17 @@ defmodule Mob.Screen do # navigation for free without implementing anything. # If a WebView is present and has internal history, navigate within it first # before popping the Mob nav stack. - def handle_info({:mob, :back}, {module, socket, nav_history, render_mode}) do + def handle_info({:mob, :back}, {module, socket, nav, render_mode}) do if render_mode == :render && :mob_nif.webview_can_go_back() do :mob_nif.webview_go_back() - {:noreply, {module, socket, nav_history, render_mode}} + {:noreply, {module, socket, nav, render_mode}} else - {module, new_socket, new_history, transition} = - if nav_history == [] do + {module, new_socket, new_nav, transition} = + if Mob.Nav.history(nav) == [] do if render_mode == :render, do: :mob_nif.exit_app() - {module, socket, [], :none} + {module, socket, nav, :none} else - apply_nav_action(module, Mob.Socket.put_mob(socket, :nav_action, {:pop}), nav_history) + apply_nav_action(module, Mob.Socket.put_mob(socket, :nav_action, {:pop}), nav) end new_socket = @@ -461,18 +468,18 @@ defmodule Mob.Screen do new_socket end - {:noreply, {module, new_socket, new_history, render_mode}} + {:noreply, {module, new_socket, new_nav, render_mode}} end end # List row selected — intercept before the user's handle_info and convert to # a plain {:select, id, index} message so screens don't need to know about # the internal {:tap, {:list, ...}} tag format. - def handle_info({:tap, {:list, id, :select, index}}, {module, socket, nav_history, render_mode}) do + def handle_info({:tap, {:list, id, :select, index}}, {module, socket, nav, render_mode}) do {:noreply, new_socket} = module.handle_info({:select, id, index}, socket) - {module, new_socket, nav_history, transition} = - apply_nav_action(module, new_socket, nav_history) + {module, new_socket, nav, transition} = + apply_nav_action(module, new_socket, nav) new_socket = if render_mode == :render do @@ -481,11 +488,11 @@ defmodule Mob.Screen do new_socket end - {:noreply, {module, new_socket, nav_history, render_mode}} + {:noreply, {module, new_socket, nav, render_mode}} end # A component's state changed — re-render so the native view gets fresh props. - def handle_info({:component_changed, _id, _module}, {module, socket, nav_history, render_mode}) do + def handle_info({:component_changed, _id, _module}, {module, socket, nav, render_mode}) do new_socket = if render_mode == :render do do_render(module, socket) @@ -493,18 +500,18 @@ defmodule Mob.Screen do socket end - {:noreply, {module, new_socket, nav_history, render_mode}} + {:noreply, {module, new_socket, nav, render_mode}} end # Periodic state sync — intercepted before the user's handle_info so the # screen module never sees this internal message. - def handle_info(:__mob_sync_state__, {module, socket, nav_history, render_mode}) do + def handle_info(:__mob_sync_state__, {module, socket, nav, render_mode}) do if module.__mob_persist__() do Mob.ScreenState.dump(module, socket) schedule_state_sync() end - {:noreply, {module, socket, nav_history, render_mode}} + {:noreply, {module, socket, nav, render_mode}} end # Plugin notification routing: the activated plugins' handlers get first crack @@ -521,11 +528,11 @@ defmodule Mob.Screen do def handle_info(message, state), do: forward_to_screen(message, state) - defp forward_to_screen(message, {module, socket, nav_history, render_mode}) do + defp forward_to_screen(message, {module, socket, nav, render_mode}) do {:noreply, new_socket} = module.handle_info(message, socket) - {module, new_socket, nav_history, transition} = - apply_nav_action(module, new_socket, nav_history) + {module, new_socket, nav, transition} = + apply_nav_action(module, new_socket, nav) new_socket = if render_mode == :render do @@ -534,7 +541,7 @@ defmodule Mob.Screen do new_socket end - {:noreply, {module, new_socket, nav_history, render_mode}} + {:noreply, {module, new_socket, nav, render_mode}} end defp to_atom_safe(nil), do: :qr @@ -542,7 +549,7 @@ defmodule Mob.Screen do defp to_atom_safe(a) when is_atom(a), do: a @impl GenServer - def terminate(reason, {module, socket, _nav_history, _render_mode}) do + def terminate(reason, {module, socket, _nav, _render_mode}) do if module.__mob_persist__(), do: Mob.ScreenState.dump(module, socket) module.terminate(reason, socket) end @@ -550,70 +557,97 @@ defmodule Mob.Screen do # ── Navigation ──────────────────────────────────────────────────────────── # Inspect the socket's nav_action and execute it, returning - # {new_module, new_socket, new_nav_history, transition}. - defp apply_nav_action(module, socket, nav_history) do + # {new_module, new_socket, new_nav, transition}. + defp apply_nav_action(module, socket, nav) do + history = Mob.Nav.history(nav) + case socket.__mob__.nav_action do nil -> - {module, socket, nav_history, :none} + {module, socket, nav, :none} {:push, dest, params} -> - {new_module, route_params} = resolve_destination(dest) - platform = socket.__mob__.platform - - new_base = - Mob.Socket.new(new_module, platform: platform) - |> Mob.Socket.assign(:safe_area, socket.assigns.safe_area) - - {:ok, mounted} = new_module.mount(Map.merge(route_params, params), %{}, new_base) + {new_module, mounted} = mount_destination(dest, params, socket) saved = {module, clear_nav_action(socket)} - {new_module, mounted, [saved | nav_history], :push} + {new_module, mounted, Mob.Nav.put_history(nav, [saved | history]), :push} {:pop} -> - case nav_history do + case history do [{prev_module, prev_socket} | rest] -> - {prev_module, prev_socket, rest, :pop} + {prev_module, prev_socket, Mob.Nav.put_history(nav, rest), :pop} [] -> - {module, clear_nav_action(socket), [], :none} + {module, clear_nav_action(socket), nav, :none} end {:pop_to_root} -> - case Enum.reverse(nav_history) do + case Enum.reverse(history) do [{root_module, root_socket} | _] -> - {root_module, root_socket, [], :pop} + {root_module, root_socket, Mob.Nav.put_history(nav, []), :pop} [] -> - {module, clear_nav_action(socket), [], :none} + {module, clear_nav_action(socket), nav, :none} end {:pop_to, dest} -> target = resolve_module(dest) - case pop_to_module(nav_history, target) do + case pop_to_module(history, target) do {:found, prev_module, prev_socket, rest} -> - {prev_module, prev_socket, rest, :pop} + {prev_module, prev_socket, Mob.Nav.put_history(nav, rest), :pop} :not_found -> - {module, clear_nav_action(socket), nav_history, :none} + {module, clear_nav_action(socket), nav, :none} end {:reset, dest, params} -> - {new_module, route_params} = resolve_destination(dest) - platform = socket.__mob__.platform - - new_base = - Mob.Socket.new(new_module, platform: platform) - |> Mob.Socket.assign(:safe_area, socket.assigns.safe_area) + {new_module, mounted} = mount_destination(dest, params, socket) + {new_module, mounted, Mob.Nav.put_history(nav, []), :reset} - {:ok, mounted} = new_module.mount(Map.merge(route_params, params), %{}, new_base) - {new_module, mounted, [], :reset} + {:switch_tab, tab} -> + apply_switch_tab(module, socket, nav, tab) + end + end - {:switch_tab, _tab} -> - # Tab switching is handled renderer-side; clear the action. - {module, clear_nav_action(socket), nav_history, :none} + # Switching stacks parks the current screen — socket and history both — under + # the stack it belongs to, then makes the target stack current. A stack that + # has been visited before is restored without re-mounting, which is the whole + # point: an inactive tab keeps its state. + # + # The transition is `:none`, not `:push` or `:pop`. Those drive the native + # navigation animation, and a tab switch is a swap rather than a move along a + # stack — animating it as a push would slide the incoming tab in from the + # right on iOS. It also keeps `set_transition` to the atoms native already + # understands, so no `.m` or `.zig` change is needed. + defp apply_switch_tab(module, socket, nav, tab) do + current = {module, clear_nav_action(socket)} + + case Mob.Nav.switch(nav, tab, current) do + {:switched, new_nav, {target_module, target_socket}} -> + {target_module, target_socket, new_nav, :none} + + {:mount_root, new_nav, root_module} -> + {mounted_module, mounted} = mount_destination(root_module, %{}, socket) + {mounted_module, mounted, new_nav, :none} + + :noop -> + {module, clear_nav_action(socket), nav, :none} end end + # Resolve a destination and mount it on a fresh socket, inheriting the current + # screen's safe-area inset. + defp mount_destination(dest, params, socket) do + {new_module, route_params} = resolve_destination(dest) + platform = socket.__mob__.platform + + new_base = + Mob.Socket.new(new_module, platform: platform) + |> Mob.Socket.assign(:safe_area, socket.assigns.safe_area) + + {:ok, mounted} = new_module.mount(Map.merge(route_params, params), %{}, new_base) + {new_module, mounted} + end + defp resolve_module(dest) when is_atom(dest) do {module, _route_params} = resolve_destination(dest) module diff --git a/test/mob/nav/multi_stack_test.exs b/test/mob/nav/multi_stack_test.exs new file mode 100644 index 00000000..2c2330a5 --- /dev/null +++ b/test/mob/nav/multi_stack_test.exs @@ -0,0 +1,232 @@ +defmodule Mob.Nav.MultiStackTest do + @moduledoc """ + `Mob.App.tab_bar/1` end to end: two declared stacks, each owning its own + history and its own live screen state across switches. + + Before MOB-109 `{:switch_tab, _}` was cleared as a no-op with a comment + claiming the renderer handled it, so every assertion here failed — one + `nav_history` cannot hold two stacks. + """ + use ExUnit.Case, async: false + + # Sibling modules in nested defmodule blocks don't auto-alias — fully + # qualified names via attributes, matching Mob.Nav.ScreenNavTest. + defmodule HomeScreen do + use Mob.Screen + + @detail Mob.Nav.MultiStackTest.HomeDetailScreen + + def mount(_params, _session, socket), do: {:ok, Mob.Socket.assign(socket, :count, 0)} + def render(assigns), do: %{type: :text, props: %{text: "home #{assigns.count}"}, children: []} + + def handle_event("bump", _, socket), + do: {:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)} + + def handle_event("push_detail", _, socket), + do: {:noreply, Mob.Socket.push_screen(socket, @detail)} + + def handle_event("to_settings", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :settings)} + + def handle_event("to_home", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :home)} + + def handle_event("to_nowhere", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :does_not_exist)} + end + + defmodule HomeDetailScreen do + use Mob.Screen + + def mount(_params, _session, socket), + do: {:ok, Mob.Socket.assign(socket, :where, :home_detail)} + + def render(assigns), do: %{type: :text, props: %{text: "#{assigns.where}"}, children: []} + + def handle_event("to_settings", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :settings)} + + def handle_event("back", _, socket), do: {:noreply, Mob.Socket.pop_screen(socket)} + end + + defmodule SettingsScreen do + use Mob.Screen + + @detail Mob.Nav.MultiStackTest.SettingsDetailScreen + + def mount(_params, _session, socket), do: {:ok, Mob.Socket.assign(socket, :theme, :light)} + + def render(assigns), + do: %{type: :text, props: %{text: "settings #{assigns.theme}"}, children: []} + + def handle_event("dark", _, socket), + do: {:noreply, Mob.Socket.assign(socket, :theme, :dark)} + + def handle_event("push_detail", _, socket), + do: {:noreply, Mob.Socket.push_screen(socket, @detail)} + + def handle_event("to_home", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :home)} + end + + defmodule SettingsDetailScreen do + use Mob.Screen + + def mount(_params, _session, socket), + do: {:ok, Mob.Socket.assign(socket, :where, :settings_detail)} + + def render(assigns), do: %{type: :text, props: %{text: "#{assigns.where}"}, children: []} + + def handle_event("to_home", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :home)} + end + + defmodule TabApp do + @behaviour Mob.App + import Mob.App + + @home Mob.Nav.MultiStackTest.HomeScreen + @settings Mob.Nav.MultiStackTest.SettingsScreen + + def navigation(_) do + tab_bar([ + stack(:home, root: @home, title: "Home"), + stack(:settings, root: @settings, title: "Settings") + ]) + end + end + + setup do + case Process.whereis(Mob.Nav.Registry) do + nil -> :ok + pid -> GenServer.stop(pid) + end + + {:ok, pid} = Mob.Nav.Registry.start_link(TabApp) + on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) + + {:ok, screen} = Mob.Screen.start_link(HomeScreen, %{}) + on_exit(fn -> if Process.alive?(screen), do: GenServer.stop(screen) end) + + %{screen: screen} + end + + describe "switching stacks" do + test "first switch mounts the target stack's declared root", %{screen: screen} do + Mob.Screen.dispatch(screen, "to_settings", %{}) + assert Mob.Screen.get_current_module(screen) == SettingsScreen + end + + test "switching back restores the previous stack's screen", %{screen: screen} do + Mob.Screen.dispatch(screen, "to_settings", %{}) + Mob.Screen.dispatch(screen, "to_home", %{}) + assert Mob.Screen.get_current_module(screen) == HomeScreen + end + + test "switching to an undeclared stack leaves navigation untouched", %{screen: screen} do + Mob.Screen.dispatch(screen, "to_nowhere", %{}) + assert Mob.Screen.get_current_module(screen) == HomeScreen + end + + test "switching to the active stack is a no-op", %{screen: screen} do + Mob.Screen.dispatch(screen, "to_home", %{}) + assert Mob.Screen.get_current_module(screen) == HomeScreen + assert Mob.Screen.get_socket(screen).assigns.count == 0 + end + end + + describe "state preservation" do + test "an inactive stack keeps its screen assigns", %{screen: screen} do + Mob.Screen.dispatch(screen, "bump", %{}) + Mob.Screen.dispatch(screen, "bump", %{}) + assert Mob.Screen.get_socket(screen).assigns.count == 2 + + Mob.Screen.dispatch(screen, "to_settings", %{}) + Mob.Screen.dispatch(screen, "to_home", %{}) + + assert Mob.Screen.get_socket(screen).assigns.count == 2 + end + + test "returning to a stack does not re-run mount", %{screen: screen} do + # mount/3 resets count to 0, so a surviving non-zero count proves the + # socket was restored rather than the root re-mounted. + Mob.Screen.dispatch(screen, "bump", %{}) + Mob.Screen.dispatch(screen, "to_settings", %{}) + Mob.Screen.dispatch(screen, "to_home", %{}) + + assert Mob.Screen.get_socket(screen).assigns.count == 1 + end + + test "the other stack keeps its own assigns too", %{screen: screen} do + Mob.Screen.dispatch(screen, "to_settings", %{}) + Mob.Screen.dispatch(screen, "dark", %{}) + Mob.Screen.dispatch(screen, "to_home", %{}) + Mob.Screen.dispatch(screen, "to_settings", %{}) + + assert Mob.Screen.get_socket(screen).assigns.theme == :dark + end + end + + describe "independent histories" do + test "each stack pushes onto its own history", %{screen: screen} do + Mob.Screen.dispatch(screen, "push_detail", %{}) + assert length(Mob.Screen.get_nav_history(screen)) == 1 + + # The settings stack has never been visited — its history starts empty + # rather than inheriting home's. + Mob.Screen.dispatch(screen, "to_settings", %{}) + assert Mob.Screen.get_nav_history(screen) == [] + end + + test "an inactive stack's history survives the round trip", %{screen: screen} do + Mob.Screen.dispatch(screen, "push_detail", %{}) + Mob.Screen.dispatch(screen, "to_settings", %{}) + Mob.Screen.dispatch(screen, "to_home", %{}) + + assert Mob.Screen.get_current_module(screen) == HomeDetailScreen + assert [{HomeScreen, _}] = Mob.Screen.get_nav_history(screen) + end + + test "both stacks hold a deep history at the same time", %{screen: screen} do + Mob.Screen.dispatch(screen, "push_detail", %{}) + Mob.Screen.dispatch(screen, "to_settings", %{}) + Mob.Screen.dispatch(screen, "push_detail", %{}) + + assert Mob.Screen.get_current_module(screen) == SettingsDetailScreen + assert [{SettingsScreen, _}] = Mob.Screen.get_nav_history(screen) + + Mob.Screen.dispatch(screen, "to_home", %{}) + assert Mob.Screen.get_current_module(screen) == HomeDetailScreen + assert [{HomeScreen, _}] = Mob.Screen.get_nav_history(screen) + end + + test "popping in one stack does not touch the other", %{screen: screen} do + Mob.Screen.dispatch(screen, "push_detail", %{}) + Mob.Screen.dispatch(screen, "to_settings", %{}) + Mob.Screen.dispatch(screen, "push_detail", %{}) + Mob.Screen.dispatch(screen, "to_home", %{}) + + Mob.Screen.dispatch(screen, "back", %{}) + assert Mob.Screen.get_current_module(screen) == HomeScreen + assert Mob.Screen.get_nav_history(screen) == [] + + Mob.Screen.dispatch(screen, "to_settings", %{}) + assert Mob.Screen.get_current_module(screen) == SettingsDetailScreen + assert length(Mob.Screen.get_nav_history(screen)) == 1 + end + + test "the back gesture pops the active stack only", %{screen: screen} do + Mob.Screen.dispatch(screen, "push_detail", %{}) + Mob.Screen.dispatch(screen, "to_settings", %{}) + Mob.Screen.dispatch(screen, "push_detail", %{}) + + send(screen, {:mob, :back}) + :sys.get_state(screen) + + assert Mob.Screen.get_current_module(screen) == SettingsScreen + + Mob.Screen.dispatch(screen, "to_home", %{}) + assert Mob.Screen.get_current_module(screen) == HomeDetailScreen + end + end +end diff --git a/test/mob/nav_test.exs b/test/mob/nav_test.exs new file mode 100644 index 00000000..3571e34f --- /dev/null +++ b/test/mob/nav_test.exs @@ -0,0 +1,158 @@ +defmodule Mob.NavTest do + use ExUnit.Case, async: true + + import Mob.App, only: [stack: 2, tab_bar: 1, drawer: 1] + + alias Mob.Nav + + defmodule HomeScreen, do: nil + defmodule SettingsScreen, do: nil + defmodule ProfileScreen, do: nil + defmodule StrayScreen, do: nil + + defp entry(module), do: {module, Mob.Socket.new(module, platform: :android)} + + defp two_tabs do + tab_bar([ + stack(:home, root: HomeScreen, title: "Home"), + stack(:settings, root: SettingsScreen, title: "Settings") + ]) + end + + describe "new/0" do + test "is an empty single-stack state" do + nav = Nav.new() + assert Nav.history(nav) == [] + assert Nav.active(nav) == nil + assert Nav.stacks(nav) == [] + end + end + + describe "from_layout/2" do + test "nil layout produces the empty state" do + assert Nav.from_layout(nil, HomeScreen) == Nav.new() + end + + test "records declared stacks in declaration order" do + nav = Nav.from_layout(two_tabs(), HomeScreen) + assert Nav.stacks(nav) == [:home, :settings] + end + + test "the stack whose root is the mounted module becomes active" do + nav = Nav.from_layout(two_tabs(), SettingsScreen) + assert Nav.active(nav) == :settings + end + + test "falls back to the first declared stack when no root matches" do + # The running screen still has to belong somewhere, or switching away + # would have nowhere to park it and its state would be lost. + nav = Nav.from_layout(two_tabs(), StrayScreen) + assert Nav.active(nav) == :home + end + + test "a bare stack declaration yields one stack" do + nav = Nav.from_layout(stack(:only, root: HomeScreen), HomeScreen) + assert Nav.stacks(nav) == [:only] + assert Nav.active(nav) == :only + end + + test "drawer branches are stacks too" do + layout = drawer([stack(:a, root: HomeScreen), stack(:b, root: SettingsScreen)]) + assert Nav.stacks(Nav.from_layout(layout, HomeScreen)) == [:a, :b] + end + + test "starts with an empty history" do + assert Nav.history(Nav.from_layout(two_tabs(), HomeScreen)) == [] + end + end + + describe "history/1 and put_history/2" do + test "round-trips the active stack's history" do + nav = Nav.from_layout(two_tabs(), HomeScreen) + history = [entry(ProfileScreen), entry(HomeScreen)] + assert nav |> Nav.put_history(history) |> Nav.history() == history + end + + test "does not disturb the active stack name" do + nav = Nav.from_layout(two_tabs(), HomeScreen) |> Nav.put_history([entry(ProfileScreen)]) + assert Nav.active(nav) == :home + end + end + + describe "switch/3" do + test "first visit asks the caller to mount that stack's root" do + nav = Nav.from_layout(two_tabs(), HomeScreen) + assert {:mount_root, _nav, SettingsScreen} = Nav.switch(nav, :settings, entry(HomeScreen)) + end + + test "first visit makes the target active with an empty history" do + nav = Nav.from_layout(two_tabs(), HomeScreen) + {:mount_root, nav, _root} = Nav.switch(nav, :settings, entry(HomeScreen)) + assert Nav.active(nav) == :settings + assert Nav.history(nav) == [] + end + + test "switching to the already-active stack is a no-op" do + nav = Nav.from_layout(two_tabs(), HomeScreen) + assert Nav.switch(nav, :home, entry(HomeScreen)) == :noop + end + + test "switching to an undeclared stack is a no-op" do + # Mob.Socket.switch_tab/2 accepts any atom; a typo must not strand the + # app on a stack that does not exist. + nav = Nav.from_layout(two_tabs(), HomeScreen) + assert Nav.switch(nav, :nope, entry(HomeScreen)) == :noop + end + + test "returning to a visited stack restores its screen without re-mounting" do + home = entry(HomeScreen) + settings = entry(SettingsScreen) + + nav = Nav.from_layout(two_tabs(), HomeScreen) + {:mount_root, nav, _} = Nav.switch(nav, :settings, home) + assert {:switched, _nav, restored} = Nav.switch(nav, :home, settings) + assert restored == home + end + + test "an inactive stack keeps its own history across a round trip" do + home_history = [entry(ProfileScreen)] + + nav = + two_tabs() + |> Nav.from_layout(HomeScreen) + |> Nav.put_history(home_history) + + {:mount_root, nav, _} = Nav.switch(nav, :settings, entry(HomeScreen)) + # The active stack's history is the target's, not the one we parked. + assert Nav.history(nav) == [] + + {:switched, nav, _restored} = Nav.switch(nav, :home, entry(SettingsScreen)) + assert Nav.history(nav) == home_history + end + + test "each stack's history stays independent" do + nav = Nav.from_layout(two_tabs(), HomeScreen) |> Nav.put_history([entry(ProfileScreen)]) + {:mount_root, nav, _} = Nav.switch(nav, :settings, entry(HomeScreen)) + + settings_history = [entry(SettingsScreen), entry(SettingsScreen)] + nav = Nav.put_history(nav, settings_history) + + {:switched, nav, _} = Nav.switch(nav, :home, entry(SettingsScreen)) + assert length(Nav.history(nav)) == 1 + + {:switched, nav, _} = Nav.switch(nav, :settings, entry(HomeScreen)) + assert Nav.history(nav) == settings_history + end + + test "the active stack is never left in parked" do + nav = Nav.from_layout(two_tabs(), HomeScreen) + {:mount_root, nav, _} = Nav.switch(nav, :settings, entry(HomeScreen)) + refute Map.has_key?(nav.parked, :settings) + assert Map.has_key?(nav.parked, :home) + end + + test "with no declared layout there is nothing to switch to" do + assert Nav.switch(Nav.new(), :home, entry(HomeScreen)) == :noop + end + end +end From 74698521f6287640233d3f85fb6832781a04b106 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 28 Aug 2026 13:38:44 -0600 Subject: [PATCH 2/2] MOB-109: fix four defects found by adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Boot crash on an unrecognised navigation/1 return. from_layout/2 had clauses for nil and is_map/1 and no catch-all, but Nav.Registry has always tolerated any shape (register_nav(_), do: :ok) and now stores it verbatim. Since from_layout/2 runs inside Mob.Screen.init/1, `def navigation(_), do: []` went from harmlessly ignored to the app failing to boot, with no supervision to absorb it until MOB-112. Added the catch-all. 2. The first-declared-stack fallback made a declared root permanently unreachable. Filing a screen that is no stack's root under :home, plus switch-to-active being a no-op, meant an app declaring tab_bar([stack( :home, root: HomeScreen), ...]) but booting on SplashScreen could never reach HomeScreen again — Splash squatted the slot and was restored every time. Such a screen now gets a private :__mob_root__ stack, absent from order and roots: state still parked, no declared root shadowed, and not itself a switch target since no tab corresponds to it. 3. Back at a tab root exited the app. Once history/1 means the *active* stack's history, "empty history therefore exit" killed the app from the root of any tab and discarded every parked stack — the opposite of what the parked state is for. Mob.Nav.back_target/1 now returns to the first stack from a secondary one, and exits only from the first stack, an orphan, or a single-stack app. 4. Mob.Nav missing from the ExDoc module groups, plus one cosmetic variable the rename missed. The ADR is updated in place rather than superseded — the branch is unpushed, so the decision it recorded never shipped, and a superseded doc for a 30-minute-old decision would be noise. Three further findings are real but want MOB-112's per-screen processes to fix cleanly, and are recorded as known gaps in the ADR: reset_to/2 not re-deriving the destination's stack (leaving two live instances of one screen in two stacks), parked screens getting neither terminate/2 nor state sync, and re-selecting the active tab not popping to root. Tests: 11 more (28 unit, 14 integration). Suite 1161 passed, format and credo --strict clean. Co-Authored-By: Claude Opus 5 (1M context) --- decisions/2026-08-28-multi-stack-nav-state.md | 57 ++++++++++++++--- lib/mob/nav.ex | 61 +++++++++++++++---- lib/mob/screen.ex | 23 +++++-- mix.exs | 2 +- test/mob/nav/multi_stack_test.exs | 22 +++++++ test/mob/nav_test.exs | 61 +++++++++++++++++-- 6 files changed, 194 insertions(+), 32 deletions(-) diff --git a/decisions/2026-08-28-multi-stack-nav-state.md b/decisions/2026-08-28-multi-stack-nav-state.md index f7b73d88..244059ce 100644 --- a/decisions/2026-08-28-multi-stack-nav-state.md +++ b/decisions/2026-08-28-multi-stack-nav-state.md @@ -52,18 +52,46 @@ 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 falls back to the first declared stack +### 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 — an app that calls `start_root/1` with a -module that is not any stack's root — the first declared stack is used rather -than leaving `active` as `nil`. - -`nil` was the more honest answer and is worse in practice: with no active stack -there is nowhere to park the running screen, so the first `switch_tab` would -discard its socket and history outright. The fallback misattributes a label; the -alternative loses user state. The screen is preserved either way, and the -misattribution only occurs in a configuration that is already unusual. +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 @@ -86,5 +114,14 @@ until MOB-112 lands per-screen supervision, would take the whole app with it. - 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. diff --git a/lib/mob/nav.ex b/lib/mob/nav.ex index afd43ec6..6216d4f3 100644 --- a/lib/mob/nav.ex +++ b/lib/mob/nav.ex @@ -54,6 +54,11 @@ defmodule Mob.Nav do 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. @@ -68,28 +73,31 @@ defmodule Mob.Nav do screen that is already mounted. `layout` is the map returned by `Mob.App.stack/2`, `tab_bar/1`, or `drawer/1` - (or `nil` when the app declares none). The active stack is the one whose root - is `current_module`; when no stack declares that module as its root the first - declared stack is used, so the running screen still belongs somewhere and is - preserved across a switch. Its state is never discarded — only its label is a - guess, and only in that fallback case. + (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 - declared = declared_stacks(layout) - - case declared do + case declared_stacks(layout) do [] -> new() - [{first_name, _} | _] -> + declared -> roots = Map.new(declared) order = Enum.map(declared, fn {name, _root} -> name end) active = - Enum.find_value(declared, first_name, fn {name, root} -> + Enum.find_value(declared, @orphan_stack, fn {name, root} -> if root == current_module, do: name end) @@ -97,6 +105,12 @@ defmodule Mob.Nav do 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 @@ -107,7 +121,12 @@ defmodule Mob.Nav do %{nav | history: history} end - @doc "Name of the active stack, or `nil` when no layout was declared." + @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 @@ -154,6 +173,26 @@ defmodule Mob.Nav do 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. diff --git a/lib/mob/screen.ex b/lib/mob/screen.ex index 3f7b2ffa..2c05b770 100644 --- a/lib/mob/screen.ex +++ b/lib/mob/screen.ex @@ -454,11 +454,22 @@ defmodule Mob.Screen do {:noreply, {module, socket, nav, render_mode}} else {module, new_socket, new_nav, transition} = - if Mob.Nav.history(nav) == [] do - if render_mode == :render, do: :mob_nif.exit_app() - {module, socket, nav, :none} - else - apply_nav_action(module, Mob.Socket.put_mob(socket, :nav_action, {:pop}), nav) + case {Mob.Nav.history(nav), Mob.Nav.back_target(nav)} do + {[_ | _], _} -> + apply_nav_action(module, Mob.Socket.put_mob(socket, :nav_action, {:pop}), nav) + + # Nothing left to pop on a secondary stack: fall back to the first one + # rather than exiting and discarding every parked stack. + {[], {:switch, target}} -> + apply_nav_action( + module, + Mob.Socket.put_mob(socket, :nav_action, {:switch_tab, target}), + nav + ) + + {[], :exit} -> + if render_mode == :render, do: :mob_nif.exit_app() + {module, socket, nav, :none} end new_socket = @@ -518,7 +529,7 @@ defmodule Mob.Screen do # at every `{:notification, payload}`. A plugin whose `:match` matches handles # it and the host screen does not also see it; an unmatched notification falls # through to the screen's own `handle_info` like any other message. - def handle_info({:notification, payload} = message, {_module, _socket, _nh, _rm} = state) + def handle_info({:notification, payload} = message, {_module, _socket, _nav, _mode} = state) when is_map(payload) do case Mob.Plugins.dispatch_notification(payload) do :handled -> {:noreply, state} diff --git a/mix.exs b/mix.exs index c2ce5ba2..1c8b3337 100644 --- a/mix.exs +++ b/mix.exs @@ -154,7 +154,7 @@ defmodule Mob.MixProject do Mob.Theme.Dark, Mob.Theme.Adaptive ], - Navigation: [Mob.Nav.Registry], + Navigation: [Mob.Nav, Mob.Nav.Registry], Plugins: [Mob.Plugins, Mob.Plugins.Supervisor, Mob.Plugins.Lifecycle], "Device APIs": [ Mob.Haptic, diff --git a/test/mob/nav/multi_stack_test.exs b/test/mob/nav/multi_stack_test.exs index 2c2330a5..c8cf614a 100644 --- a/test/mob/nav/multi_stack_test.exs +++ b/test/mob/nav/multi_stack_test.exs @@ -215,6 +215,28 @@ defmodule Mob.Nav.MultiStackTest do assert length(Mob.Screen.get_nav_history(screen)) == 1 end + test "back at a secondary stack's root returns to the first stack", %{screen: screen} do + # Not exit_app: that would discard every parked stack, which is the state + # this whole feature exists to keep. + Mob.Screen.dispatch(screen, "bump", %{}) + Mob.Screen.dispatch(screen, "to_settings", %{}) + assert Mob.Screen.get_nav_history(screen) == [] + + send(screen, {:mob, :back}) + :sys.get_state(screen) + + assert Mob.Screen.get_current_module(screen) == HomeScreen + assert Mob.Screen.get_socket(screen).assigns.count == 1 + end + + test "back at the first stack's root does not switch away", %{screen: screen} do + # :no_render mode means exit_app/0 is not called; the screen stays put. + send(screen, {:mob, :back}) + :sys.get_state(screen) + + assert Mob.Screen.get_current_module(screen) == HomeScreen + end + test "the back gesture pops the active stack only", %{screen: screen} do Mob.Screen.dispatch(screen, "push_detail", %{}) Mob.Screen.dispatch(screen, "to_settings", %{}) diff --git a/test/mob/nav_test.exs b/test/mob/nav_test.exs index 3571e34f..00cba06f 100644 --- a/test/mob/nav_test.exs +++ b/test/mob/nav_test.exs @@ -43,11 +43,40 @@ defmodule Mob.NavTest do assert Nav.active(nav) == :settings end - test "falls back to the first declared stack when no root matches" do - # The running screen still has to belong somewhere, or switching away - # would have nowhere to park it and its state would be lost. + test "a screen that is no stack's root gets its own orphan stack" do + # It must belong somewhere or switching away would discard it, but it must + # not squat a declared stack's slot either. nav = Nav.from_layout(two_tabs(), StrayScreen) - assert Nav.active(nav) == :home + assert Nav.active(nav) == :__mob_root__ + assert Nav.stacks(nav) == [:home, :settings] + end + + test "an orphan screen does not make a declared root unreachable" do + # Squatting :home would make this a no-op and strand HomeScreen forever. + nav = Nav.from_layout(two_tabs(), StrayScreen) + assert {:mount_root, _nav, HomeScreen} = Nav.switch(nav, :home, entry(StrayScreen)) + end + + test "an orphan screen is still parked, not discarded" do + orphan = entry(StrayScreen) + nav = Nav.from_layout(two_tabs(), StrayScreen) + {:mount_root, nav, _} = Nav.switch(nav, :home, orphan) + assert nav.parked[:__mob_root__] == %{current: orphan, history: []} + end + + test "the orphan stack is not a switch target" do + nav = Nav.from_layout(two_tabs(), StrayScreen) + {:mount_root, nav, _} = Nav.switch(nav, :home, entry(StrayScreen)) + assert Nav.switch(nav, :__mob_root__, entry(HomeScreen)) == :noop + end + + test "an unrecognised layout is ignored rather than raising" do + # navigation/1 is app-supplied and unvalidated, and this runs inside + # Mob.Screen.init/1 — raising would turn a shape Nav.Registry has always + # tolerated into a failure to boot. + assert Nav.from_layout([stack(:home, root: HomeScreen)], HomeScreen) == Nav.new() + assert Nav.from_layout(:nonsense, HomeScreen) == Nav.new() + assert Nav.from_layout(%{type: :unknown}, HomeScreen) == Nav.new() end test "a bare stack declaration yields one stack" do @@ -79,6 +108,30 @@ defmodule Mob.NavTest do end end + describe "back_target/1" do + test "the first declared stack exits" do + assert Nav.back_target(Nav.from_layout(two_tabs(), HomeScreen)) == :exit + end + + test "a secondary stack returns to the first" do + nav = Nav.from_layout(two_tabs(), SettingsScreen) + assert Nav.back_target(nav) == {:switch, :home} + end + + test "an orphan screen exits — it is not a tab to back out of" do + assert Nav.back_target(Nav.from_layout(two_tabs(), StrayScreen)) == :exit + end + + test "a single-stack app exits" do + nav = Nav.from_layout(stack(:only, root: HomeScreen), HomeScreen) + assert Nav.back_target(nav) == :exit + end + + test "no declared layout exits" do + assert Nav.back_target(Nav.new()) == :exit + end + end + describe "switch/3" do test "first visit asks the caller to mount that stack's root" do nav = Nav.from_layout(two_tabs(), HomeScreen)