KeenPhoenixSvelte (KeenPhoenixSvelte v1.0.0-rc.9)

Copy Markdown View Source

Auto-mount compiled client apps ("islands") inside Phoenix — LiveView or plain pages.

Svelte is the first-class, tooled path (hence the name), but the mount boundary is framework-neutral: any island whose JS entry default-exports (target, { props, context, live, api, channel, bus, el }) => { setProps, destroy } works — Svelte, Lit, React, or hand-written vanilla JS. Because every island mounts through that one contract, there is a single component, app/1 — the framework makes no difference to how it's rendered or mounted.

This library ships two cooperating halves:

  • an Elixir function component (app/1) that renders a placeholder <div> carrying a LiveView hook, and
  • a JavaScript hook + AppsManager (published as the npm package @keenmate/phoenix_svelte) that lazily imports the compiled bundle for the requested app and mounts it into that div.

Why a hook (and not data-app + window.load)

LiveView owns the DOM. A component mounted by hand on window.load fights morphdom on the next patch and never re-mounts across live navigation. The hook instead:

  • uses phx-update="ignore" so LiveView never touches the Svelte-owned subtree,
  • mounts in mounted(), pushes fresh props via $set in updated(), and tears the component down in destroyed(),
  • hands the component a live object (pushEvent, handleEvent, ...) so it talks to the server over the existing LiveView socket.

Usage

<.app name="like" id={"like-#{@id}"} props={%{id: @id, liked: @liked}} />

The id must be unique and stable (required by LiveView hooks). Props are JSON-encoded into a data attribute and parsed back on the client, so nested maps, numbers and booleans survive the trip.

Summary

Functions

Renders a mount point for the compiled client app ("island") named name.

Emits the page-wide runtime context, read once by the client and injected into every mounted app as context.

Functions

app(assigns)

Renders a mount point for the compiled client app ("island") named name.

This is the one and only island component. It is framework-neutral: the bundle can be Svelte, Lit, React or vanilla JS, and it is rendered and mounted identically regardless — the framework never changes the wiring.

Attributes

  • name (required) - the app folder name under assets/apps/, resolved at runtime to /apps/<name>/main.mjs (or a registered/CDN URL — see KeenPhoenixSvelte.Apps).
  • id (required) - unique, stable DOM id (LiveView hooks require it).
  • props - a map passed to the app. Defaults to %{}.
  • component - selects which view a multi-component app renders. Pure sugar for a component key in props (see below). Defaults to nil (unset).
  • class - optional class list on the wrapper div.
  • tag - wrapper element, defaults to "div".
  • eager - mount before the LiveView socket connects. Defaults to false.

Any other attribute (e.g. data-*, style) is forwarded to the wrapper via the :global attribute.

Selecting a component (multi-component apps)

Independent apps each bundle their own framework runtime. When several views belong together, ship them as one app (one bundle, one shared runtime) and let each <.app> pick a view with component:

<.app name="widgets" id="w-chart" component="chart" props={%{series: @series}} />
<.app name="widgets" id="w-table" component="table" props={%{rows: @rows}} />

Both mount from the single widgets bundle — imported once, mounted twice — so the runtime is paid once no matter how many views the page shows.

component is exactly sugar for a component entry in props: the two calls below are identical. It wins over a :component key already in props.

<.app name="widgets" id="w" component="table" props={%{rows: @rows}} />
<.app name="widgets" id="w" props={%{component: "table", rows: @rows}} />

The island's entry reads props.component and mounts the matching view:

const VIEWS = { chart: Chart, table: Table };
export default (target, { props, ...rest }) => {
  const Comp = VIEWS[props.component] ?? Chart;
  // ...mount Comp...
};

Eager mounting

By default, on a LiveView page the island mounts from the KeenApp hook's mounted() — which cannot run until the socket connects and the view mounts. On a cold first load that connect round-trip is often the biggest slice of the delay between "bundle loaded" and "first paint".

With eager={true}, the island is mounted immediately by mountStatic() (as the deferred app.js parses), the same path a plain page uses — so it paints without waiting for the socket. It mounts with live: null and a boundary field liveStatus: "pending"; once the socket connects, the hook hands it the live bridge and dispatches a keen:live-ready event on the wrapper element.

Use it for islands that don't need live to render their first frame — e.g. ones that fetch from api, a channel, or a different server entirely. An island that must have live to paint should stay on the default (non-eager) path. On a plain page eager is a no-op (there is never a live there; liveStatus is "none"). See the guide External apps → First render.

Placeholder / loader (no flash of empty container)

Until the compiled bundle is fetched and mounted, the wrapper would otherwise be empty. To avoid that flash, the wrapper is rendered with a placeholder that the client clears the instant it mounts the app (after the bundle loads, so it stays visible for the whole fetch). Because the wrapper is phx-update="ignore", the placeholder is rendered once and never re-diffed.

Resolution, most specific first:

  1. A <:placeholder> slot on this call — full HEEx, overrides everything. An empty slot (<:placeholder />) disables the placeholder for this app.
  2. The server-wide default, config :keen_phoenix_svelte, :placeholder.
  3. A built-in, dependency-free skeleton (used when nothing is configured).

Configure the server-wide default once (e.g. in config/config.exs):

# raw HTML string
config :keen_phoenix_svelte, placeholder: ~s(<div class="my-skeleton"></div>)

# or a function (1-arity gets the app name), or false to disable globally
config :keen_phoenix_svelte, placeholder: &MyApp.app_loader/1
config :keen_phoenix_svelte, placeholder: false

Per app, override with the slot:

<.app name="chart" id="chart" props={@cfg}>
  <:placeholder>
    <div class="skeleton h-64 w-full"></div>
  </:placeholder>
</.app>

Attributes

  • name (:string) (required)
  • id (:string) (required)
  • props (:map) - Defaults to %{}.
  • component (:string) - Selects a view in a multi-component app; sugar for a component key in props. Defaults to nil.
  • class (:any) - Defaults to nil.
  • tag (:string) - Defaults to "div".
  • eager (:boolean) - Defaults to false.
  • Global attributes are accepted.

Slots

  • placeholder - Markup shown until the island mounts. Overrides the server-wide default.

runtime(assigns)

Emits the page-wide runtime context, read once by the client and injected into every mounted app as context.

Render it once per page (e.g. in your root layout), on both LiveView and plain controller-rendered pages:

<KeenPhoenixSvelte.runtime context={%{
  user: %{id: @current_user.id, name: @current_user.name, roles: @roles},
  csrf_token: get_csrf_token(),
  api_base: "/api"
}} />

Keep this to context, not payload — user identity, a CSRF token for the api helper, an api_base, locale, and any tokens the app needs. Per-app data belongs in each <.app props={...} />.

The context is JSON-encoded with HTML-safe escaping so it is safe to embed in the <script type="application/json"> tag.

Preloading bundles

An island's bundle is normally fetched lazily by the client — on a LiveView page that import() doesn't fire until the socket connects and the hook mounts, so the download starts hundreds of ms into the page. preload has the browser fetch the bundles during initial HTML parse instead, via <link rel="modulepreload">, so the bytes are cached by the time the hook runs (the render still waits on mount — you're only moving the download earlier).

By default this is automatic: every <.app> rendered on the page records itself, and <.runtime> preloads exactly those bundles — no per-page list to maintain, and nothing preloaded that the page doesn't mount:

<KeenPhoenixSvelte.runtime context={@ctx} />

Auto-detection works because the page body (where the <.app> tags live) is rendered before the root layout's <head> (where <.runtime> goes), so the runtime already knows which islands the page mounted. Keep <.runtime> in a layout that wraps the page content (the standard root-layout placement) — if it renders before the <.app> tags, auto sees nothing and preloads nothing (harmless: it just falls back to lazy loading).

preload accepts:

  • :auto (default) — the apps actually rendered on this page. The right choice almost always; scopes to exactly what's on the page with no manual list.
  • a list of app names — preload exactly those. Use it to override auto (e.g. preload an app a later interaction will mount). Registered apps use their manifest URL; a local app falls back to base_path/<name>/main.mjs.
  • true — preload every app in the manifest, whether or not it's on this page. Rarely what you want; auto is page-scoped and needs no registry.
  • false — emit nothing (opt back into fully lazy loading).

A cross-origin (:direct) URL gets crossorigin="anonymous" so the preload's credentials mode matches the module import() and the fetch is actually reused.

Only the entry bundle is preloaded. A multi-file island's sibling assets (a stylesheet or data file it pulls via new URL("./x.css", import.meta.url)) are discovered inside the bundle at mount time, so the server can't know their URLs to preload them. If a sibling's early load matters, inline it into the JS (single-file island) or add your own <link rel="preload"> for a known filename.

Attributes

  • context (:map) - Defaults to %{}.
  • id (:string) - Defaults to "keen-context".
  • preload (:any) - Emit <link rel=modulepreload> for app bundles: :auto (default — the apps actually rendered on this page), a list of app names, true (all manifest apps), or false (none). Defaults to :auto.