Skip to main content

README.md

# LiveConnected

Defer a Phoenix LiveView's `mount/3` and `handle_params/3` until the socket
connects. **For pages that don't need SSR/SEO**, this skips the expensive
`mount`/`handle_params` work on the static (dead) render and shows a skeleton
instead, running the real work only once the socket connects.

A LiveView's `mount` and `handle_params` run twice on first load — once for the
static HTML that the browser is about to throw away, then again in the connected
LiveView process. For dashboards, authenticated app pages, and anything else
behind a login that search engines never see, the dead-render pass is wasted
work. `LiveConnected` skips it and paints a skeleton.

## Installation

```elixir
def deps do
  [
    {:live_connected, "~> 0.1.0"}
  ]
end
```

## Usage

```elixir
defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view
  use LiveConnected

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :stats, Analytics.expensive_dashboard())}
  end

  def render(assigns) do
    ~H"<Dashboard.stats stats={@stats} />"
  end

  # optional — omit for a generic shimmer skeleton
  def loading(assigns), do: ~H"<Dashboard.skeleton />"
end
```

You write a completely normal LiveView: ordinary `mount/3`, `handle_params/3`,
and `render/1`. No renamed callbacks, no restructured bodies. Adding the behavior
is just `use LiveConnected` plus an optional `loading/1`.

### Ordering: put `use LiveConnected` *after* `use MyAppWeb, :live_view`

```elixir
use MyAppWeb, :live_view
use LiveConnected          # <- must come second
```

That ordering ensures the `~H` sigil and LiveView's callback defaults are in
scope when `LiveConnected` compiles its wrappers around your callbacks. Putting
it first will not work.

## How the deferral works

`mount/3` runs twice on first load: once during the static HTTP render (the
"dead render", where `connected?(socket) == false`), then again in the spawned
LiveView process once the WebSocket connects. `handle_params/3` runs in both
phases too.

`LiveConnected` wraps your own callbacks with `@before_compile` +
`defoverridable` + `super` and decides whether to *call* them per phase. On the
dead render the `mount` wrapper assigns `live_connected?: false` and returns
without calling your `mount`; the `handle_params` wrapper no-ops; and the
`render` wrapper calls `loading/1` instead of your `render/1`. On connect it
assigns `live_connected?: true` and calls `super`, so everything runs normally.
All phase state keys off the single `:live_connected?` assign, read defensively
so a missing flag fails toward "run normally".

## Including the CSS

The default skeleton and `LiveConnected.Skeletons` components are styled by a
dependency-free stylesheet. Import it from your app CSS:

```css
/* assets/css/app.css */
@import "../../deps/live_connected/priv/static/live_connected.css";
```

Adjust the relative depth to match your asset pipeline. The shimmer is CSS-only
(no JS) and disables itself under `prefers-reduced-motion`. Tune the look with
custom properties (`--lc-skeleton-base`, `--lc-skeleton-shine`, etc.).

### Reusable skeleton components

To avoid layout shift, match your eventual layout with the optional components:

```elixir
def loading(assigns) do
  ~H"<LiveConnected.Skeletons.table rows={5} cols={4} />"
end
```

`card/1`, `table/1`, and `list/1` are available. They are small and optional.

## Opting out

When a page later needs SSR/SEO, make it behave like a normal LiveView:

```elixir
use LiveConnected, enabled: false
```

It then runs everything in both phases — no deferral, no skeleton.

## Caveats

### Template-file LiveViews

If your LiveView renders from a colocated `.heex` template file instead of a
`render/1`, there is no `render/1` for `LiveConnected` to wrap, so the skeleton
cannot take over the dead render. `LiveConnected` emits a compile-time warning in
this case. Add a `render/1` (even a one-liner that calls your template) to get
the skeleton.

### Status codes and redirects move to the connected phase

Deferring `handle_params` means "resource missing → 404" or an ownership
redirect now happens **on connect**, not on the dead render. The static response
becomes a `200` with a skeleton, and the user sees a brief flash of skeleton
before any bounce. For the SEO-irrelevant pages this library targets, that is
acceptable — but be aware of it.

When you need a correct dead-render status, use the **cheap-guard pattern**: keep
a trivial existence/authz check that runs in both phases, and defer only the
expensive load. Branch on `@live_connected?` inside your own callback:

```elixir
def handle_params(%{"id" => id}, _uri, socket) do
  cond do
    not socket.assigns.live_connected? ->
      # cheap guard runs on the dead render -> correct 404
      if Catalog.exists?(id),
        do: {:noreply, socket},
        else: {:noreply, push_navigate(socket, to: ~p"/not-found")}

    true ->
      {:noreply, assign(socket, :product, Catalog.full_product!(id))}
  end
end
```

This still relies on `use LiveConnected` calling `handle_params` on the dead
render with `live_connected?: false` assigned, so your `not @live_connected?`
branch runs the cheap check while the expensive branch is skipped until connect.
The tradeoff: you write one explicit branch, but you keep a correct status code
without giving up deferral of the heavy work.

### Live patches need no special handling

After the first connect the LiveView is always connected, so deferred
`handle_params` runs normally on every `push_patch` / `<.link patch>`. There is
nothing to configure.

## How this relates to `assign_async`

`assign_async`/`start_async` solve non-blocking loads *within* the connected
view — they let the connected render paint immediately and stream data in as it
arrives. `LiveConnected` is about skipping the dead-render work *entirely*. They
are complementary and combine well: use `LiveConnected` to skip the static pass,
then `assign_async` inside your connected `mount` to keep the connected render
snappy.

## Non-goals

This is intentionally a thin, ~one-file wrapper over the `connected?/1` pattern.
Its value is ergonomics, the skeleton, and the opt-out — not cleverness. If you
need SSR/SEO, you want a normal LiveView (or `enabled: false`); this library is
deliberately not that.

## License

MIT — see [LICENSE](LICENSE).