Mob.LiveView (mob v0.5.10)

Copy Markdown View Source

Bridge between Phoenix LiveView and the Mob native WebView.

Overview

LiveView mode lets you ship a mobile app using only Phoenix LiveView — no native UI code required. Mob runs a local Phoenix endpoint on the device and wraps it in a native WebView. LiveView updates travel over the existing WebSocket at loopback speed (~1–5 ms).

Enable it with:

mix mob.enable liveview

See guides/liveview.md for the full setup walkthrough.


The two-bridge architecture

This is the most important thing to understand when working in LiveView mode. There are two separate JavaScript bridges for communicating between JS and Elixir, and they are mutually exclusive.

Bridge 1 — The native bridge (always present)

The native WebView (iOS WKWebView / Android WebView) injects a window.mob object into every page it loads. This object routes calls through the NIF, bypassing LiveView entirely:

DirectionHow it works
JS → Elixirwindow.mob.send(data)postMessage / JavascriptInterface → NIF → mob_deliver_webview_messagehandle_info({:webview, :message, data}, socket) in your Mob.Screen
Elixir → JSMob.WebView.post_message(socket, data) → NIF → evaluateJavascript("window.mob._dispatch(...)") → all registered onMessage handlers

iOS injects the shim via WKUserScript at .atDocumentStart — before any page JS runs.

Android injects it via evaluateJavascript in WebViewClient.onPageFinished — after the page has loaded. See the Android timing note below.

Bridge 2 — The LiveView bridge (active after MobHook mounts)

When MobHook mounts it replaces window.mob with a LiveView-backed version that routes over the Phoenix WebSocket:

DirectionHow it works
JS → Elixirwindow.mob.send(data)this.pushEvent("mob_message", data)handle_event("mob_message", data, socket) in your LiveView
Elixir → JSpush_event(socket, "mob_push", data)this.handleEvent("mob_push", handler) → all registered onMessage handlers

The _dispatch function is a no-op in LiveView mode — native post_message calls from Elixir still work at the NIF level but the LiveView path is preferred.


Why a DOM element is required (the non-obvious part)

mix mob.enable liveview injects MobHook into assets/js/app.js and registers it with LiveSocket. This is necessary but not sufficient.

Phoenix LiveView hooks only execute their mounted() callback when:

  1. A DOM element with phx-hook="MobHook" exists in the rendered page, AND
  2. The LiveView WebSocket has connected.

Without a matching DOM element the hook never fires, window.mob is never replaced, and all JS messages silently route through the native NIF bridge instead of LiveView. handle_event/3 in your LiveView will never be called.

mix mob.enable liveview patches root.html.heex to add this element:

<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>

It is placed immediately after the opening <body> tag so it mounts as early as possible. If you set up LiveView mode manually and something is not working, the missing bridge element is the most likely cause.

If root.html.heex is not found

mix mob.enable liveview will print the element and ask you to add it manually if it cannot find root.html.heex. Add it inside <body> in whichever layout file wraps your entire app.


Android timing note

On Android, window.mob is injected after onPageFinished. There is a brief window between DOMContentLoaded and onPageFinished where window.mob is undefined. The MobHook mounts after LiveView connects, which is also after onPageFinished, so in practice the bridges are sequenced correctly. However, if you call window.mob during DOMContentLoaded, guard it:

document.addEventListener("DOMContentLoaded", () => {
  if (window.mob) window.mob.send({ type: "ready" })
})

iOS does not have this issue — window.mob is available before any JS runs.


Message API

The window.mob API is identical in both bridge modes. Your JS code does not need to know which bridge is active:

// Send a message to Elixir
window.mob.send({ event: "button_tapped", id: "submit" })

// Receive messages from Elixir
window.mob.onMessage(function(data) {
  console.log("received:", data)
})

Elixir side — receiving JS messages in a LiveView

defmodule MyAppWeb.HomeLive do
  use MyAppWeb, :live_view
  use Mob.LiveView   # optional: adds a no-op fallthrough for mob_message

  def handle_event("mob_message", %{"event" => "button_tapped", "id" => id}, socket) do
    {:noreply, assign(socket, :last_tap, id)}
  end
end

use Mob.LiveView is optional. It adds a catch-all handle_event("mob_message", ...) clause so unhandled native events do not crash your LiveView.

Important: defining your own handle_event/3 replaces the catch-all entirely (defoverridable semantics). If you define handle_event/3, add your own catch-all for events you do not handle:

def handle_event("mob_message", _data, socket), do: {:noreply, socket}

Elixir side — pushing messages to JS

push_event(socket, "mob_push", %{type: "theme_changed", value: "dark"})

This calls all handlers registered with window.mob.onMessage(fn) in JS.


local_url/1

Use Mob.LiveView.local_url/1 to build the loopback URL for Mob.UI.webview/1:

Mob.UI.webview(url: Mob.LiveView.local_url("/"))
Mob.UI.webview(url: Mob.LiveView.local_url("/dashboard"))

The port is read from Application.get_env(:mob, :liveview_port), defaulting to 4000. Set it in mob.exs (created by mix mob.enable liveview):

config :mob, liveview_port: 4000

Troubleshooting

handle_event("mob_message", ...) never fires

The MobHook is not mounting. Check:

  1. root.html.heex has <div id="mob-bridge" phx-hook="MobHook" style="display:none"></div> inside <body>
  2. app.js contains const MobHook = { ... } and hooks: {MobHook} in the LiveSocket config
  3. Open browser devtools in the WebView and confirm window.mob.send is a function that calls pushEvent, not postMessage

Messages arrive in handle_info({:webview, :message, ...}) instead of handle_event

window.mob is still pointing at the native bridge. The MobHook has not mounted. See point 1 above.

window.mob is undefined on Android during DOMContentLoaded

Expected — see the Android timing note above. Guard the call or move it to after LiveView connects.

LiveView works in the browser but not in the WebView

Ensure Phoenix is binding to 127.0.0.1 (not just localhost) and that liveview_port in mob.exs matches the port Phoenix is listening on. The WebView resolves 127.0.0.1 to the device's own loopback — not the Mac's. The BEAM and Phoenix must both run on the device (i.e., you ran mix mob.deploy and the app is running on-device, not the dev server on your Mac).

Summary

Functions

Returns a loopback URL for the local Phoenix endpoint at path.

Functions

local_url(path \\ "/")

@spec local_url(String.t()) :: String.t()

Returns a loopback URL for the local Phoenix endpoint at path.

The WebView on the device loads this URL. Because both the BEAM and Phoenix run on-device, 127.0.0.1 resolves correctly. Do not use localhost — on Android it may resolve to the host machine rather than the device loopback.

Port defaults to 4000. Override in mob.exs:

config :mob, liveview_port: 4001

Examples

iex> Mob.LiveView.local_url("/")
"http://127.0.0.1:4000/"

iex> Mob.LiveView.local_url("/dashboard")
"http://127.0.0.1:4000/dashboard"