diff --git a/guides/navigation.md b/guides/navigation.md index 212612a..88b1dfa 100644 --- a/guides/navigation.md +++ b/guides/navigation.md @@ -136,15 +136,30 @@ 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: @@ -152,7 +167,7 @@ same stack operation represents directional movement, such as custom tabs: 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: @@ -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 @@ -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). @@ -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. diff --git a/guides/testing.md b/guides/testing.md index 48be16d..5bad129 100644 --- a/guides/testing.md +++ b/guides/testing.md @@ -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) diff --git a/lib/mob/component_registry.ex b/lib/mob/component_registry.ex index 1c2bd70..0928363 100644 --- a/lib/mob/component_registry.ex +++ b/lib/mob/component_registry.ex @@ -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 """ @@ -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 ────────────────────────────────────────────────────────────── diff --git a/lib/mob/component_server.ex b/lib/mob/component_server.ex index 887cc37..9340474 100644 --- a/lib/mob/component_server.ex +++ b/lib/mob/component_server.ex @@ -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 @@ -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} -> @@ -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 @@ -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 diff --git a/lib/mob/nav.ex b/lib/mob/nav.ex index 1284f5f..f05b99e 100644 --- a/lib/mob/nav.ex +++ b/lib/mob/nav.ex @@ -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. diff --git a/lib/mob/router.ex b/lib/mob/router.ex index 975dd56..e2df09f 100644 --- a/lib/mob/router.ex +++ b/lib/mob/router.ex @@ -121,6 +121,38 @@ defmodule Mob.Router do @spec get_screen_pid(GenServer.server()) :: pid() def get_screen_pid(pid), do: GenServer.call(pid, :get_screen_pid) + @doc false + @spec reset_navigation(map(), module(), module()) :: map() + def reset_navigation(nav, new_module, nav_module \\ Mob.Nav) do + if function_exported?(nav_module, :reset, 2) do + nav_module.reset(nav, new_module) + else + roots = Map.get(nav, :roots, %{}) + + active = + Enum.find_value(Map.get(nav, :order, []), :__mob_root__, fn name -> + if Map.get(roots, name) == new_module, do: name + end) + + nav + |> Map.put(:active, active) + |> Map.put(:history, []) + |> Map.put(:parked, %{}) + end + end + + @doc false + @spec reset_all_supported?(module(), module()) :: boolean() + def reset_all_supported?( + screen_server \\ Mob.Screen.Server, + screen_state \\ Mob.ScreenState + ) do + Code.ensure_loaded?(screen_server) and + function_exported?(screen_server, :discard_persisted_state, 1) and + Code.ensure_loaded?(screen_state) and + function_exported?(screen_state, :delete_all, 0) + end + # ── GenServer callbacks ─────────────────────────────────────────────────── @impl GenServer @@ -339,6 +371,10 @@ defmodule Mob.Router do defp start_screen(module, params, state), do: start_screen(module, params, make_ref(), state) defp start_screen(module, params, ref, state) do + start_screen(module, params, ref, state, []) + end + + defp start_screen(module, params, ref, state, screen_opts) do opts = [ module: module, params: params, @@ -346,7 +382,8 @@ defmodule Mob.Router do owner: self(), render_mode: state.render_mode, platform: state.platform, - nif: state.nif + nif: state.nif, + restore_persisted_state: Keyword.get(screen_opts, :restore_persisted_state, true) ] case Mob.Screen.Server.start_link(opts) do @@ -362,8 +399,25 @@ defmodule Mob.Router do # The single place `current` changes. The sender is told here and nowhere # else, so only the screen the user is looking at can commit a frame. defp make_current(state, entry, transition) do - Mob.Sender.activate(entry.ref, transition) - %{state | current: entry} + activation_token = + if activation_frame_supported?() do + Mob.Sender.activate_frame(entry.ref, transition) + else + Mob.Sender.activate(entry.ref, transition) + nil + end + + %{state | current: Map.put(entry, :activation_token, activation_token)} + end + + # During a code push modules are loaded independently. Only enter the token + # protocol once every participant can carry it end-to-end; otherwise use the + # established activation/render API until the next navigation. + defp activation_frame_supported? do + function_exported?(Mob.Sender, :activate_frame, 2) and + function_exported?(Mob.Sender, :render, 6) and + function_exported?(Mob.Screen.Server, :render, 3) and + function_exported?(Mob.Screen.Server, :render_sync, 3) end defp all_entries(state) do @@ -441,6 +495,15 @@ defmodule Mob.Router do end defp substitute(state, dead_pid, new_entry) do + new_entry = + case state.current do + %{pid: ^dead_pid} = current -> + Map.put(new_entry, :activation_token, Map.get(current, :activation_token)) + + _other -> + new_entry + end + replace = fn %{pid: ^dead_pid} -> new_entry other -> other @@ -528,17 +591,39 @@ defmodule Mob.Router do defp do_paint(_entry, _transition, %{render_mode: :no_render}, _mode), do: :ok - defp do_paint(entry, transition, _state, :sync) do + defp do_paint(entry, transition, state, :sync) do # Unprotected, this is the other way a screen crash killed the owner: the # user's render/1 runs inside the screen, and a raise there exits this call. - case safe_call(fn -> Mob.Screen.Server.render_sync(entry.pid, transition) end) do + token = activation_token(entry, state) + + case safe_call(fn -> render_screen_sync(entry.pid, transition, token) end) do {:ok, _} -> :ok {:exit, _reason} -> :ok end end - defp do_paint(entry, transition, _state, :async), - do: Mob.Screen.Server.render(entry.pid, transition) + defp do_paint(entry, transition, state, :async) do + token = activation_token(entry, state) + + if token && function_exported?(Mob.Screen.Server, :render, 3) do + Mob.Screen.Server.render(entry.pid, transition, token) + else + Mob.Screen.Server.render(entry.pid, transition) + end + end + + defp render_screen_sync(pid, transition, token) do + if token && function_exported?(Mob.Screen.Server, :render_sync, 3) do + Mob.Screen.Server.render_sync(pid, transition, token) + else + Mob.Screen.Server.render_sync(pid, transition) + end + end + + defp activation_token(%{pid: pid}, %{current: %{pid: pid} = current}), + do: Map.get(current, :activation_token) + + defp activation_token(_entry, _state), do: nil # Drop the entry from tracking BEFORE stopping, so the exit we asked for is # recognised as deliberate rather than restarted as a crash. @@ -555,6 +640,18 @@ defmodule Mob.Router do state end + defp discard_screen(entry, state) do + # This call both deletes the current module/key snapshot and disables the + # periodic/final dumps before stop_screen/2 asks the process to terminate. + # The order matters: deleting first and then allowing terminate/2 to dump + # would immediately recreate the session we are trying to discard. + safe_call(fn -> + Mob.Screen.Server.discard_persisted_state(entry.pid, @stop_timeout_ms) + end) + + stop_screen(entry, state) + end + defp stop_process(pid) do if Process.alive?(pid) do # Unlink first. We are discarding this screen deliberately, so its @@ -643,29 +740,32 @@ defmodule Mob.Router do end end - defp apply_nav_action({:switch_tab, tab}, state, mode) do - case Mob.Nav.switch(state.nav, tab, state.current) do - {:switched, nav, entry} -> - state = make_current(%{state | nav: nav}, entry, :none) - do_paint(entry, :none, state, mode) - state + defp apply_nav_action({:reset, dest, params, transition, :all}, state, mode) do + if reset_all_supported?() do + with {:ok, new_module, route_params} <- safe_resolve(dest, state) do + reset_all_resolved(new_module, Map.merge(route_params, params), transition, state, mode) + end + else + Logger.error( + "[mob] all-stack reset was ignored while older navigation lifecycle code was loaded. " <> + "Retry after the code push finishes or restart the app." + ) - {:mount_root, nav, root_module} -> - # Start first, mutate after. Switching nav before the mount could fail - # leaves navigation pointing at a stack whose screen never started. - case start_screen(root_module, %{}, state) do - {:ok, entry, state} -> - state = make_current(%{state | nav: nav}, entry, :none) - do_paint(entry, :none, state, mode) - state + repaint_current(state, mode) + end + end - {:error, _reason} -> - repaint_current(state, mode) - end + defp apply_nav_action({:switch_tab, tab}, state, mode) do + apply_tab_switch(tab, :none, state, mode) + end - :noop -> - repaint_current(state, mode) - end + defp apply_nav_action({:switch_tab, tab, transition}, state, mode) do + apply_tab_switch(tab, transition, %{}, state, mode) + end + + defp apply_nav_action({:switch_tab, tab, transition, mount_params}, state, mode) + when is_map(mount_params) do + apply_tab_switch(tab, transition, mount_params, state, mode) end # A shape this router does not know. Reachable during a hot code push, where @@ -683,6 +783,35 @@ defmodule Mob.Router do repaint_current(state, mode) end + defp apply_tab_switch(tab, transition, state, mode) do + apply_tab_switch(tab, transition, %{}, state, mode) + end + + defp apply_tab_switch(tab, transition, mount_params, state, mode) do + case Mob.Nav.switch(state.nav, tab, state.current) do + {:switched, nav, entry} -> + state = make_current(%{state | nav: nav}, entry, transition) + do_paint(entry, transition, state, mode) + state + + {:mount_root, nav, root_module} -> + # Start first, mutate after. Switching nav before the mount could fail + # leaves navigation pointing at a stack whose screen never started. + case start_screen(root_module, mount_params, state) do + {:ok, entry, state} -> + state = make_current(%{state | nav: nav}, entry, transition) + do_paint(entry, transition, state, mode) + state + + {:error, _reason} -> + repaint_current(state, mode) + end + + :noop -> + repaint_current(state, mode) + end + end + defp push_resolved(new_module, mount_params, state, mode) do case start_screen(new_module, mount_params, state) do {:ok, entry, state} -> @@ -713,6 +842,34 @@ defmodule Mob.Router do end end + defp reset_all_resolved(new_module, mount_params, transition, state, mode) do + discarded = all_entries(state) + + # Mount first and mutate only after it succeeds. An auth reset often points + # at user code, and a failed mount must not destroy every still-live tab. + case start_screen( + new_module, + mount_params, + make_ref(), + state, + restore_persisted_state: false + ) do + {:ok, entry, state} -> + state = Enum.reduce(discarded, state, &discard_screen/2) + # Every old screen is now stopped, so nothing from the prior session + # can recreate a record after this sweep. This also covers one that + # exited between collection and its synchronous preparation call. + Mob.ScreenState.delete_all() + nav = reset_navigation(state.nav, new_module) + state = make_current(%{state | nav: nav}, entry, transition) + do_paint(entry, :none, state, mode) + state + + {:error, _reason} -> + repaint_current(state, mode) + end + end + defp pop_to_resolved(target, state, mode) do history = Mob.Nav.history(state.nav) diff --git a/lib/mob/screen/server.ex b/lib/mob/screen/server.ex index e839dea..3f024ad 100644 --- a/lib/mob/screen/server.ex +++ b/lib/mob/screen/server.ex @@ -55,7 +55,7 @@ defmodule Mob.Screen.Server do """ @type render_ref :: reference() - defstruct [:module, :socket, :render_mode, :ref, :owner, :nif] + defstruct [:module, :socket, :render_mode, :ref, :owner, :nif, persist_on_terminate: true] @doc """ Start a screen linked to the calling process. @@ -87,6 +87,12 @@ defmodule Mob.Screen.Server do @spec socket(pid()) :: Mob.Socket.t() def socket(pid), do: GenServer.call(pid, :get_socket) + @doc false + @spec discard_persisted_state(pid(), timeout()) :: :ok + def discard_persisted_state(pid, timeout \\ 5_000) do + GenServer.call(pid, :discard_persisted_state, timeout) + end + @doc """ Render this screen's tree, in this screen's process. @@ -101,6 +107,11 @@ defmodule Mob.Screen.Server do @spec render(pid(), atom()) :: :ok def render(pid, transition \\ :none), do: GenServer.cast(pid, {:render, transition}) + @doc false + @spec render(pid(), atom(), reference() | nil) :: :ok + def render(pid, transition, activation_token), + do: GenServer.cast(pid, {:render, transition, activation_token}) + @doc "Paint and block until the frame has been committed." @spec render_sync(pid(), atom()) :: :ok def render_sync(pid, transition \\ :none) do @@ -109,6 +120,12 @@ defmodule Mob.Screen.Server do GenServer.call(pid, {:render_sync, transition}, :infinity) end + @doc false + @spec render_sync(pid(), atom(), reference() | nil) :: :ok + def render_sync(pid, transition, activation_token) do + GenServer.call(pid, {:render_sync, transition, activation_token}, :infinity) + end + @doc "Repaint with the screen module's newly loaded code." @spec hot_reload(pid()) :: :ok def hot_reload(pid), do: GenServer.cast(pid, :__mob_hot_reload__) @@ -138,7 +155,13 @@ defmodule Mob.Screen.Server do case module.mount(Keyword.get(opts, :params, %{}), %{}, socket) do {:ok, mounted} -> # Restore persisted assigns after mount so mount always runs cleanly. - socket = maybe_load_state(module, mounted) + socket = + if Keyword.get(opts, :restore_persisted_state, true) do + maybe_load_state(module, mounted) + else + mounted + end + if module.__mob_persist__(), do: schedule_state_sync() {:ok, @@ -166,6 +189,11 @@ defmodule Mob.Screen.Server do def handle_call(:get_socket, _from, state), do: {:reply, state.socket, state} + def handle_call(:discard_persisted_state, _from, state) do + if state.module.__mob_persist__(), do: Mob.ScreenState.delete(state.module, state.socket) + {:reply, :ok, Map.put(state, :persist_on_terminate, false)} + end + def handle_call(:get_tree, _from, state) do {:reply, state.module.render(state.socket.assigns), state} end @@ -174,11 +202,19 @@ defmodule Mob.Screen.Server do {:reply, :ok, %{state | socket: paint(state, transition, :sync)}} end + def handle_call({:render_sync, transition, activation_token}, _from, state) do + {:reply, :ok, %{state | socket: paint(state, transition, :sync, activation_token)}} + end + @impl GenServer def handle_cast({:render, transition}, state) do {:noreply, %{state | socket: paint(state, transition)}} end + def handle_cast({:render, transition, activation_token}, state) do + {:noreply, %{state | socket: paint(state, transition, :async, activation_token)}} + end + def handle_cast(:__mob_hot_reload__, state) do {:noreply, %{state | socket: paint(state, :none)}} end @@ -198,7 +234,7 @@ defmodule Mob.Screen.Server do # 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__, state) do - if state.module.__mob_persist__() do + if Map.get(state, :persist_on_terminate, true) and state.module.__mob_persist__() do Mob.ScreenState.dump(state.module, state.socket) schedule_state_sync() end @@ -245,7 +281,10 @@ defmodule Mob.Screen.Server do @impl GenServer def terminate(reason, state) do - if state.module.__mob_persist__(), do: Mob.ScreenState.dump(state.module, state.socket) + if Map.get(state, :persist_on_terminate, true) and state.module.__mob_persist__() do + Mob.ScreenState.dump(state.module, state.socket) + end + state.module.terminate(reason, state.socket) end @@ -285,10 +324,12 @@ defmodule Mob.Screen.Server do end end - defp paint(state, transition, mode \\ :async) - defp paint(%{render_mode: :no_render} = state, _transition, _mode), do: state.socket + defp paint(state, transition, mode \\ :async, activation_token \\ nil) - defp paint(state, transition, mode) do + defp paint(%{render_mode: :no_render} = state, _transition, _mode, _activation_token), + do: state.socket + + defp paint(state, transition, mode, activation_token) do socket = ensure_safe_area(state.socket, state.socket.__mob__.platform, state.nif) platform = socket.__mob__.platform list_renderers = Map.get(socket.__mob__, :list_renderers, %{}) @@ -302,7 +343,13 @@ defmodule Mob.Screen.Server do |> Mob.Component.expand(self(), platform) Mob.ComponentRegistry.reconcile(self(), active_component_keys) - Mob.Sender.render(state.ref, tree, platform, state.nif, transition) + + if activation_token && function_exported?(Mob.Sender, :render, 6) do + Mob.Sender.render(state.ref, tree, platform, state.nif, transition, activation_token) + else + Mob.Sender.render(state.ref, tree, platform, state.nif, transition) + end + if mode == :sync, do: Mob.Sender.sync(:infinity) Mob.Socket.put_root_view(socket, :json_tree) diff --git a/lib/mob/screen_case.ex b/lib/mob/screen_case.ex index 8b0d5b7..03d707f 100644 --- a/lib/mob/screen_case.ex +++ b/lib/mob/screen_case.ex @@ -206,9 +206,12 @@ defmodule Mob.ScreenCase do * in-BEAM: the destination of the nav action recorded on the socket by `Mob.Socket.push_screen/3` and friends. Destination-bearing actions (`{:push, Dest, _}`, `{:reset, Dest, _}`, `{:reset, Dest, _, _}`, + `{:reset, Dest, _, _, :all}`, `{:pop_to, Dest}`) return `Dest`; destinationless ones (`{:pop}`, `{:pop_to_root}`, - `{:switch_tab, tab}`) return the raw action unchanged. + `{:switch_tab, tab}`, `{:switch_tab, tab, transition}`, + `{:switch_tab, tab, transition, mount_params}`) return the raw + action unchanged. * on device: the screen currently showing (`Mob.Test.screen/1`). """ @spec navigated_to(View.t()) :: term() | nil @@ -217,6 +220,7 @@ defmodule Mob.ScreenCase do {:push, dest, _params} -> dest {:reset, dest, _params} -> dest {:reset, dest, _params, _transition} -> dest + {:reset, dest, _params, _transition, :all} -> dest {:pop_to, dest} -> dest other -> other end diff --git a/lib/mob/screen_state.ex b/lib/mob/screen_state.ex index 3fb2596..0fe7cad 100644 --- a/lib/mob/screen_state.ex +++ b/lib/mob/screen_state.ex @@ -36,6 +36,7 @@ defmodule Mob.ScreenState do VALUES (?, ?, ?, ?) """ @delete_sql "DELETE FROM mob_screen_states WHERE key = ?" + @delete_all_sql "DELETE FROM mob_screen_states" @doc """ Persist the current assigns of `socket` for `module`. @@ -96,6 +97,23 @@ defmodule Mob.ScreenState do :ok end + @doc """ + Delete every persisted screen snapshot. + + This is the final sweep for an explicit navigation session boundary such as + `Mob.Socket.reset_to/4` with `scope: :all`. Per-screen deletion uses the live + socket so custom `screen_key/1` callbacks are respected; this sweep also + removes a snapshot written by a screen that exited during that preparation. + """ + @spec delete_all() :: :ok + def delete_all do + with repo when not is_nil(repo) <- repo() do + apply(repo, :query!, [@delete_all_sql, []]) + end + + :ok + end + # ── Private ──────────────────────────────────────────────────────────────── defp repo, do: Application.get_env(:mob, :repo) diff --git a/lib/mob/sender.ex b/lib/mob/sender.ex index ab298da..6f2319a 100644 --- a/lib/mob/sender.ex +++ b/lib/mob/sender.ex @@ -50,10 +50,11 @@ defmodule Mob.Sender do `sync/1` that merely replied would return before the frame was committed. Mailbox order is the wrong tool here, and it looks like the right one. - `Mob.Router` uses `activate/2` before asking a screen to paint. Activation is - synchronous and carries the navigation transition as a one-shot reservation, - so an ordinary repaint from the newly active screen cannot race ahead and - erase the animation. Whichever tree arrives first consumes the reservation. + `Mob.Router` uses an activation-frame token before asking a screen to paint. + Activation is synchronous and carries the navigation transition; only the + router-requested paint bearing that token may cross the boundary. A timer + repaint that began while the screen was parked is therefore dropped even if + its cast reaches the sender after activation. `Mob.Router` uses `sync/1` on its `handle_call` paths to keep the guarantee `Mob.Test` documents for the synchronous navigation helpers. Note the ordering @@ -73,7 +74,7 @@ defmodule Mob.Sender do """ @type screen_ref :: reference() | atom() - defstruct active: nil, pending: %{}, reserved_transition: nil + defstruct active: nil, pending: %{}, reserved_transition: nil, activation_gate: nil @doc "Start the sender. Named, so there is exactly one." @spec start_link(keyword()) :: GenServer.on_start() @@ -134,6 +135,14 @@ defmodule Mob.Sender do if running?(), do: GenServer.call(__MODULE__, {:activate, ref, transition}), else: :ok end + @doc false + @spec activate_frame(screen_ref(), atom()) :: reference() | nil + def activate_frame(ref, transition) do + if running?() do + GenServer.call(__MODULE__, {:activate_frame, ref, transition}) + end + end + @doc """ Queue `tree` for commit on behalf of screen `ref`. @@ -145,6 +154,15 @@ defmodule Mob.Sender do GenServer.cast(__MODULE__, {:render, ref, tree, platform, nif, transition}) end + @doc false + @spec render(screen_ref(), map(), atom(), module() | atom(), atom(), reference() | nil) :: :ok + def render(ref, tree, platform, nif, transition, activation_token) do + GenServer.cast( + __MODULE__, + {:render, ref, tree, platform, nif, transition, activation_token} + ) + end + @doc """ Block until every render queued before this call has been committed or dropped. @@ -168,7 +186,27 @@ defmodule Mob.Sender do @impl GenServer def handle_call({:activate, ref, transition}, _from, state) do reserved_transition = if transition == :none, do: nil, else: {ref, transition} - {:reply, :ok, %{state | active: ref, reserved_transition: reserved_transition}} + + # An inactive screen may have queued a repaint just before activation. + # That tree predates the navigation boundary and must not become the first + # frame of the newly active screen; the router requests a fresh paint next. + pending = Map.delete(state.pending, ref) + + {:reply, :ok, + %{state | active: ref, pending: pending, reserved_transition: reserved_transition}} + end + + def handle_call({:activate_frame, ref, transition}, _from, state) do + token = make_ref() + + state = + state + |> Map.put(:active, ref) + |> Map.put(:pending, Map.delete(state.pending, ref)) + |> Map.put(:reserved_transition, nil) + |> Map.put(:activation_gate, {ref, token, transition}) + + {:reply, token, state} end def handle_call(:sync, _from, state) do @@ -185,6 +223,27 @@ defmodule Mob.Sender do end def handle_cast({:render, ref, tree, platform, nif, transition}, state) do + handle_cast({:render, ref, tree, platform, nif, transition, nil}, state) + end + + def handle_cast( + {:render, ref, tree, platform, nif, transition, activation_token}, + %{activation_gate: {ref, expected_token, reserved}} = state + ) do + if activation_token == expected_token do + transition = if transition == :none, do: reserved, else: transition + pending = Map.put(state.pending, ref, {tree, platform, nif, transition}) + send(self(), :flush) + {:noreply, %{state | pending: pending, activation_gate: nil}} + else + # This render began before the router activated the screen. The router's + # tokened paint follows it from the same screen process, so dropping it + # prevents a stale target frame from consuming the navigation boundary. + {:noreply, state} + end + end + + def handle_cast({:render, ref, tree, platform, nif, transition, _activation_token}, state) do # Overwrite rather than append: a newer tree for the same screen supersedes # the one waiting, which is the whole point of queueing here. The transition # is the exception — it describes the navigation animation for this frame, diff --git a/lib/mob/socket.ex b/lib/mob/socket.ex index f1a8b40..ccd442c 100644 --- a/lib/mob/socket.ex +++ b/lib/mob/socket.ex @@ -173,21 +173,30 @@ defmodule Mob.Socket do end @doc """ - Replace the entire navigation stack with a single new screen. + Replace the current navigation stack with a single new screen. Used for auth transitions (post-login → home with no back button to login). Pass `transition: :push` or `transition: :pop` when the reset still represents directional movement, such as switching between custom tabs. The default, - `:reset`, cross-fades. + `:reset`, cross-fades. Pass `scope: :all` for an auth boundary that must also + discard every parked tab stack. The default `scope: :stack` preserves the + established current-stack-only behavior. Raises `ArgumentError` on any other transition — including `:none`, which would replace the stack while telling the platform no navigation happened, leaving the incoming screen wearing the outgoing one's view identities. """ - @spec reset_to(t(), atom() | module(), map(), [{:transition, transition()}]) :: t() + @spec reset_to(t(), atom() | module(), map(), [ + {:transition, transition()} | {:scope, :stack | :all} + ]) :: t() def reset_to(socket, dest, params \\ %{}, opts \\ []) do - transition = validate_transition!(Keyword.get(opts, :transition, :reset)) - put_mob(socket, :nav_action, {:reset, dest, params, transition}) + transition = validate_transition!(Keyword.get(opts, :transition, :reset), "reset_to/4") + scope = validate_reset_scope!(Keyword.get(opts, :scope, :stack)) + + case scope do + :stack -> put_mob(socket, :nav_action, {:reset, dest, params, transition}) + :all -> put_mob(socket, :nav_action, {:reset, dest, params, transition, :all}) + end end @valid_transitions [:push, :pop, :reset] @@ -207,19 +216,61 @@ defmodule Mob.Socket do # tree into the outgoing screen's view identities — a `TextField` at the same # position inherits the old screen's text and focus, and scroll offsets # survive a stack that no longer exists. - defp validate_transition!(transition) when transition in @valid_transitions, do: transition + defp validate_transition!(transition, _function) when transition in @valid_transitions, + do: transition - defp validate_transition!(other) do + defp validate_transition!(other, function) do raise ArgumentError, - "Mob.Socket.reset_to/4: invalid transition #{inspect(other)}. " <> + "Mob.Socket.#{function}: invalid transition #{inspect(other)}. " <> "Expected one of #{inspect(@valid_transitions)}." end + defp validate_reset_scope!(scope) when scope in [:stack, :all], do: scope + + defp validate_reset_scope!(other) do + raise ArgumentError, + "Mob.Socket.reset_to/4: invalid scope #{inspect(other)}. " <> + "Expected one of [:stack, :all]." + end + @doc """ Switch to the named tab in a tab_bar or drawer layout. + + By default, switching tabs has no animation. Pass `transition: :push`, + `transition: :pop`, or `transition: :reset` when the tab order implies + directional movement or a cross-fade. `mount_params: %{...}` supplies the + params for the target root's first mount. A previously mounted stack ignores + later mount params and restores its existing screen state. """ @spec switch_tab(t(), atom()) :: t() def switch_tab(socket, tab) when is_atom(tab) do put_mob(socket, :nav_action, {:switch_tab, tab}) end + + @spec switch_tab(t(), atom(), [ + {:transition, transition()} | {:mount_params, map()} + ]) :: t() + def switch_tab(socket, tab, opts) when is_atom(tab) and is_list(opts) do + transition = + case Keyword.fetch(opts, :transition) do + {:ok, value} -> validate_transition!(value, "switch_tab/3") + :error -> :none + end + + case Keyword.fetch(opts, :mount_params) do + {:ok, mount_params} when is_map(mount_params) -> + put_mob(socket, :nav_action, {:switch_tab, tab, transition, mount_params}) + + {:ok, mount_params} -> + raise ArgumentError, + "Mob.Socket.switch_tab/3: invalid mount_params #{inspect(mount_params)}. " <> + "Expected a map." + + :error when transition == :none -> + switch_tab(socket, tab) + + :error -> + put_mob(socket, :nav_action, {:switch_tab, tab, transition}) + end + end end diff --git a/lib/mob/test.ex b/lib/mob/test.ex index a28cf74..3ef4ba0 100644 --- a/lib/mob/test.ex +++ b/lib/mob/test.ex @@ -357,21 +357,78 @@ defmodule Mob.Test do def pop_to_root(node), do: nav(node, {:pop_to_root}) @doc """ - Replace the entire navigation stack with a new root screen. Synchronous. + Replace the current navigation stack with a new root screen. Synchronous. Use this to simulate auth transitions (e.g. login → home with no back button). Pass `transition: :push` or `transition: :pop` to drive a directional reset, - matching `Mob.Socket.reset_to/4`. + matching `Mob.Socket.reset_to/4`. Pass `scope: :all` to discard every parked + stack as well as the active one. """ - @spec reset_to(node(), module() | atom(), map(), [{:transition, atom()}]) :: :ok + @spec reset_to(node(), module() | atom(), map(), [ + {:transition, atom()} | {:scope, :stack | :all} + ]) :: :ok def reset_to(node, dest, params \\ %{}, opts \\ []) do - case Keyword.get(opts, :transition) do - nil -> nav(node, {:reset, dest, params}) - transition -> nav(node, {:reset, dest, params, transition}) + transition = Keyword.get(opts, :transition) + + case Keyword.get(opts, :scope, :stack) do + :stack when is_nil(transition) -> + nav(node, {:reset, dest, params}) + + :stack -> + nav(node, {:reset, dest, params, transition}) + + :all -> + nav(node, {:reset, dest, params, transition || :reset, :all}) + + other -> + raise ArgumentError, + "Mob.Test.reset_to/4: invalid scope #{Kernel.inspect(other)}. " <> + "Expected one of [:stack, :all]." + end + end + + @doc """ + Switch to a named tab stack. Synchronous. + + Pass `transition: :push`, `transition: :pop`, or `transition: :reset` to + exercise the same directional animation as `Mob.Socket.switch_tab/3`. + `mount_params: %{...}` is passed to a target root only on its first mount. + """ + @spec switch_tab(node(), atom(), [{:transition, atom()} | {:mount_params, map()}]) :: :ok + def switch_tab(node, tab, opts \\ []) do + transition = + case Keyword.fetch(opts, :transition) do + :error -> :none + {:ok, value} -> validate_tab_transition!(value) + end + + case Keyword.fetch(opts, :mount_params) do + {:ok, mount_params} when is_map(mount_params) -> + nav(node, {:switch_tab, tab, transition, mount_params}) + + {:ok, mount_params} -> + raise ArgumentError, + "Mob.Test.switch_tab/3: invalid mount_params #{Kernel.inspect(mount_params)}. " <> + "Expected a map." + + :error when transition == :none -> + nav(node, {:switch_tab, tab}) + + :error -> + nav(node, {:switch_tab, tab, transition}) end end + defp validate_tab_transition!(transition) when transition in [:push, :pop, :reset], + do: transition + + defp validate_tab_transition!(transition) do + raise ArgumentError, + "Mob.Test.switch_tab/3: invalid transition #{Kernel.inspect(transition)}. " <> + "Expected one of [:push, :pop, :reset]." + end + # ── Lists ───────────────────────────────────────────────────────────────────── @doc """ diff --git a/test/mob/component_server_test.exs b/test/mob/component_server_test.exs index 8d8a754..096e89a 100644 --- a/test/mob/component_server_test.exs +++ b/test/mob/component_server_test.exs @@ -29,6 +29,47 @@ defmodule Mob.ComponentServerTest do end end + defmodule ComponentScreen do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + + def render(_assigns) do + Mob.UI.native_view(Mob.ComponentServerTest.Recorder, id: :owned) + end + end + + defmodule BlockingTermination do + use Mob.Component + + def mount(%{observer: observer}, socket), + do: {:ok, Mob.Socket.assign(socket, :observer, observer)} + + def render(_assigns), do: %{} + + def terminate(reason, socket) do + send(socket.assigns.observer, {:component_terminating, self(), reason}) + + receive do + :release -> :ok + end + end + end + + defmodule RaisingTermination do + use Mob.Component + + def mount(%{observer: observer}, socket), + do: {:ok, Mob.Socket.assign(socket, :observer, observer)} + + def render(_assigns), do: %{} + + def terminate(reason, socket) do + send(socket.assigns.observer, {:component_terminating, self(), reason}) + raise "hostile terminate callback" + end + end + setup do # Mob.ComponentRegistry registers under a fixed global name. Another # async test file (component_test.exs) may have already started it — @@ -152,6 +193,13 @@ defmodule Mob.ComponentServerTest do def calls, do: Agent.get(__MODULE__, & &1.calls) + def platform, do: :ios + def safe_area, do: {0.0, 0.0, 0.0, 0.0} + def clear_taps, do: :ok + def register_tap(_tag), do: 0 + def set_transition(_transition), do: :ok + def set_root(_json), do: :ok + def reset, do: Agent.update(__MODULE__, fn _ -> %{calls: [], next: 0, freed: [], result: :allocate} end) @@ -260,6 +308,122 @@ defmodule Mob.ComponentServerTest do assert {:deregister_component, [0]} in MockNIF.calls() end + test "a component terminates and releases its handle when its screen dies" do + screen_pid = spawn(fn -> Process.sleep(:infinity) end) + + {:ok, pid} = + Mob.ComponentServer.start( + module: Recorder, + id: :owned, + screen_pid: screen_pid, + props: %{}, + platform: :ios, + nif: MockNIF + ) + + assert Mob.ComponentServer.get_handle(pid) == 0 + assert {:ok, ^pid} = Mob.ComponentRegistry.lookup(screen_pid, :owned, Recorder) + + ref = Process.monitor(pid) + Process.exit(screen_pid, :kill) + + assert_receive {:DOWN, ^ref, :process, ^pid, :killed}, 500 + assert {:error, :not_found} = Mob.ComponentRegistry.lookup(screen_pid, :owned, Recorder) + assert {:deregister_component, [0]} in MockNIF.calls() + end + + test "a blocking component terminate callback cannot hold up its owner" do + screen_pid = spawn(fn -> Process.sleep(:infinity) end) + + {:ok, component_pid} = + Mob.ComponentServer.start( + module: BlockingTermination, + id: :blocking, + screen_pid: screen_pid, + props: %{observer: self()}, + platform: :ios, + nif: MockNIF + ) + + screen_ref = Process.monitor(screen_pid) + Process.exit(screen_pid, :kill) + + assert_receive {:DOWN, ^screen_ref, :process, ^screen_pid, :killed}, 100 + assert_receive {:component_terminating, ^component_pid, :killed}, 500 + + assert {:error, :not_found} = + Mob.ComponentRegistry.lookup(screen_pid, :blocking, BlockingTermination) + + assert {:deregister_component, [0]} in MockNIF.calls() + Process.exit(component_pid, :kill) + end + + test "a raising component terminate callback cannot affect its owner or leak its handle" do + screen_pid = spawn(fn -> Process.sleep(:infinity) end) + + {:ok, component_pid} = + Mob.ComponentServer.start( + module: RaisingTermination, + id: :raising, + screen_pid: screen_pid, + props: %{observer: self()}, + platform: :ios, + nif: MockNIF + ) + + screen_ref = Process.monitor(screen_pid) + component_ref = Process.monitor(component_pid) + Process.exit(screen_pid, :kill) + + assert_receive {:DOWN, ^screen_ref, :process, ^screen_pid, :killed}, 100 + assert_receive {:component_terminating, ^component_pid, :killed}, 500 + assert_receive {:DOWN, ^component_ref, :process, ^component_pid, _reason}, 500 + + assert {:error, :not_found} = + Mob.ComponentRegistry.lookup(screen_pid, :raising, RaisingTermination) + + assert {:deregister_component, [0]} in MockNIF.calls() + end + + test "an old component cannot deregister its replacement" do + screen_pid = self() + + {:ok, old_pid} = + Mob.ComponentServer.start( + module: Recorder, + id: :replaced, + screen_pid: screen_pid, + props: %{}, + platform: :ios, + nif: MockNIF + ) + + :ok = :sys.suspend(old_pid) + Mob.ComponentRegistry.reconcile(screen_pid, MapSet.new()) + + {:ok, replacement_pid} = + Mob.ComponentServer.start( + module: Recorder, + id: :replaced, + screen_pid: screen_pid, + props: %{}, + platform: :ios, + nif: MockNIF + ) + + assert {:ok, ^replacement_pid} = + Mob.ComponentRegistry.lookup(screen_pid, :replaced, Recorder) + + old_ref = Process.monitor(old_pid) + :ok = :sys.resume(old_pid) + assert_receive {:DOWN, ^old_ref, :process, ^old_pid, :shutdown}, 500 + + assert {:ok, ^replacement_pid} = + Mob.ComponentRegistry.lookup(screen_pid, :replaced, Recorder) + + assert Process.alive?(replacement_pid) + end + test "pool exhaustion fails only that component — process survives with the sentinel handle" do MockNIF.set_result(:exhausted) @@ -336,11 +500,8 @@ defmodule Mob.ComponentServerTest do end test "register/reconcile/register cycling does not leak slots (MOB-100 root cause)" do - # Exercises the REAL production stop path: Mob.ComponentRegistry.reconcile/2 - # calls Process.exit(pid, :shutdown) directly (see lib/mob/component_registry.ex), - # not GenServer.stop. Before trap_exit was added to init/1, that signal - # terminated the process without ever running terminate/2 — so every - # screen navigation leaked a slot, independent of the slot-0 sentinel bug. + # Exercises the production reconciliation path used both after a render + # and while a screen terminates. screen_pid = self() for i <- 1..5 do @@ -362,8 +523,8 @@ defmodule Mob.ComponentServerTest do Mob.ComponentRegistry.reconcile(screen_pid, MapSet.new()) - # reconcile/2 exits the process; wait for it to actually be gone - # before the next cycle re-registers under the same {screen_pid, id}. + # Keep the monitor assertion so a future asynchronous implementation + # cannot make the next cycle race the old registration. ref = Process.monitor(pid) assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 500 end diff --git a/test/mob/component_test.exs b/test/mob/component_test.exs index 9a9dce9..78d23d7 100644 --- a/test/mob/component_test.exs +++ b/test/mob/component_test.exs @@ -153,7 +153,7 @@ defmodule Mob.ComponentTest do test "deregister removes the entry" do screen = self() Mob.ComponentRegistry.register(screen, :temp, CounterComponent, self()) - Mob.ComponentRegistry.deregister(screen, :temp, CounterComponent) + Mob.ComponentRegistry.deregister(screen, :temp, CounterComponent, self()) assert {:error, :not_found} = Mob.ComponentRegistry.lookup(screen, :temp, CounterComponent) diff --git a/test/mob/nav/multi_stack_test.exs b/test/mob/nav/multi_stack_test.exs index d30ba4c..510dd33 100644 --- a/test/mob/nav/multi_stack_test.exs +++ b/test/mob/nav/multi_stack_test.exs @@ -28,6 +28,21 @@ defmodule Mob.Nav.MultiStackTest do def handle_event("to_settings", _, socket), do: {:noreply, Mob.Socket.switch_tab(socket, :settings)} + def handle_event("to_settings_with_params", _, socket), + do: + {:noreply, + Mob.Socket.switch_tab(socket, :settings, mount_params: %{source: :first_visit})} + + def handle_event("to_settings_with_other_params", _, socket), + do: + {:noreply, + Mob.Socket.switch_tab(socket, :settings, mount_params: %{source: :later_visit})} + + def handle_event("to_broken", _, socket), + do: + {:noreply, + Mob.Socket.switch_tab(socket, :broken, mount_params: %{source: :never_mounted})} + def handle_event("to_home", _, socket), do: {:noreply, Mob.Socket.switch_tab(socket, :home)} @@ -54,7 +69,12 @@ defmodule Mob.Nav.MultiStackTest do @detail Mob.Nav.MultiStackTest.SettingsDetailScreen - def mount(_params, _session, socket), do: {:ok, Mob.Socket.assign(socket, :theme, :light)} + def mount(params, _session, socket) do + {:ok, + socket + |> Mob.Socket.assign(:theme, :light) + |> Mob.Socket.assign(:mount_source, Map.get(params, :source))} + end def render(assigns), do: %{type: :text, props: %{text: "settings #{assigns.theme}"}, children: []} @@ -69,6 +89,13 @@ defmodule Mob.Nav.MultiStackTest do do: {:noreply, Mob.Socket.switch_tab(socket, :home)} end + defmodule BrokenScreen do + use Mob.Screen + + def mount(_params, _session, _socket), do: {:error, :requested_mount_failure} + def render(_assigns), do: %{type: :text, props: %{text: "broken"}, children: []} + end + defmodule SettingsDetailScreen do use Mob.Screen @@ -87,11 +114,13 @@ defmodule Mob.Nav.MultiStackTest do @home Mob.Nav.MultiStackTest.HomeScreen @settings Mob.Nav.MultiStackTest.SettingsScreen + @broken Mob.Nav.MultiStackTest.BrokenScreen def navigation(_) do tab_bar([ stack(:home, root: @home, title: "Home"), - stack(:settings, root: @settings, title: "Settings") + stack(:settings, root: @settings, title: "Settings"), + stack(:broken, root: @broken, title: "Broken") ]) end end @@ -126,6 +155,31 @@ defmodule Mob.Nav.MultiStackTest do assert Mob.Screen.get_current_module(screen) == SettingsScreen end + test "first switch passes mount params to the target root", %{screen: screen} do + Mob.Screen.dispatch(screen, "to_settings_with_params", %{}) + + assert Mob.Screen.get_current_module(screen) == SettingsScreen + assert Mob.Screen.get_socket(screen).assigns.mount_source == :first_visit + end + + test "restoring a stack preserves its original mount params", %{screen: screen} do + Mob.Screen.dispatch(screen, "to_settings_with_params", %{}) + Mob.Screen.dispatch(screen, "to_home", %{}) + Mob.Screen.dispatch(screen, "to_settings_with_other_params", %{}) + + assert Mob.Screen.get_current_module(screen) == SettingsScreen + assert Mob.Screen.get_socket(screen).assigns.mount_source == :first_visit + end + + test "a failed first mount leaves the current navigation intact", %{screen: screen} do + Mob.Screen.dispatch(screen, "bump", %{}) + Mob.Screen.dispatch(screen, "to_broken", %{}) + + assert Mob.Screen.get_current_module(screen) == HomeScreen + assert Mob.Screen.get_socket(screen).assigns.count == 1 + assert Mob.Screen.get_nav_history(screen) == [] + 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", %{}) diff --git a/test/mob/nav/reset_all_test.exs b/test/mob/nav/reset_all_test.exs new file mode 100644 index 0000000..be2d879 --- /dev/null +++ b/test/mob/nav/reset_all_test.exs @@ -0,0 +1,312 @@ +defmodule Mob.Nav.ResetAllTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + + defmodule TestRepo do + use Ecto.Repo, otp_app: :mob_reset_all_test, adapter: Ecto.Adapters.SQLite3 + end + + @create_table """ + CREATE TABLE IF NOT EXISTS mob_screen_states ( + key TEXT PRIMARY KEY NOT NULL, + vsn INTEGER NOT NULL DEFAULT 0, + data BLOB NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + + defmodule LoginScreen do + use Mob.Screen + + def mount(params, _session, socket), + do: {:ok, Mob.Socket.assign(socket, :source, Map.get(params, :source))} + + def render(assigns), + do: %{type: :text, props: %{text: "login #{assigns.source}"}, children: []} + + def handle_event("home_a", _, socket) do + {:noreply, Mob.Socket.switch_tab(socket, :home, mount_params: %{session: :a})} + end + + def handle_event("home_b", _, socket) do + {:noreply, Mob.Socket.switch_tab(socket, :home, mount_params: %{session: :b})} + end + + def handle_event("persistent_a", _, socket) do + {:noreply, Mob.Socket.switch_tab(socket, :persistent, mount_params: %{session: :a})} + end + end + + defmodule HomeScreen do + use Mob.Screen + + @detail Mob.Nav.ResetAllTest.HomeDetailScreen + + def mount(params, _session, socket), + do: {:ok, Mob.Socket.assign(socket, :session, Map.fetch!(params, :session))} + + def render(assigns), + do: %{type: :text, props: %{text: "home #{assigns.session}"}, children: []} + + def handle_event("detail", _, socket), + do: {:noreply, Mob.Socket.push_screen(socket, @detail)} + + def handle_event("settings", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :settings, mount_params: %{session: :a})} + end + + defmodule PersistentHomeScreen do + use Mob.Screen, vsn: 1 + + def mount(params, _session, socket), + do: {:ok, Mob.Socket.assign(socket, :session, Map.fetch!(params, :session))} + + def render(assigns), + do: %{type: :text, props: %{text: "persistent #{assigns.session}"}, children: []} + + def handle_event("settings", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :settings, mount_params: %{session: :a})} + end + + defmodule HomeDetailScreen do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + def render(_assigns), do: %{type: :text, props: %{text: "home detail"}, children: []} + + def handle_event("settings", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :settings, mount_params: %{session: :a})} + end + + defmodule SettingsScreen do + use Mob.Screen + + @detail Mob.Nav.ResetAllTest.SettingsDetailScreen + + def mount(params, _session, socket), + do: {:ok, Mob.Socket.assign(socket, :session, Map.fetch!(params, :session))} + + def render(assigns), + do: %{type: :text, props: %{text: "settings #{assigns.session}"}, children: []} + + def handle_event("detail", _, socket), + do: {:noreply, Mob.Socket.push_screen(socket, @detail)} + + def handle_event("reset_home", _, socket) do + {:noreply, Mob.Socket.reset_to(socket, HomeScreen, %{session: :b}, scope: :all)} + end + + def handle_event("reset_persistent", _, socket) do + {:noreply, Mob.Socket.reset_to(socket, PersistentHomeScreen, %{session: :b}, scope: :all)} + end + end + + defmodule SettingsDetailScreen do + use Mob.Screen + + @login Mob.Nav.ResetAllTest.LoginScreen + @broken Mob.Nav.ResetAllTest.BrokenScreen + + def mount(_params, _session, socket), do: {:ok, socket} + def render(_assigns), do: %{type: :text, props: %{text: "settings detail"}, children: []} + + def handle_event("logout", _, socket) do + {:noreply, Mob.Socket.reset_to(socket, @login, %{source: :logout}, scope: :all)} + end + + def handle_event("broken", _, socket) do + {:noreply, Mob.Socket.reset_to(socket, @broken, %{}, scope: :all)} + end + end + + defmodule BrokenScreen do + use Mob.Screen + def mount(_params, _session, _socket), do: {:error, :broken} + def render(_assigns), do: %{type: :text, props: %{text: "broken"}, children: []} + end + + defmodule TabApp do + @behaviour Mob.App + import Mob.App + + @home Mob.Nav.ResetAllTest.HomeScreen + @persistent Mob.Nav.ResetAllTest.PersistentHomeScreen + @settings Mob.Nav.ResetAllTest.SettingsScreen + + def navigation(_) do + tab_bar([ + stack(:home, root: @home, title: "Home"), + stack(:persistent, root: @persistent, title: "Persistent"), + stack(:settings, root: @settings, title: "Settings") + ]) + end + end + + defmodule OldNavWithoutReset do + end + + defmodule OldScreenServerWithoutDiscard do + end + + defmodule OldScreenStateWithoutDeleteAll do + end + + setup do + case Process.whereis(Mob.Nav.Registry) do + nil -> :ok + pid -> stop_safely(pid) + end + + {:ok, registry} = Mob.Nav.Registry.start_link(TabApp) + on_exit(fn -> stop_safely(registry) end) + + {:ok, router} = Mob.Screen.start_link(LoginScreen, %{source: :initial}) + on_exit(fn -> stop_safely(router) end) + + %{router: router} + end + + test "all-stack reset discards an orphan, both tab histories, and parked screens", %{ + router: router + } do + Mob.Screen.dispatch(router, "home_a", %{}) + Mob.Screen.dispatch(router, "detail", %{}) + Mob.Screen.dispatch(router, "settings", %{}) + Mob.Screen.dispatch(router, "detail", %{}) + + before_reset = :sys.get_state(router) + old_pids = Map.keys(before_reset.screens) + assert length(old_pids) == 5 + + Mob.Screen.dispatch(router, "logout", %{}) + + state = :sys.get_state(router) + assert state.current.module == LoginScreen + assert state.current.params == %{source: :logout} + assert state.nav.active == :__mob_root__ + assert state.nav.history == [] + assert state.nav.parked == %{} + assert Map.keys(state.screens) == [state.current.pid] + assert Enum.all?(old_pids, &(not Process.alive?(&1))) + + Mob.Screen.dispatch(router, "home_b", %{}) + + assert Mob.Screen.get_current_module(router) == HomeScreen + assert Mob.Screen.get_socket(router).assigns.session == :b + refute Mob.Router.get_screen_pid(router) in old_pids + end + + test "resetting to a declared root selects its stack", %{router: router} do + Mob.Screen.dispatch(router, "home_a", %{}) + Mob.Screen.dispatch(router, "settings", %{}) + Mob.Screen.dispatch(router, "reset_home", %{}) + + state = :sys.get_state(router) + assert state.current.module == HomeScreen + assert state.current.params == %{session: :b} + assert state.nav.active == :home + assert state.nav.history == [] + assert state.nav.parked == %{} + assert state.nav.order == [:home, :persistent, :settings] + end + + test "a failed replacement mount leaves every stack intact", %{router: router} do + Mob.Screen.dispatch(router, "home_a", %{}) + Mob.Screen.dispatch(router, "detail", %{}) + Mob.Screen.dispatch(router, "settings", %{}) + Mob.Screen.dispatch(router, "detail", %{}) + + before_reset = :sys.get_state(router) + + capture_log(fn -> Mob.Screen.dispatch(router, "broken", %{}) end) + + after_reset = :sys.get_state(router) + assert after_reset.current == before_reset.current + assert after_reset.nav == before_reset.nav + assert after_reset.screens == before_reset.screens + assert Enum.all?(Map.keys(before_reset.screens), &Process.alive?/1) + end + + test "an all-stack reset is a persistence boundary between users", %{router: router} do + db = System.tmp_dir!() <> "/mob_reset_all_#{System.unique_integer([:positive])}.db" + Application.put_env(:mob_reset_all_test, TestRepo, database: db, pool_size: 1) + Application.put_env(:mob, :repo, TestRepo) + start_supervised!(TestRepo) + TestRepo.query!(@create_table, []) + + on_exit(fn -> + Application.delete_env(:mob, :repo) + File.rm(db) + end) + + Mob.Screen.dispatch(router, "persistent_a", %{}) + user_a_socket = Mob.Screen.get_socket(router) + assert user_a_socket.assigns.session == :a + Mob.ScreenState.dump(PersistentHomeScreen, user_a_socket) + + Mob.Screen.dispatch(router, "settings", %{}) + Mob.Screen.dispatch(router, "reset_persistent", %{}) + + user_b_socket = Mob.Screen.get_socket(router) + assert user_b_socket.assigns.session == :b + assert :not_found = Mob.ScreenState.load(PersistentHomeScreen, user_b_socket) + assert %{rows: [[0]]} = TestRepo.query!("SELECT count(*) FROM mob_screen_states", []) + + # Keep this test's fixture from writing after its Repo is torn down. The + # old user-A screen was already stopped by reset; this prepares only the + # fresh user-B screen for the test process's own shutdown. + current = Mob.Router.get_screen_pid(router) + assert :ok = Mob.Screen.Server.discard_persisted_state(current) + end + + test "new Router resets safely while an old Mob.Nav module is loaded" do + home = %{module: HomeScreen, pid: self()} + settings = %{module: SettingsScreen, pid: self()} + + nav = %Mob.Nav{ + active: :settings, + history: [settings], + parked: %{home: %{current: home, history: [home]}}, + order: [:home, :settings], + roots: %{home: HomeScreen, settings: SettingsScreen} + } + + reset = Mob.Router.reset_navigation(nav, HomeScreen, OldNavWithoutReset) + + assert reset.active == :home + assert reset.history == [] + assert reset.parked == %{} + assert reset.order == [:home, :settings] + assert reset.roots == nav.roots + end + + test "new Router recognises an old Screen.Server cannot safely reset all stacks" do + refute Mob.Router.reset_all_supported?(OldScreenServerWithoutDiscard) + assert Mob.Router.reset_all_supported?(Mob.Screen.Server) + end + + test "new Router recognises an old ScreenState cannot safely reset all stacks" do + refute Mob.Router.reset_all_supported?( + Mob.Screen.Server, + OldScreenStateWithoutDeleteAll + ) + + assert Mob.Router.reset_all_supported?(Mob.Screen.Server, Mob.ScreenState) + end + + test "capability detection loads an available ScreenState on a cold path" do + :code.purge(Mob.ScreenState) + :code.delete(Mob.ScreenState) + assert :code.is_loaded(Mob.ScreenState) == false + + assert Mob.Router.reset_all_supported?(Mob.Screen.Server, Mob.ScreenState) + refute :code.is_loaded(Mob.ScreenState) == false + end + + defp stop_safely(pid) do + GenServer.stop(pid) + catch + :exit, _ -> :ok + end +end diff --git a/test/mob/nav/tab_transition_test.exs b/test/mob/nav/tab_transition_test.exs new file mode 100644 index 0000000..20f302d --- /dev/null +++ b/test/mob/nav/tab_transition_test.exs @@ -0,0 +1,183 @@ +defmodule Mob.Nav.TabTransitionTest do + @moduledoc """ + Directional tab transitions from the socket action through the native NIF. + + These run in `:render`: action-shape tests alone cannot prove that the + selected transition survives router switching and reaches native paint. + """ + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + + defmodule CrashControl do + def start, do: Agent.start(fn -> false end, name: __MODULE__) + def crash_next, do: Agent.update(__MODULE__, fn _ -> true end) + + def take do + Agent.get_and_update(__MODULE__, fn crash? -> {crash?, false} end) + end + end + + defmodule HomeScreen do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + def render(_assigns), do: %{type: :text, props: %{text: "home"}, children: []} + + def handle_event("settings_default", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :settings)} + + def handle_event("settings_push", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :settings, transition: :push)} + + def handle_event("home_push", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :home, transition: :push)} + + def handle_event("noop", _, socket), do: {:noreply, socket} + end + + defmodule SettingsScreen do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + + def render(_assigns) do + if CrashControl.take(), do: raise("requested render crash") + %{type: :text, props: %{text: "settings"}, children: []} + end + + def handle_event("home_pop", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :home, transition: :pop)} + + def handle_event("settings_pop", _, socket), + do: {:noreply, Mob.Socket.switch_tab(socket, :settings, transition: :pop)} + end + + defmodule TabApp do + @behaviour Mob.App + import Mob.App + + @home Mob.Nav.TabTransitionTest.HomeScreen + @settings Mob.Nav.TabTransitionTest.SettingsScreen + + def navigation(_) do + tab_bar([ + stack(:home, root: @home, title: "Home"), + stack(:settings, root: @settings, title: "Settings") + ]) + end + end + + defmodule RecordingNif do + def start, do: Agent.start(fn -> [] end, name: __MODULE__) + def transitions, do: __MODULE__ |> Agent.get(& &1) |> Enum.reverse() + def reset, do: Agent.update(__MODULE__, fn _ -> [] end) + + def platform, do: :android + def safe_area, do: {0.0, 0.0, 0.0, 0.0} + def take_launch_notification, do: :none + def clear_taps, do: :ok + def register_tap(_), do: 0 + def set_root(_json), do: :ok + + def set_transition(transition) do + Agent.update(__MODULE__, &[transition | &1]) + :ok + end + end + + setup do + for name <- [Mob.Nav.Registry, Mob.Sender, Mob.Listener, Mob.ComponentRegistry], + pid = Process.whereis(name) do + stop_safely(pid) + end + + {:ok, components} = Mob.ComponentRegistry.start_link() + {:ok, crash_control} = CrashControl.start() + {:ok, recording} = RecordingNif.start() + {:ok, registry} = Mob.Nav.Registry.start_link(TabApp) + {:ok, router} = Mob.Router.start_root(HomeScreen, %{}, nif: RecordingNif) + + on_exit(fn -> + stop_safely(router) + stop_safely(registry) + stop_safely(components) + stop_safely(crash_control) + + for name <- [Mob.Sender, Mob.Listener], pid = Process.whereis(name) do + stop_safely(pid) + end + + stop_safely(recording) + end) + + # The router queues initial paint from its process. Dispatching through the + # same router drains that screen cast and performs a synchronous repaint, + # so no cross-sender ordering race can record the initial :none afterward. + Mob.Router.dispatch(router, "noop", %{}) + RecordingNif.reset() + %{router: router} + end + + defp stop_safely(pid) do + GenServer.stop(pid) + catch + :exit, _ -> :ok + end + + defp transitions do + Mob.Sender.sync(:infinity) + RecordingNif.transitions() + end + + test "a directional transition reaches native on first mount and restore", %{router: router} do + Mob.Router.dispatch(router, "settings_push", %{}) + assert List.last(transitions()) == :push + assert Mob.Router.get_current_module(router) == SettingsScreen + + Mob.Router.dispatch(router, "home_pop", %{}) + assert List.last(transitions()) == :pop + assert Mob.Router.get_current_module(router) == HomeScreen + end + + test "legacy switch_tab/2 remains an unanimated swap", %{router: router} do + Mob.Router.dispatch(router, "settings_default", %{}) + assert List.last(transitions()) == :none + assert Mob.Router.get_current_module(router) == SettingsScreen + end + + test "rapid alternating transitions retain their order", %{router: router} do + Mob.Router.dispatch(router, "settings_push", %{}) + Mob.Router.dispatch(router, "home_pop", %{}) + Mob.Router.dispatch(router, "settings_push", %{}) + Mob.Router.dispatch(router, "home_pop", %{}) + + assert transitions() == [:push, :pop, :push, :pop] + assert Mob.Router.get_current_module(router) == HomeScreen + end + + test "reselecting the active tab stays a no-op", %{router: router} do + Mob.Router.dispatch(router, "home_push", %{}) + assert List.last(transitions()) == :none + assert Mob.Router.get_current_module(router) == HomeScreen + end + + test "a render crash cannot strand an activation frame", %{router: router} do + original_pid = Mob.Router.get_screen_pid(router) + CrashControl.crash_next() + + capture_log(fn -> + Mob.Router.dispatch(router, "settings_push", %{}) + # Drain the linked screen's EXIT and the replacement's first paint. + :sys.get_state(router) + :sys.get_state(router) + end) + + Mob.Sender.sync(:infinity) + + assert Mob.Router.get_current_module(router) == SettingsScreen + assert Mob.Router.get_screen_pid(router) != original_pid + assert Process.alive?(Mob.Router.get_screen_pid(router)) + assert List.last(RecordingNif.transitions()) == :push + end +end diff --git a/test/mob/nav_test.exs b/test/mob/nav_test.exs index 528be04..bcf82d8 100644 --- a/test/mob/nav_test.exs +++ b/test/mob/nav_test.exs @@ -108,6 +108,33 @@ defmodule Mob.NavTest do end end + describe "reset/2" do + test "preserves declared roots and order while clearing all materialized state" do + nav = Nav.from_layout(two_tabs(), HomeScreen) + {:mount_root, nav, _} = Nav.switch(nav, :settings, entry(HomeScreen)) + + nav = + nav + |> Nav.put_history([entry(ProfileScreen)]) + |> Nav.reset(HomeScreen) + + assert Nav.stacks(nav) == [:home, :settings] + assert nav.roots == %{home: HomeScreen, settings: SettingsScreen} + assert Nav.active(nav) == :home + assert Nav.history(nav) == [] + assert nav.parked == %{} + end + + test "selects the orphan stack when the replacement is not a declared root" do + nav = Nav.from_layout(two_tabs(), HomeScreen) |> Nav.reset(StrayScreen) + + assert Nav.active(nav) == :__mob_root__ + assert Nav.history(nav) == [] + assert nav.parked == %{} + assert Nav.stacks(nav) == [:home, :settings] + end + end + describe "drop_parked/2" do setup do nav = Nav.from_layout(two_tabs(), HomeScreen) diff --git a/test/mob/screen_case_test.exs b/test/mob/screen_case_test.exs index 7a09648..91d0574 100644 --- a/test/mob/screen_case_test.exs +++ b/test/mob/screen_case_test.exs @@ -62,6 +62,18 @@ defmodule Mob.ScreenCaseTest do {:noreply, Mob.Socket.reset_to(socket, CounterScreen, %{}, transition: :push)} end + def handle_event("reset_all", _params, socket) do + {:noreply, Mob.Socket.reset_to(socket, CounterScreen, %{}, scope: :all)} + end + + def handle_event("switch_push", _params, socket) do + {:noreply, Mob.Socket.switch_tab(socket, :settings, transition: :push)} + end + + def handle_event("switch_params", _params, socket) do + {:noreply, Mob.Socket.switch_tab(socket, :settings, mount_params: %{user_id: 7})} + end + def handle_info({:tap, :go}, socket) do {:noreply, Mob.Socket.push_screen(socket, CounterScreen)} end @@ -194,6 +206,21 @@ defmodule Mob.ScreenCaseTest do view = NavScreen |> mount_screen() |> render_event("reset_push") assert navigated_to(view) == CounterScreen end + + test "records an all-stack reset as the destination module" do + view = NavScreen |> mount_screen() |> render_event("reset_all") + assert navigated_to(view) == CounterScreen + end + + test "keeps a tab switch carrying a transition as the raw action" do + view = NavScreen |> mount_screen() |> render_event("switch_push") + assert navigated_to(view) == {:switch_tab, :settings, :push} + end + + test "keeps a tab switch carrying mount params as the raw action" do + view = NavScreen |> mount_screen() |> render_event("switch_params") + assert navigated_to(view) == {:switch_tab, :settings, :none, %{user_id: 7}} + end end # tree/1's :device clause must route to Mob.Test.tree/1 (the logical render diff --git a/test/mob/screen_state_test.exs b/test/mob/screen_state_test.exs index 260ed8b..21b7966 100644 --- a/test/mob/screen_state_test.exs +++ b/test/mob/screen_state_test.exs @@ -155,6 +155,53 @@ defmodule Mob.ScreenStateTest do end end + describe "delete_all/0" do + test "removes every screen snapshot", %{socket: socket} do + Mob.ScreenState.dump(PersistScreen, Mob.Socket.assign(socket, count: 1)) + + keyed = Mob.Socket.new(KeyedScreen) |> Mob.Socket.assign(user_id: 99) + Mob.ScreenState.dump(KeyedScreen, keyed) + + assert %{rows: [[2]]} = TestRepo.query!("SELECT count(*) FROM mob_screen_states", []) + assert :ok = Mob.ScreenState.delete_all() + assert %{rows: [[0]]} = TestRepo.query!("SELECT count(*) FROM mob_screen_states", []) + end + + test "is a no-op when no Repo is configured" do + Application.delete_env(:mob, :repo) + assert :ok = Mob.ScreenState.delete_all() + after + Application.put_env(:mob, :repo, TestRepo) + end + end + + describe "discarding a live screen's persisted state" do + test "suppresses both periodic sync and the final terminate dump" do + {:ok, pid} = + Mob.Screen.Server.start_link( + module: PersistScreen, + params: %{}, + owner: self(), + ref: make_ref(), + render_mode: :no_render, + platform: :android, + nif: :mob_nif + ) + + socket = Mob.Screen.Server.socket(pid) |> Mob.Socket.assign(count: 77) + Mob.ScreenState.dump(PersistScreen, socket) + assert {:ok, 1, %{count: 77}} = Mob.ScreenState.load(PersistScreen, socket) + + assert :ok = Mob.Screen.Server.discard_persisted_state(pid) + send(pid, :__mob_sync_state__) + :sys.get_state(pid) + assert :not_found = Mob.ScreenState.load(PersistScreen, socket) + + GenServer.stop(pid) + assert :not_found = Mob.ScreenState.load(PersistScreen, socket) + end + end + # ── use Mob.Screen, vsn: ───────────────────────────────────────────────── describe "use Mob.Screen, vsn:" do diff --git a/test/mob/sender_test.exs b/test/mob/sender_test.exs index 4ebdebc..9d82514 100644 --- a/test/mob/sender_test.exs +++ b/test/mob/sender_test.exs @@ -177,6 +177,77 @@ defmodule Mob.SenderTest do assert {:set_transition, :push} in RecordingNif.calls() end + test "activation drops a stale pending repaint for the newly active screen" do + state = %Sender{active: :home} + + {:noreply, state} = + Sender.handle_cast( + {:render, :settings, tree("stale"), :ios, RecordingNif, :none}, + state + ) + + {:reply, :ok, state} = + Sender.handle_call({:activate, :settings, :push}, self(), state) + + assert state.pending == %{} + + {:noreply, state} = Sender.handle_info(:flush, state) + assert committed_texts() == [] + + {:noreply, state} = + Sender.handle_cast( + {:render, :settings, tree("fresh"), :ios, RecordingNif, :none}, + state + ) + + {:noreply, _state} = Sender.handle_info(:flush, state) + assert [json] = committed_texts() + assert json =~ "fresh" + assert {:set_transition, :push} in RecordingNif.calls() + end + + test "a pre-activation render arriving late cannot consume the fresh frame" do + state = %Sender{active: :home} + + {:reply, token, state} = + Sender.handle_call({:activate_frame, :settings, :push}, self(), state) + + assert is_reference(token) + + {:noreply, state} = + Sender.handle_cast( + {:render, :settings, tree("stale"), :ios, RecordingNif, :none, nil}, + state + ) + + assert state.pending == %{} + assert state.activation_gate == {:settings, token, :push} + + {:noreply, state} = + Sender.handle_cast( + {:render, :settings, tree("fresh"), :ios, RecordingNif, :push, token}, + state + ) + + {:noreply, state} = Sender.handle_info(:flush, state) + + assert state.activation_gate == nil + assert [json] = committed_texts() + assert json =~ "fresh" + refute json =~ "stale" + assert {:set_transition, :push} in RecordingNif.calls() + end + + test "activation upgrades sender state loaded before the gate field existed" do + old_state = Map.delete(%Sender{active: :home}, :activation_gate) + + {:reply, token, state} = + Sender.handle_call({:activate_frame, :settings, :push}, self(), old_state) + + assert state.active == :settings + assert state.activation_gate == {:settings, token, :push} + end + test "the activated transition survives a second ordinary paint before flush" do state = %Sender{active: :home} diff --git a/test/mob/socket_test.exs b/test/mob/socket_test.exs index a6c9d92..7aee909 100644 --- a/test/mob/socket_test.exs +++ b/test/mob/socket_test.exs @@ -162,6 +162,34 @@ defmodule Mob.SocketTest do end end + test "emits an all-stack reset only when explicitly requested" do + socket = + Socket.new(MyScreen) + |> Socket.reset_to(OtherScreen, %{source: :logout}, scope: :all) + + assert socket.__mob__.nav_action == + {:reset, OtherScreen, %{source: :logout}, :reset, :all} + end + + test "combines all-stack scope with a directional transition" do + socket = + Socket.new(MyScreen) + |> Socket.reset_to(OtherScreen, %{}, transition: :pop, scope: :all) + + assert socket.__mob__.nav_action == {:reset, OtherScreen, %{}, :pop, :all} + end + + test "keeps the established action shape for explicit stack scope" do + socket = Socket.new(MyScreen) |> Socket.reset_to(OtherScreen, %{}, scope: :stack) + assert socket.__mob__.nav_action == {:reset, OtherScreen, %{}, :reset} + end + + test "rejects an unknown reset scope" do + assert_raise ArgumentError, ~r/invalid scope :tabs/, fn -> + Socket.new(MyScreen) |> Socket.reset_to(OtherScreen, %{}, scope: :tabs) + end + end + test "rejects :none, which would replace the stack without telling the platform" do # :none suppresses the navigation-version bump, so SwiftUI diffs the # incoming tree into the outgoing screen's view identities — a TextField @@ -172,4 +200,53 @@ defmodule Mob.SocketTest do end end end + + describe "switch_tab/3" do + test "keeps the legacy action shape when no transition is requested" do + socket = Socket.new(MyScreen) |> Socket.switch_tab(:settings) + assert socket.__mob__.nav_action == {:switch_tab, :settings} + + socket = Socket.new(MyScreen) |> Socket.switch_tab(:settings, []) + assert socket.__mob__.nav_action == {:switch_tab, :settings} + end + + test "stores a validated directional transition" do + for transition <- [:push, :pop, :reset] do + socket = Socket.new(MyScreen) |> Socket.switch_tab(:settings, transition: transition) + assert socket.__mob__.nav_action == {:switch_tab, :settings, transition} + end + end + + test "stores mount params with the default or an explicit transition" do + socket = Socket.new(MyScreen) |> Socket.switch_tab(:settings, mount_params: %{user_id: 7}) + + assert socket.__mob__.nav_action == + {:switch_tab, :settings, :none, %{user_id: 7}} + + socket = + Socket.new(MyScreen) + |> Socket.switch_tab(:settings, transition: :push, mount_params: %{user_id: 7}) + + assert socket.__mob__.nav_action == + {:switch_tab, :settings, :push, %{user_id: 7}} + end + + test "rejects non-map mount params" do + assert_raise ArgumentError, ~r/invalid mount_params \[user_id: 7\]/, fn -> + Socket.new(MyScreen) |> Socket.switch_tab(:settings, mount_params: [user_id: 7]) + end + end + + test "rejects an invalid transition" do + assert_raise ArgumentError, ~r/Mob.Socket.switch_tab\/3: invalid transition :puhs/, fn -> + Socket.new(MyScreen) |> Socket.switch_tab(:settings, transition: :puhs) + end + end + + test "rejects an explicit :none transition" do + assert_raise ArgumentError, ~r/invalid transition :none/, fn -> + Socket.new(MyScreen) |> Socket.switch_tab(:settings, transition: :none) + end + end + end end diff --git a/test/mob/test_test.exs b/test/mob/test_test.exs index e81cd19..6da0fac 100644 --- a/test/mob/test_test.exs +++ b/test/mob/test_test.exs @@ -7,6 +7,36 @@ defmodule Mob.TestTest do alias Mob.Test, as: M + describe "reset_to/4" do + test "rejects an invalid scope before making an RPC" do + assert_raise ArgumentError, ~r/Mob.Test.reset_to\/4: invalid scope :tabs/, fn -> + M.reset_to(:unused_node, :login, %{}, scope: :tabs) + end + end + end + + describe "switch_tab/3" do + test "rejects transitions that application sockets reject" do + assert_raise ArgumentError, ~r/Mob.Test.switch_tab\/3: invalid transition :puhs/, fn -> + M.switch_tab(:unused_node, :settings, transition: :puhs) + end + + assert_raise ArgumentError, ~r/invalid transition :none/, fn -> + M.switch_tab(:unused_node, :settings, transition: :none) + end + + assert_raise ArgumentError, ~r/invalid transition nil/, fn -> + M.switch_tab(:unused_node, :settings, transition: nil) + end + end + + test "rejects invalid mount params before making an RPC" do + assert_raise ArgumentError, ~r/Mob.Test.switch_tab\/3: invalid mount_params/, fn -> + M.switch_tab(:unused_node, :settings, mount_params: [user_id: 7]) + end + end + end + defp sample_tree do %{ type: :root,