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 liveviewSee 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:
| Direction | How it works |
|---|---|
| JS → Elixir | window.mob.send(data) → postMessage / JavascriptInterface → NIF → mob_deliver_webview_message → handle_info({:webview, :message, data}, socket) in your Mob.Screen |
| Elixir → JS | Mob.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:
| Direction | How it works |
|---|---|
| JS → Elixir | window.mob.send(data) → this.pushEvent("mob_message", data) → handle_event("mob_message", data, socket) in your LiveView |
| Elixir → JS | push_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:
- A DOM element with
phx-hook="MobHook"exists in the rendered page, AND - 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
enduse 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: 4000Troubleshooting
handle_event("mob_message", ...) never fires
The MobHook is not mounting. Check:
root.html.heexhas<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>inside<body>app.jscontainsconst MobHook = { ... }andhooks: {MobHook}in the LiveSocket config- Open browser devtools in the WebView and confirm
window.mob.sendis a function that callspushEvent, notpostMessage
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
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: 4001Examples
iex> Mob.LiveView.local_url("/")
"http://127.0.0.1:4000/"
iex> Mob.LiveView.local_url("/dashboard")
"http://127.0.0.1:4000/dashboard"