Mob.Sender (mob v0.7.39)

Copy Markdown View Source

The only process permitted to call the render NIFs.

Why this is forced

Not a style choice — the native tap registry requires it. From ios/mob_nif.m (the Android side in android/jni/mob_nif.zig is the same shape):

static TapHandle *tap_tables[2];   // grown on demand, see MOB-133
static int tap_active = 0;
static int tap_build_count = 0;   // cursor into the BUILDING table

clear_taps prepares the inactive table and resets the cursor, register_tap appends at tap_build_count++, and set_root swaps the tables atomically. The double buffering makes a concurrent reader safe — a drag or scroll event arriving mid-render still resolves against the last committed table. It does nothing for concurrent writers: there is one global build cursor, so two renders in flight interleave their handles into the same building table, and whichever reaches set_root first commits a table holding both screens' handles while the other screen's tree is never committed at all.

So clear_taps -> register_tap* -> set_root is one indivisible sequence, and serialising it through a single process is the only thing that keeps it that way once more than one screen is live (MOB-112).

Coalescing falls out of it

Because renders are queued rather than executed by the caller, the sender can look at what is waiting and commit only what matters:

  • for a given screen, only the newest tree is committed — a screen that re-renders three times before the sender gets to it produces one commit, not three
  • a tree for a screen that is not active is dropped, never committed

That second point is what lets an inactive tab keep its state without rendering. It is also why switching stacks re-renders: the incoming screen's tree is produced fresh at switch time rather than replayed from a queue.

Ordering

render/5 is asynchronous, so a caller that needs the commit to have landed calls sync/1, which performs the flush itself rather than waiting for the self-sent one.

It has to. send(self(), :flush) during the render cast appends to the back of the mailbox — behind a sync/1 the caller has already queued — so a 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 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 guarantee only covers renders cast by the calling process; the BEAM promises nothing about the relative order of sends from different processes.

Summary

Types

Identifies which screen a tree belongs to — one per live screen since MOB-112, not one per navigation stack. Screens below the top of a stack are live processes that repaint, so a stack-wide key would let a background screen's tree commit over the foreground one.

Functions

Activate a screen and reserve its navigation transition for the next frame.

Returns a specification to start this module under a supervisor.

Start the sender if it is not already running.

Queue tree for commit on behalf of screen ref.

Whether the sender is running. Renders are silently dropped when it is not.

Declare which screen's trees may be committed.

Start the sender. Named, so there is exactly one.

Block until every render queued before this call has been committed or dropped.

Types

screen_ref()

@type screen_ref() :: reference() | atom()

Identifies which screen a tree belongs to — one per live screen since MOB-112, not one per navigation stack. Screens below the top of a stack are live processes that repaint, so a stack-wide key would let a background screen's tree commit over the foreground one.

Functions

activate(ref, transition)

@spec activate(screen_ref(), atom()) :: :ok

Activate a screen and reserve its navigation transition for the next frame.

Unlike set_active/1, this call is synchronous. The router uses it at the navigation boundary so paints sent by different screen processes cannot be observed before the transition intent. The first tree for ref consumes the reservation; later ordinary repaints remain :none.

A :none transition only activates the screen and creates no reservation.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

ensure_started()

@spec ensure_started() :: :ok

Start the sender if it is not already running.

Mob.App.start/0 starts it on the normal boot path, but a screen can be started without going through Mob.Appliveview_notes.md documents exactly that — and a missing sender fails in the worst possible way: renders are casts, so they vanish silently and the app shows a blank screen with no log, until the first synchronous render exits :noproc. Mob.Router calls this so no render path can reach that state.

Deliberately unlinked. The caller is usually a screen, and a screen crash must not take down the process every other screen renders through.

render(ref, tree, platform, nif, transition)

@spec render(screen_ref(), map(), atom(), module() | atom(), atom()) :: :ok

Queue tree for commit on behalf of screen ref.

Returns immediately. The tree is committed only if ref is active when the sender gets to it, and only if no newer tree for ref has arrived by then.

running?()

@spec running?() :: boolean()

Whether the sender is running. Renders are silently dropped when it is not.

set_active(ref)

@spec set_active(screen_ref()) :: :ok

Declare which screen's trees may be committed.

A render for any other screen is dropped. Mob.Router sets this; MOB-113's router takes it over.

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Start the sender. Named, so there is exactly one.

sync(timeout \\ 5000)

@spec sync(timeout()) :: :ok

Block until every render queued before this call has been committed or dropped.

"Queued before" means cast by the calling process — the BEAM orders sends between a given pair of processes and says nothing about sends from different ones. Committed or dropped: a return of :ok does not promise the caller's own tree reached the screen, only that the sender has caught up. A tree for a screen that is not active is dropped, and sync/1 returns :ok all the same.