Skip to content

Screen async: start_async / handle_async / cancel_async + fix wrong Task/on_end_reached guide examples - #198

Merged
GenericJam merged 2 commits into
masterfrom
feat/screen-async
Oct 9, 2026
Merged

GenericJam merged 2 commits into
masterfrom
feat/screen-async

Conversation

@GenericJam

@GenericJam GenericJam commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

LiveView-style async work for screens, plus fixes to guide examples that were wrong.

A user asked whether Mob has assign_async / start_async / stream for loading skeletons. MOB-107 proposed start_async/handle_async, but it was closed as superseded by MOB-112 (one process per screen), and the API half was never built. Doing it by hand meant matching {ref, result}, {:DOWN, …} and {:EXIT, pid, :normal} (screens trap exits), and the guides got it wrong.

No Linear issue. The workspace is at its free-plan issue cap (USAGE_LIMIT_EXCEEDED on issueCreate). The issue text is reproduced below; file it once there's room.

API

  • Mob.Socket.start_async(socket, name, fun) delivers {:ok, result} or {:exit, reason} to the new optional handle_async/3 callback. Starting a running name replaces the older task, and only the newest reports.
  • Mob.Socket.cancel_async(socket, name, reason \\ {:shutdown, :cancel}): handle_async/3 gets {:exit, reason}, even if the result was already queued. Reason :normal raises, since it wouldn't stop the task.
  • Mob.ScreenCase.render_async(view, timeout \\ 1000) awaits the screen's tasks in off-device tests.
  • None of a task's messages reach handle_info/2. A crashing task doesn't crash the screen. Tasks stop when their screen stops, for any exit reason.

Implementation: Mob.Screen.Async, shared by Mob.Screen.Server and Mob.ScreenCase. Each task is a reporting runner (linked to the screen, traps exits) plus a worker that runs the function. Rationale is in decisions/2026-10-09-screen-async-tasks.md.

Doc fixes

  • guides/events.md: the pull-to-refresh and infinite-scroll examples started Task.async/1 and never handled the reply. The infinite-scroll example also put on_end_reached on <Scroll> and matched {:end_reached, tag}. on_end_reached is a <LazyList> prop and delivers {:tap, tag}: ios/mob_nif.m (mob_send_tap) and mob_new's MobBridge.kt.eex (nativeSendTap). Removed {:end_reached, tag} from the scroll event list.
  • guides/screen_lifecycle.md: handle_async/3 section with a skeleton example, and a note that slow work in mount/3 delays the first frame. guides/testing.md: render_async/2. guides/liveview.md: LiveView → Mob mapping (no stream; use <LazyList>).

Verification

  • mix test: 2070 passed. mix credo --strict: clean. mix compile --warnings-as-errors: clean.
  • 18 new tests. Each mechanism was revert-checked: the runner split, the cancel rekey, the token-filtered await, routing in route_info/2, replacement through the runner, and the external-kill :DOWN/:EXIT branches.
  • Guide examples (the skeleton render, pull-to-refresh, infinite scroll) were compiled and run under Mob.ScreenCase in a throwaway test.
  • Not run on a device or emulator. No native code changed; a delivered result repaints through the existing after_forward/2 path.

Review

Reviewer subagent (Codex rate-limited until 2026-10-13). The first pass found three should-fix bugs, each confirmed by experiment, and all fixed:

  • a cancel_async/3 race (273 of 300k cancels still delivered {:ok, _});
  • render_async/2 consuming another view's results;
  • a task killed through its own link killing the ScreenCase test process.

Pre-merge review: reviewer subagent, MERGE. Its three nitpicks are fixed:

  • a replaced worker that traps exits survived; replacement now goes through the runner;
  • the on_end_reached no-retry latch is iOS-only (Android re-fires), now documented in events.md and components.md;
  • the external-kill branches had no tests.

Also fixed guides/event_audit.md's {:end_reached, tag} row.


Issue text (to file in Linear)

A user asked whether Mob has LiveView's assign_async / start_async / stream for loading skeletons. It doesn't. Plain Task + handle_info/2 works, and since MOB-112 (one process per screen, mob 0.7.33) results are routed correctly: a popped screen's result is dropped by the BEAM, and a background tab handles its own result.

The routing fix is done, but the API half of MOB-107 was never built. MOB-107 proposed start_async / handle_async and was closed as superseded by MOB-112, and the API went with it. lib/ still has no async surface at all.

What every screen hand-rolls today

  • Keep the task ref in assigns and match {ref, result} on it.
  • Process.demonitor(ref, [:flush]).
  • Handle failure through {:DOWN, ...}. Screens trap exits, so a crash also arrives as {:EXIT, pid, reason} plus a [mob] … linked process … exited warning, and a normal finish sends {:EXIT, pid, :normal} into the user's handle_info.
  • Keep a catch-all handle_info/2 clause.
  • Cancel the task when the screen leaves: a task linked to a screen stopped with reason :normal keeps running.

The docs are wrong

guides/events.md "Pull-to-refresh" and "Infinite scroll" call Task.async/1 and never handle the reply. Copying them gives {ref, result}, {:DOWN, …} and {:EXIT, …} messages that nothing in the screen handles, and :loading / :refreshing never clear.

Change

LiveView-shaped, so the API is already familiar:

  • Mob.Socket.start_async(socket, name, fun): runs fun in a process linked to the screen. Starting a name that is still running replaces the older task, and the older task's result is never delivered.
  • Mob.Socket.cancel_async(socket, name, reason \\ {:shutdown, :cancel}).
  • New optional handle_async(name, {:ok, result} | {:exit, reason}, socket) callback on Mob.Screen.
  • Mob.Screen.Server consumes the task's result, :DOWN and :EXIT messages, so none of them reach handle_info/2. Each task is a runner process, linked to the screen and trapping exits, plus a worker, so a screen's tasks stop when it exits for any reason, :normal included.
  • Mob.ScreenCase.render_async/2, so off-device screen tests can await results (LiveView's render_async).
  • Docs: fix the events.md examples, add handle_async to screen_lifecycle.md, and add a LiveView → Mob mapping (assign_async/start_async → start_async + handle_async; stream → :lazy_list + on_end_reached).

Non-goals

  • stream / incremental tree patching. Mob has no network between the screen and its view, so the reason stream exists doesn't apply, and :lazy_list already virtualizes rows. Patching the tree was tried before: it was involved and didn't fix any performance problem.
  • assign_async / AsyncResult. Can be built on top later if users ask; start_async plus a :loading assign already covers skeleton loading.

Refs: MOB-107 (original proposal), MOB-112 (per-screen processes).

…ender_async; fix wrong Task and on_end_reached examples in guides

Mob.Socket.start_async/3 runs work in a task linked to the screen and
delivers {:ok, result} | {:exit, reason} to the new optional
handle_async/3 callback. None of the task's messages reach handle_info/2;
the newest task per name wins; cancel_async/3 always reports {:exit, _};
tasks stop with their screen for any exit reason. Mirrors LiveView.

guides/events.md started Task.async and never handled the reply, and its
infinite-scroll example used on_end_reached on <Scroll> matching
{:end_reached, tag}; on_end_reached is a <LazyList> prop delivering
{:tap, tag}.

Linear issue not filed: workspace at its free issue cap.
Review: reviewer subagent (Codex rate-limited until 2026-10-13): SHIP.
…ached latch; test external runner kill

Pre-merge review (reviewer subagent): replacing a name killed the runner
outright, leaving a worker that traps exits running; it now goes through
the runner, which kills the worker. on_end_reached's row-count latch is
iOS-only; Android re-fires on each arrival at the end. event_audit.md
listed {:end_reached, tag}; it is {:tap, tag}.
@GenericJam
GenericJam merged commit 1843423 into master Oct 9, 2026
4 checks passed
@GenericJam

Copy link
Copy Markdown
Owner Author

Tracked as MOB-460 (filed after the fact; the Linear workspace was at its issue cap when this merged).

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