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$setinupdated(), and tears the component down indestroyed(), - hands the component a
liveobject (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
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 underassets/apps/, resolved at runtime to/apps/<name>/main.mjs(or a registered/CDN URL — seeKeenPhoenixSvelte.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 acomponentkey inprops(see below). Defaults tonil(unset).class- optional class list on the wrapper div.tag- wrapper element, defaults to"div".eager- mount before the LiveView socket connects. Defaults tofalse.
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:
- A
<:placeholder>slot on this call — full HEEx, overrides everything. An empty slot (<:placeholder />) disables the placeholder for this app. - The server-wide default,
config :keen_phoenix_svelte, :placeholder. - 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: falsePer 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 acomponentkey inprops. Defaults tonil.class(:any) - Defaults tonil.tag(:string) - Defaults to"div".eager(:boolean) - Defaults tofalse.- Global attributes are accepted.
Slots
placeholder- Markup shown until the island mounts. Overrides the server-wide default.
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), orfalse(none). Defaults to:auto.