Skip to content

MOB-109: multi-stack navigation state — make tab_bar/1 representable - #95

Merged
GenericJam merged 2 commits into
masterfrom
feat/mob-109-multi-stack-nav
Aug 28, 2026
Merged

MOB-109: multi-stack navigation state — make tab_bar/1 representable#95
GenericJam merged 2 commits into
masterfrom
feat/mob-109-multi-stack-nav

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

First step of epic MOB-108 (ADR).

Problem

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 Mob.Screen held a single nav_history list. apply_nav_action/3 handled {: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, matching UITabBarController.

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.

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.

No .m, .zig, or template change — a tab switch renders with the :none transition, 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:

  • Boot crashfrom_layout/2 had no catch-all, so an unrecognised navigation/1 return (def navigation(_), do: []) went from harmlessly ignored to the app failing to boot.
  • A declared root became permanently unreachable — filing a non-root boot screen under the first stack, plus switch-to-active being a no-op, meant HomeScreen could never be reached again. Such screens now get a private orphan stack.
  • Back at a tab root exited the app, discarding every parked stack — the opposite of what the parked state is for. back_target/1 now returns to the first stack.
  • Mob.Nav missing 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

  • 42 new tests (28 unit on the pure state, 14 driving Mob.Screen end to end)
  • Negative control: 7 of the 12 original integration tests fail against the previous code; all pass now
  • Suite 1161 passed, mix format --check-formatted, mix credo --strict, --warnings-as-errors all clean

Scope note

tab_bar/1 is now representable, not yet visible — nothing renders a tab bar, so switch_tab/2 is programmatic-only. That's the next layer's work.

GenericJam and others added 2 commits August 28, 2026 13:29
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>
@GenericJam
GenericJam merged commit 7a1587e into master Aug 28, 2026
4 checks passed
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant