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 tableclear_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
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
@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.
Returns a specification to start this module under a supervisor.
See Supervisor.
@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.App — liveview_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.
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.
@spec running?() :: boolean()
Whether the sender is running. Renders are silently dropped when it is not.
@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.
@spec start_link(keyword()) :: GenServer.on_start()
Start the sender. Named, so there is exactly one.
@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.