Packages
mob
0.9.21
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.1
0.9.0
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.39
0.7.38
0.7.37
0.7.36
0.7.35
0.7.34
0.7.33
0.7.32
0.7.31
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
guides/liveview.md
# LiveView Mode
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).
> **Looking for LiveView-style authoring in native screens?** This guide is about
> running an actual Phoenix LiveView in a WebView. For writing native `~MOB`
> screens the LiveView way (`assign_async`, `start_async`, streams, events,
> tests), see [Coming from Phoenix LiveView](coming_from_liveview.md).
## Setup
Run this from your Mob project root (the directory with `mix.exs`):
```bash
mix mob.enable liveview
```
This does four things:
1. **Generates `lib/<app>/mob_screen.ex`** — a `Mob.Screen` that opens a WebView
pointing at `http://127.0.0.1:PORT/`
2. **Patches `assets/js/app.js`** — adds the `MobHook` LiveView hook definition and
registers it with `LiveSocket`
3. **Patches `root.html.heex`** — adds a hidden `<div id="mob-bridge">` that the hook
mounts on (see [why this is required](#why-the-hidden-div-is-required))
4. **Creates or updates `mob.exs`** — sets `liveview_port` so `Mob.LiveView.local_url/1`
knows which port Phoenix is listening on
After running, wire up the screen in your app:
```elixir
# In Mob.App.on_start/0
Mob.Screen.start_root(MyApp.MobScreen)
```
Make sure Phoenix is running on the port set in `mob.exs` (default: 4000).
---
## The two-bridge architecture
Understanding this is essential when something is not working.
There are **two separate JavaScript bridges** that can route messages between your
page's JS and Elixir. They are mutually exclusive — whichever one is active owns
`window.mob`.
### Bridge 1 — The native bridge
The native WebView (iOS `WKWebView` / Android `WebView`) injects a `window.mob`
object into every page it loads. It routes directly through the NIF, bypassing
LiveView entirely.
```
JS → window.mob.send(data)
→ native postMessage / JavascriptInterface
→ NIF (mob_deliver_webview_message)
→ handle_info({:webview, :message, data}, socket) ← in your Mob.Screen
```
```
Elixir → Mob.WebView.post_message(socket, data)
→ NIF (webview_post_message)
→ evaluateJavascript("window.mob._dispatch(...)")
→ all window.mob.onMessage handlers in JS
```
### Bridge 2 — The LiveView bridge
When `MobHook` mounts it **replaces** `window.mob` with a LiveView-backed version.
Messages now travel over the Phoenix WebSocket.
```
JS → window.mob.send(data)
→ LiveView pushEvent("mob_message", data)
→ handle_event("mob_message", data, socket) ← in your LiveView
```
```
Elixir → push_event(socket, "mob_push", data)
→ LiveView handleEvent("mob_push", handler)
→ all window.mob.onMessage handlers in JS
```
Your JS code does not need to know which bridge is active — the `window.mob` API
is identical in both modes.
---
## Why the hidden div is required
This is the most commonly missed step when setting up LiveView mode manually.
Phoenix LiveView hooks run their `mounted()` callback **only when**:
1. A DOM element with `phx-hook="MobHook"` exists in the rendered page, **and**
2. The LiveView WebSocket has connected.
Registering `MobHook` in `app.js` is necessary but not sufficient. Without a
matching DOM element the hook is dormant — it never fires, `window.mob` is never
replaced with the LiveView version, and all JS messages silently use Bridge 1
(the native NIF bridge) instead of Bridge 2 (LiveView).
The symptom: `window.mob.send()` appears to work but `handle_event/3` in your
LiveView never receives anything. The messages arrive in `handle_info/2` in your
`Mob.Screen` instead.
`mix mob.enable liveview` adds this element immediately after `<body>` in
`root.html.heex`:
```html
<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>
```
Placing it at the top of `<body>` ensures the hook mounts as early as possible
after LiveView connects, so `window.mob` is overridden before page-specific JS runs.
### Adding it manually
If `mix mob.enable liveview` could not find `root.html.heex`, or you are setting
up manually, add the element anywhere inside `<body>` in whatever layout file
wraps your entire application:
```html
<body>
<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>
<%= @inner_content %>
</body>
```
---
## Android timing note
On iOS, `window.mob` (Bridge 1) is injected via `WKUserScript` at
`.atDocumentStart` — before any page JavaScript runs.
On Android, it is injected via `evaluateJavascript` in `WebViewClient.onPageFinished`
— after the page has fully loaded. There is a brief window between
`DOMContentLoaded` and `onPageFinished` where `window.mob` is `undefined`.
In practice this is harmless: `MobHook` mounts after LiveView connects, which
happens after `onPageFinished`, so Bridge 2 is in place before any user
interaction is possible.
However, if you call `window.mob` during `DOMContentLoaded`, guard it:
```javascript
document.addEventListener("DOMContentLoaded", () => {
if (window.mob) window.mob.send({ type: "ready" })
})
```
---
## Using the message API
### Receiving JS messages in a LiveView
```elixir
defmodule MyAppWeb.HomeLive do
use MyAppWeb, :live_view
use Mob.LiveView # optional — see below
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 that returns `{:noreply, socket}`, so unhandled native events do not crash
your LiveView.
**Important:** defining your own `handle_event/3` replaces the catch-all entirely
(Elixir `defoverridable` semantics). If you define `handle_event/3`, add your own
catch-all for events you do not explicitly handle:
```elixir
def handle_event("mob_message", %{"type" => "ping"}, socket) do
{:noreply, assign(socket, :pinged, true)}
end
# required — without this, unhandled mob_message events raise FunctionClauseError
def handle_event("mob_message", _data, socket), do: {:noreply, socket}
```
### Pushing messages from Elixir to JS
```elixir
push_event(socket, "mob_push", %{type: "theme_changed", value: "dark"})
```
This calls all handlers registered with `window.mob.onMessage(fn)` in your page JS.
### JS side
```javascript
// Send to Elixir
window.mob.send({ event: "button_tapped", id: "submit" })
// Receive from Elixir
window.mob.onMessage(function(data) {
if (data.type === "theme_changed") applyTheme(data.value)
})
```
---
## Configuring the port
The WebView loads `http://127.0.0.1:PORT/`. Set the port in `mob.exs`:
```elixir
config :mob, liveview_port: 4000
```
`Mob.LiveView.local_url/1` reads this value:
```elixir
Mob.UI.webview(url: Mob.LiveView.local_url("/")) # http://127.0.0.1:4000/
Mob.UI.webview(url: Mob.LiveView.local_url("/dashboard")) # http://127.0.0.1:4000/dashboard
```
Use `127.0.0.1` explicitly — not `localhost`. On Android, `localhost` may resolve
to the host machine rather than the device's own loopback interface.
---
## Troubleshooting
### `handle_event("mob_message", ...)` never fires
The MobHook has not mounted. Check in order:
1. **Is the bridge element present?** Open your `root.html.heex` and confirm
`<div id="mob-bridge" phx-hook="MobHook" ...>` is inside `<body>`.
2. **Is MobHook registered?** Open `assets/js/app.js` and confirm:
- `const MobHook = { mounted() { ... } }` is defined
- `hooks: {MobHook}` (or `hooks: {MobHook, ...}`) is in the `LiveSocket` constructor
3. **Verify at runtime.** Open WebView devtools and run:
```javascript
window.mob.send.toString()
// Should contain "pushEvent", not "postMessage"
```
If it says `postMessage`, MobHook has not mounted and you are on Bridge 1.
### Messages arrive in `handle_info({:webview, :message, ...})` instead of `handle_event`
Same root cause as above — `window.mob` is still the native bridge. Fix the
bridge element.
### `window.mob` is undefined
On Android during `DOMContentLoaded` this is expected — see the timing note above.
If it is undefined after the page has fully loaded, the native WebView shim failed
to inject. Check the Android logcat for WebView errors.
### LiveView works in the browser but not in the WebView
The BEAM and Phoenix must both run **on-device**, not on your development Mac.
The WebView resolves `127.0.0.1` to the device's own loopback. Run
`mix mob.deploy` to push the app to the device, then confirm the node is
running with `mix mob.connect`.
### Port mismatch
If the WebView shows a connection error, check that:
- `config :mob, liveview_port:` in `mob.exs` matches the port in `config/dev.exs`
(`config :my_app, MyAppWeb.Endpoint, http: [port: 4000]`)
- Both values are the same number