Skip to content

feat(navigation): allow directional resets - #100

Merged
GenericJam merged 2 commits into
masterfrom
fix/navigation-reset-transition
Aug 29, 2026
Merged

feat(navigation): allow directional resets#100
GenericJam merged 2 commits into
masterfrom
fix/navigation-reset-transition

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Summary

  • add an optional navigation transition to Mob.Socket.reset_to/4
  • preserve stack-reset semantics while allowing directional :push and :pop animations
  • retain compatibility with legacy three-element reset actions
  • document the custom-tab use case and cover the socket contract

Why

Custom tab bars sometimes replace the active navigation stack while still
needing direction-aware motion. Previously reset_to/3 always selected the
reset animation, so consumers could not express that distinction without
changing the stack operation.

Verification

  • mix test — 1,235 passed, 38 excluded
  • mix format --check-formatted
  • mix compile --warnings-as-errors
  • mix credo --strict
  • git diff --check

GenericJamand others added 2 commits August 29, 2026 10:33
Ports PR #100 onto master and fixes three deficiencies found reviewing it.
`Mob.Socket.reset_to/4` takes `transition: :push | :pop | :reset | :none`.
A reset always replaces the stack; the option only changes the animation, for
cases like a custom tab bar where replacing the stack still represents
directional movement.
Ported, not merged: #100 patched `apply_nav_action/3` and `reset_resolved/4`
in lib/mob/screen.ex, but MOB-113 (#101) has since extracted navigation into
lib/mob/router.ex, where screen.ex no longer has an apply_nav_action at all.
The change is re-applied against the router.
Three fixes on top:
1. Mob.ScreenCase.navigated_to/1 matched only `{:reset, dest, _params}`, so
once reset_to/4 started emitting a fourth element it fell through to the
catch-all and returned the raw action tuple instead of the destination
module. That broke `assert navigated_to(view) == SomeScreen` for EVERY
reset — not just ones passing a transition, since the default also emits
four elements — in the helper whose entire job is that assertion. No test
covered reset there, which is why a green suite hid it.
2. The transition was unvalidated. set_transition/1 accepts any atom and the
platform falls back to no animation for one it does not recognise, so a
typo silently produced the wrong motion with nothing to point at. It is
checked at the socket boundary now, matching how Mob.UI validates sheet
detents.
3. The original tests asserted the nav action's shape but never that the
chosen animation reaches the native boundary — a reset that recorded
`:push` and still painted `:reset` would have passed them. Added
router-level coverage in `:render` with an injected NIF, since do_paint
short-circuits under `:no_render` and the transition is not observable
there.
The three-element action still works: it arrives from Mob.Test.reset_to/3 and
from any socket built before a hot code push.
Verified as negative controls — reverting the transition wiring fails the two
directional tests, and reverting the ScreenCase clause fails both reset
assertions.
Suite 1254 passed, format and credo --strict clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The finding that mattered: `:none` was in the allowed transition set, and it
is the one value that suppresses the navigation-version bump — mob_nif's
mob_bump_frame_generation, MobViewModel.navVersion, and the .id() on the root
view. A reset stops every screen process and replaces the stack, so telling
the platform no navigation happened leaves SwiftUI diffing the incoming tree
into the outgoing screen's view identities: a text field at the same position
inherits the old screen's text and focus, and scroll offsets survive a stack
that no longer exists. Rejected now, with the reasoning recorded where the
validator lives. Nothing can depend on it — the option is unreleased.
The comment justifying the validator was also wrong about this. It argued
from the platform's fallback for an unrecognised atom, which is strictly
safer than the `:none` the validator was letting through.
Also fixed:
- An action shape the router does not recognise was an unmatched function
clause in the owner, which owns navigation and links every live screen, so
one bad action killed all of them. Reachable during a hot code push, where
module loading is not atomic and a screen on new code can hand an action to
a router on old code — the exact direction the compatibility clause cannot
cover, because the guard would have to live in the old code. Now logged and
ignored with a repaint. Pinned by a test; removing the clause fails it.
- `Mob.ScreenCase.navigated_to/1`'s three-element clause was dead in-repo
(Mob.Socket only emits four now), so both new tests exercised the same
clause and deleting the legacy one left the suite green. The legacy test
builds that shape by hand, which is the only way it now occurs — from a
socket predating a hot code push.
- `Mob.Test.reset_to/4` takes the option, so the behaviour is drivable on a
device rather than only in-BEAM.
- `@type transition` was declared and unreferenced while `@valid_transitions`
duplicated it; the spec uses the type now.
- Documented arities (`reset_to/2,3` in the guide and Mob.App), that the
function can raise, and the test file leaking Mob.Sender/Mob.Listener under
their global names.
One test was a change-detector — it asserted the same four atoms the
implementation uses as its allow-list, never consulting the renderer it named.
Rewritten to pin the two things that matter: the transitions that work, and
that :none does not.
Suite 1256 passed, format and credo --strict clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam
GenericJamforce-pushed the fix/navigation-reset-transition branch from c74e216 to 880b405CompareAugust 29, 2026 16:44
@GenericJam

Copy link
Copy Markdown
OwnerAuthor

Ported onto master and reviewed adversarially. Rebasing wasn't enough: MOB-113 (#101) has since extracted navigation into lib/mob/router.ex, and lib/mob/screen.ex no longer has an apply_nav_action at all — so the change is re-applied against the router rather than merged textually. The port itself is line-for-line faithful.

Four deficiencies fixed on top.

Mob.ScreenCase.navigated_to/1 broke for every reset. It matched only {:reset, dest, _params}, so once reset_to emits a fourth element it fell through to the catch-all and returned the raw action tuple instead of the destination module — in the helper whose entire job is assert navigated_to(view) == SomeScreen. Worth stressing this wasn't limited to callers passing a transition: the default reset_to/3 also emits four elements now, so it broke all resets. No test covered reset there, which is why a green suite hid it.

:none had to go from the allowed set. It's the one transition value that suppresses the navigation-version bump (mob_bump_frame_generation, MobViewModel.navVersion, and the .id() on the root view). A reset stops every screen process and replaces the stack, so telling the platform no navigation happened leaves SwiftUI diffing the incoming tree into the outgoing screen's view identities — a text field at the same position inherits the old screen's text and focus, and scroll offsets survive a stack that no longer exists. Nothing can depend on it; the option is unreleased.

An unrecognised action shape killed the router and every screen.apply_nav_action/3 had no fallback, so an unmatched action was a FunctionClauseError in the owner process — which owns navigation and links every live screen. That's reachable during a hot code push, where module loading isn't atomic and a screen already on new code can hand an action to a router still on old code. It's the one direction the compatibility clause can't help with, because the guard would have to live in the old code. Now logged and ignored with a repaint, and pinned by a test.

The tests asserted shape, not behaviour. They checked the nav action tuple but never that the chosen animation reaches set_transition/1 — a reset recording :push and still painting :reset would have passed. Added router-level coverage in :render mode with an injected NIF, since do_paint short-circuits under :no_render.

Also: Mob.Test.reset_to/4 takes the option so it's drivable on device; @type transition was declared but unreferenced while @valid_transitions duplicated it; documented arities and the fact the function can raise; and one test was a change-detector asserting the same atoms the implementation uses as its allow-list.

Every fix is verified as a negative control — reverting each one fails exactly the test written for it.

Suite 1256 passing, format and credo --strict clean. Bumped to 0.7.34 with a changelog entry. Merging and publishing.

@GenericJam
GenericJam merged commit 5e684c1 into masterAug 29, 2026
4 checks passed
@GenericJam
GenericJam deleted the fix/navigation-reset-transition branch August 29, 2026 16:47
GenericJam added a commit that referenced this pull request Aug 30, 2026
…emantics
navigation.md still described tab_bar/drawer as rendering native chrome
(UITabBarController / NavigationBar) — since 0.7.33 the runtime backs the
declaration with real per-stack state but draws no chrome; switching is
programmatic. New 'Tabs and multi-stack state' section documents lazy
materialization, parking, independent histories, back-at-secondary-root,
the orphan stack, and the MOB-115/116/117 gaps. Directional-reset docs
gain the ArgumentError validation and transition-survives-coalescing
behavior (#100/#103).
screen_lifecycle.md gains crash/restart semantics (per-screen isolation,
restart cap, re-mount + load_state), per-screen self() and message
delivery, terminate/2 reality (pop stops the leaving screen only), and
multi-stack system-back.
Mob.App.tab_bar/1 and drawer/1 docstrings no longer claim chrome that is
not drawn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GenericJam added a commit that referenced this pull request Aug 30, 2026
…eature coverage (#105)
* docs(navigation): multi-stack state, honest tab/drawer claims, back semantics
navigation.md still described tab_bar/drawer as rendering native chrome
(UITabBarController / NavigationBar) — since 0.7.33 the runtime backs the
declaration with real per-stack state but draws no chrome; switching is
programmatic. New 'Tabs and multi-stack state' section documents lazy
materialization, parking, independent histories, back-at-secondary-root,
the orphan stack, and the MOB-115/116/117 gaps. Directional-reset docs
gain the ArgumentError validation and transition-survives-coalescing
behavior (#100/#103).
screen_lifecycle.md gains crash/restart semantics (per-screen isolation,
restart cap, re-mount + load_state), per-screen self() and message
delivery, terminate/2 reality (pop stops the leaving screen only), and
multi-stack system-back.
Mob.App.tab_bar/1 and drawer/1 docstrings no longer claim chrome that is
not drawn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(testing): document Mob.ScreenCase and settle/2; remove nonexistent screen_pid/1
The testing guide never mentioned Mob.ScreenCase (#44), the blessed
in-BEAM unit-test path — it now leads the guide. The old sync-point
advice referenced Mob.Test.screen_pid/1, which does not exist, and
:sys.get_state on :mob_screen, which stopped being sufficient when
rendering moved to Mob.Sender (MOB-110) and :mob_screen became the
navigation owner (MOB-112); both are replaced with Mob.Test.settle/2
and an explanation of the three processes it drains. Unit-test examples
updated for the process model (dispatch is synchronous; get_socket is
the natural sync point after send).
Mob.Test's moduledoc claimed tap/2 goes through handle_event/3; it
sends {:tap, tag} to handle_info/2 like a real native tap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(components,theming): Sheet section, handle-pool limits, font tokens
components.md had no coverage of Mob.UI.sheet/2 (0.7.29) or intrinsic
content detents (0.7.32) beyond the surface-matrix row — new ':sheet'
section documents presence-is-presentation, detents including
[:content] / [{:content, max_height: n}], exactly-once {:dismiss, tag},
and the iOS scrim limitation. New 'Handle limits' section covers the
256-handle tap pool and the 256-slot native component pool with
{:error, :component_slots_exhausted} (0.7.28 behavior). :text gains the
font prop (named font tokens, 0.7.25).
theming.md never mentioned fonts — adds the fonts:/font_fallback: token
type with a pointer to Styling → Custom fonts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fix tap examples to handle_info/2 and stale module/function names
README and several guides showed UI taps handled by
handle_event("tap", %{"tag" => ...}) — a real tap delivers
{:tap, tag} to handle_info/2, so those example screens would never
respond on a device. README's diagram and testing snippet updated for
the per-screen process model and Mob.ScreenCase. getting_started
referenced Mob.Nav.push/2, which does not exist (Mob.Socket.push_screen
is the API). event_audit's list-select re-emitter is Mob.Screen.Server
since MOB-113.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(agentic): split into single-agent and agent-team halves; new practices
Part 1 (Working with one agent) keeps the existing content in order and
adds: verify effects not exit codes (assert the app answers after a
deploy), the honesty contract (success = a handler ran — assert on state
change after a tap, settle-window caveat), match the evidence to the
question (frames for layout, screenshots-with-tolerance for appearance,
recordings for motion), lifecycle-event simulation recipes (simctl push
.apns, adb broadcast / cmd notification post), and environment
discipline (complete .tool-versions incl. zig/JDK, the
MOB_DIR/MOB_DEV_DIR/MOB_NEW_DIR override chain).
Part 2 (Working with agent teams) is new: one driver per device with
lease discipline (humans outrank agents), unique node names per session
(mob.connect --name), per-task git worktrees, the mob.push/mob.watch
fan-out hazard (they reach every live node, no device scoping — fleets
deploy per device or push over their own dist connection), and durable
artifacts as the handoff medium between context windows.
The standard agent loop gains a settle/2 step before native-side reads.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(mix): group new modules in hexdocs sidebar
Mob.Router joins Navigation; Mob.Screen.Server, Mob.Listener and
Mob.Sender get a Runtime Processes group; Mob.ScreenCase joins
Testing & Debugging. All five were shipping ungrouped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: document #80's honest tap returns and pixel sampling
Written minutes before #80 merged, the honesty-contract and
evidence-matching sections claimed no pixel-sampling API existed and
leaned solely on state-change assertions. Now that tap_xy/3 reports
observed effect, both guides document the contract: :ok only when an
event reached the BEAM within 300ms, else {:error, :no_view_at_point |
:no_element_at_point | :no_effect} — plus the platform limits that make
:no_effect legitimate (SwiftUI on_tap containers, physical-device
injection) and the serial-harness assumption the 300ms window shares
with state-change checks.
Evidence matching now splits exact-color decisions (sample_color/2:
real pixels, dominant/average as 0xAARRGGBB, iOS debug-build only) from
holistic visual parity (screenshots with tolerance). testing.md gains
matching sections.
No change needed for #77: push_notifications.md already described
tap-to-open from a killed app, which that fix made true.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
GenericJam added a commit that referenced this pull request Sep 1, 2026
…emantics
navigation.md still described tab_bar/drawer as rendering native chrome
(UITabBarController / NavigationBar) — since 0.7.33 the runtime backs the
declaration with real per-stack state but draws no chrome; switching is
programmatic. New 'Tabs and multi-stack state' section documents lazy
materialization, parking, independent histories, back-at-secondary-root,
the orphan stack, and the MOB-115/116/117 gaps. Directional-reset docs
gain the ArgumentError validation and transition-survives-coalescing
behavior (#100/#103).
screen_lifecycle.md gains crash/restart semantics (per-screen isolation,
restart cap, re-mount + load_state), per-screen self() and message
delivery, terminate/2 reality (pop stops the leaving screen only), and
multi-stack system-back.
Mob.App.tab_bar/1 and drawer/1 docstrings no longer claim chrome that is
not drawn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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

@GenericJam