diff --git a/local-live-view/README.md b/local-live-view/README.md index cdc61492..e9d08f74 100644 --- a/local-live-view/README.md +++ b/local-live-view/README.md @@ -1,90 +1,17 @@ -# First steps +# LocalLiveView -## Usage -To use LocalLiveView you need to: -1. Add `:local_live_view` to your mix.exs file: -```elixir -defmodule LocalThermostat.MixProject do - use Mix.Project - ... - defp deps do - [ - {:local_live_view, github: "software-mansion/popcorn", sparse: "local-live-view"} - ] - end -end -``` -2. Attach local_live_view.js script in your .html file: -```html - - - - - -``` -3. Define your LocalLiveView in the `lib` directory: -```elixir -defmodule ThermostatLive do - use LocalLiveView +**LocalLiveView is a library for running Phoenix LiveView state in the browser, powered by [Popcorn](https://hexdocs.pm/popcorn).** - def render(assigns) do - ~H""" -

Current temperature: {@temperature}°F

- -

Country: {@country}

- """ - end +LocalLiveView compiles your Elixir modules to WebAssembly and runs them via Popcorn directly in the browser. You get zero-latency UI and offline capability while using the same Phoenix LiveView API you already know. - def mount(_params, _session, socket) do - temperature = 65 - {:ok, assign(socket, :temperature, temperature)} - end +## Documentation - def handle_event("inc_temperature", _params, socket) do - {:noreply, update(socket, :temperature, &(&1 + 1))} - end -end -``` -4. Add html tag to render defined view, by using data-pop-view: -```html - - - -
- - -``` - -## Build - -To build the project after defining the above, run: - -```bash -mix deps.get -mix build -``` - -This will generate the necessary scripts and popcorn files into the output directory. - -## Serve - -To serve the project locally: - -```bash -mix dev -``` - -and visit [localhost:4000](http://localhost:4000). - -You can also use `mix popcorn.server` directly, or any HTTP server that sets the required headers: - -``` -Cross-Origin-Opener-Policy: "same-origin" -Cross-Origin-Embedder-Policy: "require-corp" -``` +The API documentation and guides are available at ## Examples -- `examples/local-lv-thermostat` - Simple thermostat app demonstrating basic LocalLiveView state management and events. -- `examples/local-lv-forms` - Form handling with LocalLiveView, including validation and submission. -- `examples/local-lv-compare` - Side-by-side comparison of Phoenix LiveView and LocalLiveView. +The source code for all examples is in the `examples/` directory of this repository: + +- `local-lv-thermostat` — Basic state management and events +- `local-lv-forms` — Form handling with validation and Mirror Sync +- `local-lv-compare` — Side-by-side comparison of Phoenix LiveView vs LocalLiveView diff --git a/local-live-view/lib/server/mirror.ex b/local-live-view/lib/server/mirror.ex index d9d4b693..cbfed972 100644 --- a/local-live-view/lib/server/mirror.ex +++ b/local-live-view/lib/server/mirror.ex @@ -6,12 +6,12 @@ defmodule LocalLiveView.Mirror do It receives synced payloads from the local runtime via the `handle_sync/2` callback. ``` - defmodule Mirror.MyLive do + defmodule Mirror.MyLocal do use LocalLiveView.Mirror @impl true def handle_sync(local_assigns, _mirror_assigns) do - Phoenix.PubSub.broadcast(MyApp.PubSub, "llv_mirror:MyLive", {:llv_attrs, local_assigns}) + Phoenix.PubSub.broadcast(MyApp.PubSub, "llv_mirror:MyLocal", {:llv_attrs, local_assigns}) {:ok, local_assigns} end end diff --git a/local-live-view/mix.exs b/local-live-view/mix.exs index b217d295..92d39e60 100644 --- a/local-live-view/mix.exs +++ b/local-live-view/mix.exs @@ -47,11 +47,14 @@ defmodule LocalLiveView.MixProject do filter_modules: ~r/^(?!Elixir.Phoenix\.).*/, extras: [ "pages/introduction/welcome.md", - "README.md" + "pages/getting-started/installation.md", + "pages/guides/first-view.md", + "pages/guides/mirror-sync.md" ], groups_for_extras: [ Introduction: ~r"/introduction/", - "Getting started": "README.md" + "Getting started": ~r"/getting-started/", + Guides: ~r"/guides/" ] ] end diff --git a/local-live-view/pages/getting-started/installation.md b/local-live-view/pages/getting-started/installation.md new file mode 100644 index 00000000..9f473a7d --- /dev/null +++ b/local-live-view/pages/getting-started/installation.md @@ -0,0 +1,108 @@ +# Installation + +LocalLiveView is installed into an existing Phoenix project using the `mix llv.install` generator. + +## Prerequisites + +- A Phoenix project generated with `mix phx.new` +- `:local_live_view` added as a dependency +- `pnpm` or `npm` for JS package management + +## Step 1 — Add the dependency + +Add `:local_live_view` to your `mix.exs`: + +```elixir +defp deps do + [ + # ...existing deps... + {:local_live_view, github: "software-mansion/popcorn", sparse: "local-live-view"} + ] +end +``` + +Then fetch it: + +```bash +mix deps.get +``` + +## Step 2 — Run the installer + +```bash +mix llv.install +``` + +The installer configures your project automatically: + +| What | Where | +|---|---| +| Adds `LocalLiveView.Socket` | `lib/*_web/endpoint.ex` | +| Adds COOP/COEP security headers (required for WASM) | `lib/*_web/endpoint.ex` | +| Registers `LocalLiveView.ChannelRegistry` | `lib//application.ex` | +| Imports `LocalLiveView.Component` | `lib/*_web.ex` (html_helpers) | +| Changes app.js script tag to `type="module"` | `lib/*_web/components/layouts/root.html.heex` | +| Adds `setup` call for the JS bridge | `assets/js/app.js` | +| Adds `local_live_view` JS package | `assets/package.json` | +| Replaces esbuild watcher with `build.mjs` | `mix.exs`, `config/dev.exs` | +| Generates the `local/` WASM project | `local/` | + +> **Manual fallback:** If the installer can't find a file (e.g. your project has a non-standard structure), it prints the exact snippet to add manually. + +## Step 3 — Install JS dependencies + +```bash +pnpm install +# or: npm install --prefix assets +``` + +## Step 4 — Build the WASM bundle + +```bash +mix llv.build +``` + +This compiles your `local/` project to a WASM bundle at `priv/static/assets/js/wasm/bundle.avm`. + +To build automatically as part of `mix setup`, the installer already adds `llv.build` to the `setup` alias in `mix.exs`. + +## Step 5 — Start the server + +```bash +mix phx.server +``` + +Visit [localhost:4000](http://localhost:4000). The installer generated a sample `HelloLocal` view — add it to any template to confirm everything works: + +```heex +<.local_live_view view="HelloLocal" /> +``` + +## What was generated + +The installer creates a `local/` directory — a separate Mix project for your client-side Elixir code: + +``` +local/ +├── config/ +│ └── config.exs # Popcorn output path config +├── lib/ +│ ├── local/ +│ │ └── application.ex # OTP application +│ └── hello_local.ex # Sample LocalLiveView +├── .formatter.exs +└── mix.exs # Compiles to WASM via popcorn.cook +``` + +Add your LocalLiveView modules to `local/lib/`. They will be compiled into the WASM bundle when you run `mix llv.build`. + +## Security headers + +LocalLiveView requires [SharedArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer), which browsers only allow with the following HTTP headers set: + +``` +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +The installer adds a `put_wasm_security_headers/2` plug to your endpoint automatically. If you use a CDN or reverse proxy, make sure these headers are forwarded. diff --git a/local-live-view/pages/guides/first-view.md b/local-live-view/pages/guides/first-view.md new file mode 100644 index 00000000..fbb0a8b8 --- /dev/null +++ b/local-live-view/pages/guides/first-view.md @@ -0,0 +1,142 @@ +# Your first LocalLiveView + +This guide walks through building a simple counter view to introduce the LocalLiveView programming model. + +## Creating a view module + +LocalLiveView modules live in the `local/lib/` directory of your project. Create `local/lib/counter_local.ex`: + +```elixir +defmodule CounterLocal do + use LocalLiveView + + def mount(_params, _session, socket) do + {:ok, assign(socket, count: 0)} + end + + def render(assigns) do + ~H""" +
+

Count: {@count}

+ + +
+ """ + end + + def handle_event("increment", _params, socket) do + {:noreply, update(socket, :count, &(&1 + 1))} + end + + def handle_event("decrement", _params, socket) do + {:noreply, update(socket, :count, &(&1 - 1))} + end +end +``` + +This should look familiar if you've used Phoenix LiveView. The differences are: + +- `use LocalLiveView` instead of `use Phoenix.LiveView` + +## Mounting the view + +Use the `<.local_live_view>` component in any Phoenix template: + +```heex +<.local_live_view view="CounterLocal" /> +``` + +The `view` attribute is the module name as a string. The component renders a `
` that becomes the mount point for the WASM view. + +## Building and running + +After adding the module, rebuild the WASM bundle: + +```bash +mix llv.build +``` + +Then start or reload the server: + +```bash +mix phx.server +``` + +The counter is now fully local — clicks are handled in the browser with no server round-trips. + +## Callbacks + +### `mount/3` + +Called once when the view is initialized. Use it to set up initial assigns. + +```elixir +def mount(_params, _session, socket) do + {:ok, assign(socket, count: 0, label: "Counter")} +end +``` + +### `render/1` + +Returns the HEEx template for the current state. Called automatically after every state change. + +```elixir +def render(assigns) do + ~H""" +

{@label}: {@count}

+ """ +end +``` + +### `handle_event/3` + +Handles events triggered from the template. Returns `{:noreply, socket}` with updated assigns. + +```elixir +def handle_event("reset", _params, socket) do + {:noreply, assign(socket, count: 0)} +end +``` + +## Assigning state + +LocalLiveView uses the same assign functions as Phoenix LiveView: + +```elixir +# Assign a single key +assign(socket, :count, 0) + +# Assign multiple keys at once +assign(socket, count: 0, label: "Counter") + +# Update a key using the current value +update(socket, :count, &(&1 + 1)) +``` + +## Timers and periodic updates + +You can schedule recurring messages using `Process.send_after/3`, just like in Phoenix LiveView: + +```elixir +def mount(_params, _session, socket) do + Process.send_after(self(), :tick, 1000) + {:ok, assign(socket, time: Time.utc_now())} +end + +def handle_info(:tick, socket) do + Process.send_after(self(), :tick, 1000) + {:noreply, assign(socket, time: Time.utc_now())} +end +``` + +## Multiple views on one page + +Each `<.local_live_view>` on the page runs as an independent process in the WASM runtime. You can mount as many as you need: + +```heex +<.local_live_view view="CounterLocal" /> +<.local_live_view view="CounterLocal" id="second-counter" /> +<.local_live_view view="ThermostatLocal" /> +``` + +When mounting the same view multiple times, use the `id` attribute to give each instance a unique identifier. diff --git a/local-live-view/pages/guides/mirror-sync.md b/local-live-view/pages/guides/mirror-sync.md new file mode 100644 index 00000000..a7401160 --- /dev/null +++ b/local-live-view/pages/guides/mirror-sync.md @@ -0,0 +1,148 @@ +# Mirror Sync + +By default, a LocalLiveView is fully self-contained in the browser — the server knows nothing about its state. Mirror Sync is the mechanism for bridging that gap: it lets a LocalLiveView push selected assigns to the server, where a server-side module can react to them. + +## When to use it + +Use Mirror Sync when you need the server to be aware of local state, for example: + +- Broadcasting local state changes to other connected users via PubSub +- Persisting user input to a database +- Letting a server-side Phoenix LiveView display or react to local state + +## How it works + +``` +Browser Server +┌─────────────────────┐ ┌───────────────────────────┐ +│ MyLocal │ │ Mirror.MyLocal │ +│ handle_event(...) │ │ handle_sync( │ +│ mirror_sync( │──sync───▶│ local_assigns, │ +│ socket, │ │ mirror_assigns │ +│ [:count] │ │ ) │ +│ ) │ └───────────────────────────┘ +└─────────────────────┘ +``` + +1. Your LocalLiveView calls `mirror_sync/2` with the socket and a list of assign keys. +2. The JS bridge sends those assigns to the server over a Phoenix Channel. +3. The server finds `Mirror.` and calls its `handle_sync/2` callback. + +## Setting up mirror sync + +### 1. Declare the mirror keys + +Pass the keys you want to sync when calling `mirror_sync/2`: + +```elixir +defmodule MyLocal do + use LocalLiveView + + def mount(_params, _session, socket) do + {:ok, assign(socket, count: 0)} + end + + def render(assigns) do + ~H""" + + """ + end + + def handle_event("increment", _params, socket) do + socket = update(socket, :count, &(&1 + 1)) + mirror_sync(socket, [:count]) + {:noreply, socket} + end +end +``` + +`mirror_sync/2` takes the socket and a list of assign keys to send. It returns the socket unchanged, so you can pipe it or ignore the return value. + +### 2. Create the Mirror module + +On the server side, create `lib/mirror/my_live.ex`: + +```elixir +defmodule Mirror.MyLocal do + use LocalLiveView.Mirror + + @impl true + def handle_sync(local_assigns, _mirror_assigns) do + {:ok, local_assigns} + end +end +``` + +The module must be named `Mirror.` — the view name is the last part of your LocalLiveView module name. `LocalLiveView.Component` auto-detects the mirror module and enables the sync channel when rendering the mount point. + +`handle_sync/2` receives: +- `local_assigns` — a map of the synced assigns (keys are strings) +- `mirror_assigns` — the mirror's current state (what was returned from the previous `handle_sync` call) + +It must return `{:ok, new_mirror_assigns}`. + +## Broadcasting via PubSub + +The most common use case is broadcasting local state to other LiveViews: + +```elixir +defmodule Mirror.MyLocal do + use LocalLiveView.Mirror + + @impl true + def handle_sync(local_assigns, _mirror_assigns) do + Phoenix.PubSub.broadcast( + MyApp.PubSub, + "llv_mirror:MyLocal", + {:llv_attrs, local_assigns} + ) + + {:ok, local_assigns} + end +end +``` + +Then subscribe and handle in a server-side LiveView: + +```elixir +defmodule MyAppWeb.DashboardLive do + use MyAppWeb, :live_view + + def mount(_params, _session, socket) do + Phoenix.PubSub.subscribe(MyApp.PubSub, "llv_mirror:MyLocal") + {:ok, assign(socket, count: 0)} + end + + def handle_info({:llv_attrs, %{"count" => count}}, socket) do + {:noreply, assign(socket, count: count)} + end +end +``` + +## Mirror assigns and conflict resolution + +`handle_sync/2` can return different assigns than it received — this is useful when you want the mirror to store derived or enriched state: + +```elixir +def handle_sync(%{"count" => count} = local_assigns, mirror_assigns) do + enriched = Map.put(local_assigns, "total_increments", Map.get(mirror_assigns, "total_increments", 0) + 1) + {:ok, enriched} +end +``` + +The returned value becomes `mirror_assigns` in the next call, but it is **not** sent back to the browser — it only lives on the server. + +## Sync frequency + +`mirror_sync/2` sends over the wire every time it's called. Call it only when state has changed and the server needs to know — typically at the end of `handle_event/3` when relevant assigns were updated. + +## Serialization + +Assigns are serialized as a JSON-compatible map before being sent. The following types are supported: + +- Primitives: strings, numbers, booleans, nil +- Lists +- Maps (keys are converted to strings) +- Structs (converted to maps via `Map.from_struct/1`) + +Atoms, tuples, and PIDs are not serializable and will cause a runtime error if included. diff --git a/local-live-view/pages/introduction/welcome.md b/local-live-view/pages/introduction/welcome.md index 2c889d14..97116436 100644 --- a/local-live-view/pages/introduction/welcome.md +++ b/local-live-view/pages/introduction/welcome.md @@ -1,5 +1,60 @@ # Welcome -LocalLiveView delivers local-first functionality by merging the powers of [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/welcome.html) with [Popcorn](https://hexdocs.pm/popcorn/readme.html). -## What is a LocalLiveView? -LocalLiveView is a project that aims to store the LiveView state in the browser in a virtual machine provided by Popcorn. -It allows to store, update and render the assigns similarly to Phoenix LiveView. + +LocalLiveView brings local-first interactivity to Phoenix applications by running your Elixir code directly in the browser — using the same [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/welcome.html) API you already know. + +## What is LocalLiveView? + +In a standard Phoenix LiveView app, UI state lives on the server. Every interaction travels over the network: user clicks button → server processes event → server sends diff → browser updates DOM. This works great, but it means latency is always in the loop. + +LocalLiveView moves that state into the browser itself. Your Elixir modules are compiled to WebAssembly and executed via [Popcorn](https://hexdocs.pm/popcorn/introduction.html), which runs AtomVM — a tiny Erlang virtual machine — directly in the browser. Events are handled locally, renders happen instantly, and the server is only involved when you explicitly want it to be. + +The result: **zero-latency UI**, offline capability, and a familiar Elixir/LiveView programming model. + +## How it works + +``` +Browser Server +┌──────────────────────────────────────┐ ┌──────────────────┐ +│ Phoenix LiveView page │ │ Phoenix app │ +│ │ │ │ +│ <.local_live_view view="MyLocal"> │ │ Mirror.MyLocal │ +│ │ │ │ (optional) │ +│ ▼ │ │ ▲ │ +│ Popcorn (AtomVM WASM) │─────▶│ mirror_sync │ +│ MyLocal (your Elixir code) │ │ │ +│ ├── mount/3 │ └──────────────────┘ +│ ├── render/1 │ +│ └── handle_event/3 │ +└──────────────────────────────────────┘ +``` + +1. Your `local/` project is compiled to a `.avm` WASM bundle at build time. +2. When the page loads, Popcorn starts the WASM runtime and mounts your LocalLiveViews. +3. User interactions are handled in the browser — no round-trip to the server. +4. Optionally, you can sync selected assigns to the server via `mirror_sync/2`, letting server-side LiveViews react to local state changes. + +## Key concepts + +**LocalLiveView module** — An Elixir module that uses `use LocalLiveView` and implements `mount/3`, `render/1`, and optionally `handle_event/3`. Lives in the `local/` project (compiled to WASM). + +**`local/` project** — A separate Mix project inside your Phoenix app, built via `mix llv.build`. The `local/` project depends on `:local_live_view` and contains all your client-side Elixir code. + +**`<.local_live_view>`** — A Phoenix component that renders the mount point for a LocalLiveView. Generated by `mix llv.install` and available as `import LocalLiveView.Component` in your web module. + +**Mirror** — An optional server-side module (`Mirror.MyLocal`) that receives synced assigns from a LocalLiveView. Useful for broadcasting state changes to other users or persisting data. + +## Relationship to Phoenix LiveView + +LocalLiveView deliberately mirrors the Phoenix LiveView API. If you know LiveView, you already know most of LocalLiveView: + +| Phoenix LiveView | LocalLiveView | +|---|---| +| `use Phoenix.LiveView` | `use LocalLiveView` | +| `mount/3` | `mount/3` | +| `render/1` with `~H` | `render/1` with `~H` | +| `handle_event/3` | `handle_event/3` | +| `assign/2`, `update/3` | `assign/2`, `update/3` | +| `phx-click` | `phx-click` | +| Runs on server | Runs in browser (WASM) | + +The API is intentionally identical — LocalLiveView intercepts Phoenix LiveView's standard JavaScript layer, so event attributes work exactly the same way.