MOB-109: multi-stack navigation state — make tab_bar/1 representable - #95
Merged
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
This was referenced Aug 28, 2026
GenericJam
added a commit
that referenced
this pull request
Aug 29, 2026
First four steps of the screen-process architecture (MOB-108): multi-stack navigation state (#95), the sender (#96), the listener (#97), and one process per live screen (#99). None of the four required a .m, .zig, or template change — the whole move happens above the native boundary. The user-visible headlines are crash isolation that is finally real (a crashing handle_event no longer takes navigation and every sibling screen with it, which Mob.Screen's moduledoc claimed for a long time and mob#76 had to correct), tab_bar/1 and drawer/1 backed by a runtime that can actually hold more than one history, and MOB-107 fixed at the root: work started by a screen now goes to that screen's own pid instead of whichever screen happens to be current. self() inside a screen callback now means that screen's own pid. That is what user code already assumed when writing on_tap: {self(), :save}. Called out as known gaps rather than left to be discovered: nothing renders tab bar or drawer chrome yet, so switch_tab/2 is programmatic for now; and MOB-115/116/117 remain open on reset_to/2's stack derivation, parked screens missing terminate/2, and re-selecting the active tab not popping to root. Release review of v0.7.32..HEAD: 1233 tests pass, format, credo --strict and --warnings-as-errors clean. Spot-checked the merged MOB-112 against the failure modes this architecture invites — per-screen render refs (so a background screen cannot commit over the foreground), every owner-to-screen call wrapped, render/1 running in the screen rather than the owner, a restart ceiling, linked-and-trapping lifecycles, and drop_parked/2 wired to both call sites. None outstanding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 29, 2026
pshoukry
pushed a commit
to pshoukry/mob
that referenced
this pull request
Sep 9, 2026
…enericJam#95) * MOB-109: multi-stack navigation state — make tab_bar/1 representable 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) <noreply@anthropic.com> * MOB-109: fix four defects found by adversarial review 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
pshoukry
pushed a commit
to pshoukry/mob
that referenced
this pull request
Sep 9, 2026
First four steps of the screen-process architecture (MOB-108): multi-stack navigation state (GenericJam#95), the sender (GenericJam#96), the listener (GenericJam#97), and one process per live screen (GenericJam#99). None of the four required a .m, .zig, or template change — the whole move happens above the native boundary. The user-visible headlines are crash isolation that is finally real (a crashing handle_event no longer takes navigation and every sibling screen with it, which Mob.Screen's moduledoc claimed for a long time and mob#76 had to correct), tab_bar/1 and drawer/1 backed by a runtime that can actually hold more than one history, and MOB-107 fixed at the root: work started by a screen now goes to that screen's own pid instead of whichever screen happens to be current. self() inside a screen callback now means that screen's own pid. That is what user code already assumed when writing on_tap: {self(), :save}. Called out as known gaps rather than left to be discovered: nothing renders tab bar or drawer chrome yet, so switch_tab/2 is programmatic for now; and MOB-115/116/117 remain open on reset_to/2's stack derivation, parked screens missing terminate/2, and re-selecting the active tab not popping to root. Release review of v0.7.32..HEAD: 1233 tests pass, format, credo --strict and --warnings-as-errors clean. Spot-checked the merged MOB-112 against the failure modes this architecture invites — per-screen render refs (so a background screen cannot commit over the foreground), every owner-to-screen call wrapped, render/1 running in the screen rather than the owner, a restart ceiling, linked-and-trapping lifecycles, and drop_parked/2 wired to both call sites. None outstanding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First step of epic MOB-108 (ADR).
Problem
Mob.App.tab_bar/1anddrawer/1have been public API inMob.App's own moduledoc, andMob.Socket.switch_tab/2callable, whileMob.Screenheld a singlenav_historylist.apply_nav_action/3handled{:switch_tab, _}by clearing the action with the comment "Tab switching is handled renderer-side" — nothing handled it anywhere. Switching tabs was a silent no-op.Change
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 restores rather than re-mounts. Stacks materialize on first visit, matchingUITabBarController.Nav.Registrynow 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 backtab_bar/1.The active screen deliberately stays in
Mob.Screen's{module, socket}slots rather than moving into the struct: onlyswitch/3moves state in or out ofparked, 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.No
.m,.zig, or template change — a tab switch renders with the:nonetransition, an atom native already accepts.Rationale in
decisions/2026-08-28-multi-stack-nav-state.md.Review
An adversarial review pass found four defects, all fixed in the second commit:
from_layout/2had no catch-all, so an unrecognisednavigation/1return (def navigation(_), do: []) went from harmlessly ignored to the app failing to boot.HomeScreencould never be reached again. Such screens now get a private orphan stack.back_target/1now returns to the first stack.Mob.Navmissing from the ExDoc groups.Three further findings want MOB-112's per-screen processes to fix cleanly and are filed as MOB-115, MOB-116, MOB-117, and recorded as known gaps in the ADR.
Verification
Mob.Screenend to end)mix format --check-formatted,mix credo --strict,--warnings-as-errorsall cleanScope note
tab_bar/1is now representable, not yet visible — nothing renders a tab bar, soswitch_tab/2is programmatic-only. That's the next layer's work.