chore: use phoenix_vite for live svelte

This commit is contained in:
Denis Donici 2026-03-06 00:39:36 +02:00
parent 515b9cfeaa
commit d7bc8425eb
31 changed files with 17617 additions and 357 deletions

View file

@ -118,7 +118,7 @@ defp deps do
end
```
2. Fetch and run the installer (this adds `live_svelte`, configures Vite, app.js, html_helpers, SSR, and more):
2. Fetch and run the installer (this adds `live_svelte`, configures [phoenix_vite](https://github.com/LostKobrakai/phoenix_vite), Vite, app.js, html_helpers, SSR, layout with `PhoenixVite.Components.assets`, and more):
```bash
mix deps.get
@ -128,9 +128,8 @@ mix igniter.install live_svelte
3. Install npm packages and build assets:
```bash
npm install # from the assets/ directory, or:
# cd assets && npm install && cd ..
mix assets.js # runs both Vite builds (client + SSR) + Tailwind
mix assets.setup # phoenix_vite.npm assets install
mix assets.build # Vite client + SSR builds (or run mix setup to do both)
```
4. Start the server:
@ -139,6 +138,8 @@ mix assets.js # runs both Vite builds (client + SSR) + Tailwind
mix phx.server
```
With phoenix_vite, the layout uses `PhoenixVite.Components.assets` and the endpoint uses `PhoenixVite.Plug`; no separate Vite terminal is required for dev — phoenix_vite integrates the Vite dev server.
Visit `http://localhost:4000/svelte_demo` to confirm the demo Svelte component is working.
Add `--bun` to use Bun instead of npm/npx:
@ -181,26 +182,32 @@ defp html_helpers do
end
```
**4.** Add the `assets.js` alias and update `assets.deploy` in `mix.exs`:
**4.** Add phoenix_vite and configure mix aliases (or use the Igniter installer which does this). With phoenix_vite:
```elixir
defp aliases do
[
# ...
"assets.js": [
"cmd --cd assets npx vite build",
"cmd --cd assets npx vite build --config vite.ssr.config.js",
"tailwind default"
],
"assets.deploy": [
"cmd --cd assets npx vite build --mode production",
"cmd --cd assets npx vite build --config vite.ssr.config.js --mode production",
"phx.digest"
]
# mix.exs deps
{:phoenix_vite, "~> 0.4"}
# config/config.exs
config :phoenix_vite, PhoenixVite.Npm,
assets: [args: [], cd: __DIR__],
vite: [
args: ~w(exec -- vite),
cd: Path.expand("../assets", __DIR__),
env: %{"MIX_BUILD_PATH" => Mix.Project.build_path()}
]
end
# mix.exs aliases
"assets.setup": ["phoenix_vite.npm assets install"],
"assets.build": [
"phoenix_vite.npm vite build --manifest --emptyOutDir true",
"phoenix_vite.npm vite build --ssrManifest --emptyOutDir false --ssr js/server.js --outDir ../priv/svelte"
],
"assets.deploy": ["assets.build", "phx.digest"]
```
Use `PhoenixVite.Components.assets` in your root layout and `import PhoenixVite.Plug` + `plug :favicon, dev_server: {PhoenixVite.Components, :has_vite_watcher?, [__MODULE__]}` in the endpoint. Without phoenix_vite, use `LiveSvelte.Reload.vite_assets` in the layout and run Vite manually.
**5.** Update `assets/vite.config.mjs` to add the Svelte and LiveSvelte plugins:
```js
@ -215,25 +222,7 @@ plugins: [
]
```
**6.** Create `assets/vite.ssr.config.js`:
```js
import { defineConfig } from "vite"
import { svelte } from "@sveltejs/vite-plugin-svelte"
import liveSveltePlugin from "live_svelte/vitePlugin"
export default defineConfig({
plugins: [svelte(), liveSveltePlugin({ entrypoint: "./js/server.js" })],
ssr: { noExternal: true },
build: {
ssr: "./js/server.js",
outDir: "../priv/svelte",
rollupOptions: {
output: { entryFileNames: "server.js", format: "es" }
}
}
})
```
**6.** Add `ssr: { noExternal: process.env.NODE_ENV === "production" ? true : undefined }` to the main `vite.config.mjs` so the same config is used for both client and SSR builds. The SSR build is run via `phoenix_vite.npm vite build --ssr js/server.js --outDir ../priv/svelte` (see aliases above). No separate `vite.ssr.config.js` is required when using phoenix_vite.
**7.** Create `assets/js/server.js`:

View file

@ -0,0 +1,59 @@
// Client-side entry point for both Vite dev server (HMR) and production builds.
import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "topbar"
import {getHooks} from "live_svelte"
import Components from "virtual:live-svelte-components"
function formatPayload(str) {
if (!str || str === "—") return "—"
try {
return JSON.stringify(JSON.parse(str), null, 2)
} catch {
return str
}
}
const PropsDiffPayloadDisplay = {
mounted() {
this.updateDisplays()
const root = this.el
const diffOnEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='true']")
const diffOffEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='false']")
const observer = new MutationObserver(() => this.updateDisplays())
if (diffOnEl) observer.observe(diffOnEl, {attributes: true, attributeFilter: ["data-props"]})
if (diffOffEl) observer.observe(diffOffEl, {attributes: true, attributeFilter: ["data-props"]})
this._observer = observer
},
updated() {
this.updateDisplays()
},
destroyed() {
this._observer?.disconnect()
},
updateDisplays() {
const root = this.el
const diffOnEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='true']")
const diffOffEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='false']")
const preOn = root.querySelector("#payload-display-diff-on")
const preOff = root.querySelector("#payload-display-diff-off")
if (preOn) preOn.textContent = formatPayload(diffOnEl?.getAttribute("data-props") ?? "—")
if (preOff) preOff.textContent = formatPayload(diffOffEl?.getAttribute("data-props") ?? "—")
},
}
const Hooks = {
...getHooks(Components),
PropsDiffPayloadDisplay,
}
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
liveSocket.connect()
window.liveSocket = liveSocket

View file

@ -0,0 +1,5 @@
// SSR entry point for LiveSvelte (used by --ssr js/server.js).
import { getRender } from "live_svelte"
import Components from "virtual:live-svelte-components"
export const render = getRender(Components)

View file

@ -1,20 +0,0 @@
{
"type": "module",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/lodash": "^4.14.192",
"daisyui": "^5.0.0",
"dynamic-marquee": "^2.6.2",
"lodash": "^4.17.21",
"stylus": "^0.55.0",
"svelte": "^5.53.7",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"live_svelte": "file:../..",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view"
}
}

View file

@ -1,12 +1,12 @@
<script lang="ts">
import Child from "./Child.svelte"
// import Child from "./Child.svelte"
let {children}: { children?: import("svelte").Snippet } = $props()
</script>
<div class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden md:w-md p-5">
<h3 class="badge badge-outline badge-sm font-medium text-base-content/70">Parent Svelte component</h3>
<div class="card-body">
<Child onClick={() => alert("Event triggered in child and handled in parent")} />
<!-- <Child onClick={() => alert("Event triggered in child and handled in parent")} /> -->
<div class="mt-5">
<h5 class="badge badge-outline badge-sm font-medium text-base-content/70">Slot Item</h5>
{@render children?.()}

View file

@ -9,7 +9,7 @@
<p class="text-sm text-base-content/50">This text was rendered by the server before JavaScript loaded.</p>
<div class="flex items-center justify-center gap-4">
<button data-testid="ssr-increment" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 w-fit" onclick={() => clicks++}>
Click me
Click me !!!
</button>
<span data-testid="click-count" class="font-mono">{clicks}</span>
</div>

View file

@ -1,43 +1,41 @@
import { defineConfig } from "vite"
import { svelte } from "@sveltejs/vite-plugin-svelte"
import liveSveltePlugin from "live_svelte/vitePlugin"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
import path from "path"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [
svelte({ compilerOptions: { css: "injected" } }),
liveSveltePlugin({ entrypoint: "./js/server.vite.js" }),
],
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
cors: { origin: "http://localhost:4000" },
},
optimizeDeps: {
include: ["live_svelte", "phoenix", "phoenix_html", "phoenix_live_view"],
},
ssr: { noExternal: process.env.NODE_ENV === "production" ? true : undefined },
build: {
manifest: false,
ssrManifest: false,
rollupOptions: {
input: ["js/app.js", "css/app.css"],
},
outDir: "../priv/static",
emptyOutDir: true,
},
resolve: {
// Explicit alias so Vite always resolves live_svelte to library TypeScript
// source, regardless of package.json export condition availability.
alias: {
"@": ".",
live_svelte: path.resolve(__dirname, "../../assets/js/live_svelte/index.ts"),
},
},
build: {
commonjsOptions: { include: [/vendor\//, /node_modules\//] },
target: "es2020",
outDir: "../priv/static/assets",
emptyOutDir: true,
sourcemap: false,
manifest: false,
rollupOptions: {
input: {
app: path.resolve(__dirname, "./js/app.vite.js"),
},
output: {
entryFileNames: "[name].js",
chunkFileNames: "[name].js",
assetFileNames: "[name][extname]",
},
},
},
server: {
host: "localhost",
port: 5173,
},
plugins: [
tailwindcss(),
svelte({ compilerOptions: { css: "injected" } }),
liveSveltePlugin({ entrypoint: "./js/server.js" }),
],
})

View file

@ -1,34 +0,0 @@
import { defineConfig } from "vite"
import { svelte } from "@sveltejs/vite-plugin-svelte"
import liveSveltePlugin from "live_svelte/vitePlugin"
import { fileURLToPath } from "url"
import path from "path"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [
svelte(),
liveSveltePlugin({ entrypoint: "./js/server.vite.js" }),
],
resolve: {
alias: {
live_svelte: path.resolve(__dirname, "../../assets/js/live_svelte/index.ts"),
},
},
ssr: {
// Bundle all dependencies into the output file so it works as a standalone
// Node.js module (mirrors the old esbuild `bundle: true` behavior).
noExternal: true,
},
build: {
ssr: "./js/server.vite.js",
outDir: "../priv/svelte",
rollupOptions: {
output: {
entryFileNames: "server.js",
format: "es",
},
},
},
})

View file

@ -7,6 +7,14 @@
# General application configuration
import Config
config :phoenix_vite, PhoenixVite.Npm,
assets: [args: [], cd: __DIR__],
vite: [
args: ~w(exec -- vite),
cd: Path.expand("../assets", __DIR__),
env: %{"MIX_BUILD_PATH" => Mix.Project.build_path()}
]
config :example,
ecto_repos: [Example.Repo]

View file

@ -17,7 +17,7 @@ defmodule ExampleWeb do
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt js css)
def router do
quote do

View file

@ -7,11 +7,12 @@
<.live_title suffix=" · Phoenix Framework">
{assigns[:page_title] || "Example"}
</.live_title>
<LiveSvelte.Reload.vite_assets assets={["/js/app.vite.js"]}>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</LiveSvelte.Reload.vite_assets>
<PhoenixVite.Components.assets
names={["js/app.js", "css/app.css"]}
manifest={{:example, "priv/static/.vite/manifest.json"}}
dev_server={PhoenixVite.Components.has_vite_watcher?(ExampleWeb.Endpoint)}
to_url={fn p -> static_url(@conn, p) end}
/>
</head>
<body class="bg-base-200 antialiased" data-theme="light">
{@inner_content}

View file

@ -1,5 +1,6 @@
defmodule ExampleWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :example
import PhoenixVite.Plug
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
@ -13,6 +14,8 @@ defmodule ExampleWeb.Endpoint do
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
plug :favicon, dev_server: {PhoenixVite.Components, :has_vite_watcher?, [__MODULE__]}
# In test, prevent caching of assets so E2E always loads the latest app.js after mix assets.js
if Mix.env() == :test do
plug :no_cache_assets

View file

@ -37,7 +37,7 @@ defmodule ExampleWeb.Router do
live("/live-id-list-diff", LiveIdListDiff)
live("/live-slots-simple", LiveSlotsSimple)
live("/live-slots-dynamic", LiveSlotsDynamic)
live("/live-slots-nested", LiveSlotsNested)
# live("/live-slots-nested", LiveSlotsNested)
live("/live-client-side-loading", LiveClientSideLoading)
# Ecto Examples
live("/live-notes-otp", LiveNotesOtp)

View file

@ -52,6 +52,7 @@ defmodule Example.MixProject do
{:wallaby, "~> 0.30", runtime: false, only: :test},
{:phoenix_test, "~> 0.9", only: :test},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_vite, "~> 0.4"},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev}
]
end
@ -64,24 +65,20 @@ defmodule Example.MixProject do
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup", "cmd --cd assets npm install"],
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.js": [
"cmd --cd assets npx vite build",
"cmd --cd assets npx vite build --config vite.ssr.config.js",
"tailwind default"
"assets.setup": ["phoenix_vite.npm assets install", "tailwind.install --if-missing"],
"assets.build": [
"phoenix_vite.npm vite build --manifest --emptyOutDir true",
"phoenix_vite.npm vite build --ssrManifest --emptyOutDir false --ssr js/server.js --outDir ../priv/svelte"
],
"test.e2e": ["assets.js", "ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.setup": ["tailwind.install --if-missing"],
"assets.build": ["tailwind default"],
"assets.deploy": [
"cmd --cd assets npx vite build",
"cmd --cd assets npx vite build --config vite.ssr.config.js",
"tailwind default --minify",
"assets.build",
"phx.digest"
]
],
"test.e2e": ["assets.build", "ecto.create --quiet", "ecto.migrate --quiet", "test"]
]
end
end

17305
example_project/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
{
"type": "module",
"dependencies": {
"live_svelte": "file:../",
"phoenix": "file:./deps/phoenix",
"phoenix_html": "file:./deps/phoenix_html",
"phoenix_live_view": "file:./deps/phoenix_live_view",
"topbar": "^3.0.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.0",
"daisyui": "^5.0.0",
"phoenix_vite": "file:./deps/phoenix_vite",
"svelte": "^5.53.7",
"tailwindcss": "^4.1.0",
"typescript": "^5.9.3",
"vite": "^6.3.0"
}
}

View file

@ -0,0 +1,17 @@
{
"css/app.css": {
"file": "assets/app-DqfLVfXj.css",
"src": "css/app.css",
"isEntry": true,
"name": "app",
"names": [
"app.css"
]
},
"js/app.js": {
"file": "assets/app-BVVDu27N.js",
"name": "app",
"src": "js/app.js",
"isEntry": true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,5 +0,0 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View file

@ -1,5 +0,0 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View file

@ -129,7 +129,9 @@ Without `@derive`, passing a struct as a prop will raise an error.
---
### `LiveSvelte.Reload` / `vite_assets/0`
### `LiveSvelte.Reload` / `vite_assets/1`
When using the Igniter installer with phoenix_vite, the layout uses `PhoenixVite.Components.assets` instead. Use `LiveSvelte.Reload.vite_assets/1` when not using phoenix_vite (e.g. manual setup).
HMR helper for development. Includes the Vite dev server client script.

View file

@ -11,26 +11,23 @@ Deploying a LiveSvelte application requires Node.js on the server for SSR (serve
```bash
# 1. Build client bundle and SSR bundle
mix assets.js
mix assets.build
# 2. Compile application (copies SSR bundle to _build)
mix compile
# OR in a single release command:
MIX_ENV=prod mix assets.js && MIX_ENV=prod mix release
MIX_ENV=prod mix assets.build && MIX_ENV=prod mix release
```
### What `mix assets.js` Does
### What `mix assets.build` Does
The `assets.js` alias runs (in order):
The `assets.build` alias runs (in order):
1. `npx vite build` — builds the client JavaScript bundle to `priv/static/assets/`
2. `npx vite build --config vite.ssr.config.js` — builds the SSR bundle to `priv/svelte/server.js`
3. `tailwind default` — builds CSS to `priv/static/assets/app.css`
1. `phoenix_vite.npm vite build --manifest --emptyOutDir true` — client bundle (and CSS when using Tailwind via Vite) to `priv/static/`
2. `phoenix_vite.npm vite build --ssrManifest ... --ssr js/server.js --outDir ../priv/svelte` — SSR bundle to `priv/svelte/server.js`
> #### Vite Must Run Before Tailwind {: .warning}
>
> The Vite client build uses `emptyOutDir: true` and clears `priv/static/assets/` first. Always run Vite before Tailwind, or Tailwind's `app.css` will be deleted. The `mix assets.js` alias handles this order correctly.
> The same `assets/vite.config.mjs` is used for both builds; phoenix_vite runs the second command with different CLI flags.
## NodeJS Supervisor
@ -68,15 +65,15 @@ config :live_svelte,
## SSR Bundle
The SSR bundle (`priv/svelte/server.js`) is:
- Built from `assets/vite.ssr.config.js`
- Built via the same `assets/vite.config.mjs` with `--ssr js/server.js --outDir ../priv/svelte`
- Fully self-contained (all dependencies bundled, `ssr: { noExternal: true }`)
- Required to be present at application start when `ssr_module: LiveSvelte.SSR.NodeJS`
After `mix assets.js`, `mix compile` copies `priv/svelte/server.js` into `_build/`. This copy in `_build/` is what NodeJS.Supervisor actually loads at runtime.
After `mix assets.build`, `mix compile` copies `priv/svelte/server.js` into `_build/`. This copy in `_build/` is what NodeJS.Supervisor actually loads at runtime.
> #### Always Compile After Building SSR Bundle {: .info}
>
> After `mix assets.js`, run `mix compile` so `_build/` gets the updated SSR bundle. In a CI/CD pipeline, ensure both steps run.
> After `mix assets.build`, run `mix compile` so `_build/` gets the updated SSR bundle. In a CI/CD pipeline, ensure both steps run.
## Docker Deployment
@ -89,7 +86,7 @@ WORKDIR /app
COPY assets/ assets/
COPY deps/ deps/
RUN cd assets && npm install
RUN mix assets.js
RUN mix assets.build
FROM elixir:1.17-slim AS release-builder
# ... standard Elixir release steps ...
@ -159,5 +156,5 @@ When upgrading LiveSvelte versions:
1. Update `{:live_svelte, "~> x.y"}` in `mix.exs`
2. Run `mix deps.get`
3. Check `CHANGELOG.md` for breaking changes
4. Rebuild: `mix assets.js && mix compile`
4. Rebuild: `mix assets.build && mix compile`
5. Run tests: `mix test`

View file

@ -40,8 +40,8 @@ mix igniter.install live_svelte --bun
### Step 3: Install JS dependencies and build
```bash
cd assets && npm install && cd ..
mix assets.js
mix assets.setup # phoenix_vite.npm assets install (or: mix setup)
mix assets.build
mix phx.server
```
@ -51,21 +51,12 @@ Visit `/svelte_demo` to verify the installation with the generated demo componen
Running `mix igniter.install live_svelte` makes the following changes to your project:
**`assets/package.json`** — adds:
- `live_svelte: "file:../deps/live_svelte"` (dependency)
- `svelte: "^5.0.0"` (dev dependency)
- `@sveltejs/vite-plugin-svelte` (dev dependency)
**`package.json`** (at project root) — the installer moves it from `assets/` and adds:
- `live_svelte`, `phoenix_vite: "file:./deps/phoenix_vite"` (dev), and Svelte-related deps
**`assets/vite.config.mjs`** — adds the Svelte plugin and `liveSveltePlugin`:
```js
import { svelte } from "@sveltejs/vite-plugin-svelte"
import { liveSveltePlugin } from "live_svelte/vitePlugin"
**`config/config.exs`** — adds `config :phoenix_vite, PhoenixVite.Npm, ...`
// ...
plugins: [svelte(), liveSveltePlugin()],
```
**`assets/vite.ssr.config.js`** — new file for the SSR bundle (Node.js server rendering)
**`assets/vite.config.mjs`** — adds the Svelte plugin, `liveSveltePlugin`, and `ssr: { noExternal: ... }`. A single config is used for both client and SSR builds (no separate `vite.ssr.config.js`); the SSR build is run via `phoenix_vite.npm vite build --ssr js/server.js --outDir ../priv/svelte`.
**`assets/js/app.js`** — adds hook wiring:
```js
@ -104,12 +95,12 @@ config :live_svelte,
ssr: true
```
**`mix.exs`** — adds the `assets.js` alias that runs both Vite builds plus Tailwind:
**`mix.exs`** — adds phoenix_vite-driven aliases: `assets.setup`, `assets.build` (client + SSR via `phoenix_vite.npm vite build`):
```elixir
"assets.js": [
"cmd npx vite build",
"cmd npx vite build --config vite.ssr.config.js",
"tailwind default"
"assets.setup": ["phoenix_vite.npm assets install", "tailwind.install --if-missing"],
"assets.build": [
"phoenix_vite.npm vite build --manifest --emptyOutDir true",
"phoenix_vite.npm vite build --ssrManifest --emptyOutDir false --ssr js/server.js --outDir ../priv/svelte"
]
```

View file

@ -28,7 +28,7 @@ config :live_svelte,
The SSR bundle is built by:
```bash
mix assets.js # runs: npx vite build --config vite.ssr.config.js
mix assets.build # runs phoenix_vite.npm vite build (client + SSR)
```
This produces `priv/svelte/server.js`, which the NodeJS supervisor loads on application start.
@ -79,7 +79,7 @@ Components with `ssr={false}` render a loading slot or nothing on the first pain
When running with `LiveSvelte.SSR.ViteJS`, changes to Svelte files trigger automatic hot module replacement. The `SvelteHook` re-mounts affected components without a full page reload.
Add the `LiveSvelte.Reload` module to your layouts to enable this:
When using phoenix_vite, the layout uses `PhoenixVite.Components.assets` and the Vite dev server is integrated automatically. When not using phoenix_vite, add the `LiveSvelte.Reload` module to your layouts to enable this:
```elixir
# config/dev.exs — added by the Igniter installer
@ -87,13 +87,13 @@ config :live_svelte,
vite_host: "http://localhost:5173"
```
Use `vite_assets/0` in your layout to include Vite's HMR client:
When not using phoenix_vite, use `LiveSvelte.Reload.vite_assets/1` in your layout to include Vite's HMR client and production fallback:
```heex
<!-- In your root layout (dev only) -->
<%= if Application.get_env(:live_svelte, :ssr_module) == LiveSvelte.SSR.ViteJS do %>
<LiveSvelte.Reload.vite_assets path="/assets/js/app.js" />
<% end %>
<LiveSvelte.Reload.vite_assets assets={["/js/app.js"]}>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}></script>
</LiveSvelte.Reload.vite_assets>
```
## Loading Slot

View file

@ -10,10 +10,10 @@ The LiveSvelte example project has two complementary test layers: fast server-si
>
> ```bash
> cd example_project
> mix assets.js && mix compile
> mix assets.build && mix compile
> ```
>
> `mix assets.js` runs Vite builds (client + SSR). `mix compile` copies the updated SSR bundle into `_build/`. Forgetting this step is the most common cause of "my JS changes have no effect" test failures.
> `mix assets.build` runs Vite builds (client + SSR). `mix compile` copies the updated SSR bundle into `_build/`. Forgetting this step is the most common cause of "my JS changes have no effect" test failures.
## PhoenixTest (Server-Side, Fast)
@ -121,13 +121,13 @@ sudo apt-get install chromium-driver
```bash
# Server-side only (fast, no browser needed)
mix assets.js && mix test --only phoenix_test
mix assets.build && mix test --only phoenix_test
# Browser E2E only
mix assets.js && mix test --only e2e
mix assets.build && mix test --only e2e
# Everything
mix assets.js && mix test
mix assets.build && mix test
```
## `LiveSvelte.Test` — Component Introspection

View file

@ -11,20 +11,20 @@ Common issues encountered when using LiveSvelte, and how to resolve them.
**Fix:**
```bash
cd example_project
mix assets.js && mix compile
mix assets.build && mix compile
```
`mix assets.js` runs Vite builds (client + SSR). `mix compile` copies the updated SSR bundle into `_build/`. Both steps are required after any change to `assets/`.
`mix assets.build` runs Vite builds (client + SSR). `mix compile` copies the updated SSR bundle into `_build/`. Both steps are required after any change to `assets/`.
## SSR Renders Stale HTML
**Symptom:** Server-side rendered HTML shows old component output even after updating Svelte files.
**Cause:** `_build/test/lib/example/priv/svelte/server.js` is a **copy** (not a symlink) of `priv/svelte/server.js`. It is updated by `mix compile`, not by `mix assets.js` alone.
**Cause:** `_build/test/lib/example/priv/svelte/server.js` is a **copy** (not a symlink) of `priv/svelte/server.js`. It is updated by `mix compile`, not by `mix assets.build` alone.
**Fix:**
```bash
mix assets.js && mix compile
mix assets.build && mix compile
```
If you're seeing stale SSR in tests, ensure the `on_exit` cleanup properly resets SSR state.
@ -60,16 +60,13 @@ This injects Svelte component CSS directly into the JS bundle instead of extract
**Cause:** The `liveSveltePlugin` is missing from one or both Vite configs.
**Fix:** Ensure `liveSveltePlugin()` is in **both** `vite.config.mjs` (client build) and `vite.ssr.config.js` (SSR build):
**Fix:** Ensure `liveSveltePlugin()` is in `vite.config.mjs` and that `ssr: { noExternal: ... }` is set. The same config is used for both client and SSR builds (via `phoenix_vite.npm vite build --ssr js/server.js ...`).
```js
// assets/vite.config.mjs
import { liveSveltePlugin } from "live_svelte/vitePlugin"
plugins: [svelte(), liveSveltePlugin()]
// assets/vite.ssr.config.js
import { liveSveltePlugin } from "live_svelte/vitePlugin"
plugins: [svelte(), liveSveltePlugin()]
import liveSveltePlugin from "live_svelte/vitePlugin"
plugins: [svelte(), liveSveltePlugin({ entrypoint: "./js/server.js" })],
ssr: { noExternal: process.env.NODE_ENV === "production" ? true : undefined },
```
Also verify that your Svelte files are in `assets/svelte/` and have the `.svelte` extension.
@ -96,7 +93,7 @@ npm install -g webdriver-manager
webdriver-manager update
```
Also ensure `mix assets.js` has been run before E2E tests — the browser needs the built JS to function.
Also ensure `mix assets.build` has been run before E2E tests — the browser needs the built JS to function.
## `mix live_svelte.install` Says "Task Not Found"
@ -171,7 +168,7 @@ For containers that wrap multiple components, use `phx-update="ignore"` on the o
2. Ensure the SSR bundle was built:
```bash
MIX_ENV=prod mix assets.js && MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.build && MIX_ENV=prod mix compile
```
3. Check that `NodeJS.Supervisor` is in `application.ex`:

View file

@ -48,6 +48,7 @@ defmodule Mix.Tasks.LiveSvelte.Install do
igniter
|> Igniter.compose_task("phoenix_vite.install", igniter.args.argv)
|> configure_environments(app_name)
|> update_phoenix_vite_config()
|> add_live_svelte_to_html_helpers(app_name)
|> update_javascript_configuration()
|> configure_tailwind_for_svelte()
@ -59,7 +60,16 @@ defmodule Mix.Tasks.LiveSvelte.Install do
|> add_svelte_demo_route()
|> update_home_template()
|> update_gitignore()
|> create_ssr_vite_config()
end
defp update_phoenix_vite_config(igniter) do
Config.configure(
igniter,
"config.exs",
:phoenix_vite,
[PhoenixVite.Npm, :assets],
{:code, Sourceror.parse_string!(~s|[args: [], cd: __DIR__]|)}
)
end
# Configure environments (config.exs, dev.exs, prod.exs)
@ -196,8 +206,6 @@ defmodule Mix.Tasks.LiveSvelte.Install do
end
# Update vite.config.mjs to add Svelte plugin and liveSveltePlugin.
# Note: ssr.noExternal is intentionally NOT added here — it only applies to SSR
# builds and is already set in the separate vite.ssr.config.js.
defp update_vite_configuration(igniter) do
Igniter.update_file(igniter, "assets/vite.config.mjs", fn source ->
Rewrite.Source.update(source, :content, fn content ->
@ -205,10 +213,23 @@ defmodule Mix.Tasks.LiveSvelte.Install do
|> add_svelte_vite_imports()
|> update_vite_optimized_deps()
|> update_vite_plugins()
|> add_ssr_vite_entry()
end)
end)
end
defp add_ssr_vite_entry(content) do
if String.contains?(content, "noExternal") do
content
else
String.replace(
content,
~r/build: \{/s,
"ssr: { noExternal: process.env.NODE_ENV === \"production\" ? true : undefined },\n build: {"
)
end
end
defp add_svelte_vite_imports(content) do
if String.contains?(content, "import { svelte }") do
content
@ -245,20 +266,35 @@ defmodule Mix.Tasks.LiveSvelte.Install do
end
end
# Update package.json with Svelte dependencies
# phoenix_vite.install creates assets/package.json; npm runs from assets/
# Move package.json to root (like live_vue) and add Svelte + phoenix_vite dependencies.
# phoenix_vite.install creates assets/package.json; we move to root and patch.
defp update_package_json_for_svelte(igniter) do
igniter
|> Igniter.update_file("assets/package.json", fn source ->
|> Igniter.move_file("assets/package.json", "package.json")
|> Igniter.update_file("package.json", fn source ->
Rewrite.Source.update(source, :content, fn content ->
content
|> add_module_type()
|> add_svelte_dependency()
|> add_svelte_dev_dependencies()
|> add_phoenix_vite_dev_dependency()
end)
end)
end
defp add_phoenix_vite_dev_dependency(content) do
if String.contains?(content, "\"phoenix_vite\"") do
content
else
String.replace(
content,
~s("vite":),
~s("phoenix_vite": "file:./deps/phoenix_vite",\n "vite":),
global: false
)
end
end
defp add_svelte_dependency(content) do
if String.contains?(content, "\"live_svelte\"") do
content
@ -349,67 +385,23 @@ defmodule Mix.Tasks.LiveSvelte.Install do
end)
end
# Update mix.exs aliases in the consumer app to use Vite
# Update mix.exs aliases: replace single phoenix_vite.npm vite build with two-step (client + SSR).
defp update_mix_aliases(igniter) do
bun? = igniter.args.options[:bun] || false
pm = if bun?, do: "bunx", else: "npx"
Igniter.update_file(igniter, "mix.exs", fn source ->
Rewrite.Source.update(source, :content, fn content ->
if String.contains?(content, ~s("assets.js":)) do
if String.contains?(content, "js/server.js") do
content
else
content
|> add_assets_js_alias(pm)
|> update_assets_deploy_alias(pm)
String.replace(
content,
~s("phoenix_vite.npm vite build"),
~s("phoenix_vite.npm vite build --manifest --emptyOutDir true", "phoenix_vite.npm vite build --ssrManifest --emptyOutDir false --ssr js/server.js --outDir ../priv/svelte")
)
end
end)
end)
end
defp add_assets_js_alias(content, pm) do
if String.contains?(content, "\"assets.js\"") do
content
else
String.replace(
content,
~r/("assets\.setup":)/,
~s("assets.js": [\n "assets.build",\n "cmd --cd assets #{pm} vite build --config vite.ssr.config.js"\n ],\n \\1)
)
end
end
defp update_assets_deploy_alias(content, pm) do
ssr_cmd = ~s("cmd --cd assets #{pm} vite build --config vite.ssr.config.js --mode production")
cond do
# Guard: SSR step already present in assets.deploy (idempotent).
# Check for --mode production which is unique to the deploy command;
# assets.js also references vite.ssr.config.js so a generic check would
# fire immediately after add_assets_js_alias runs, skipping the deploy update.
String.contains?(content, "vite.ssr.config.js --mode production") ->
content
# phoenix_vite.install pattern: assets.deploy is a list containing "assets.build".
# Use dotall regex so inline and multi-line list formats both match.
Regex.match?(~r/"assets\.deploy":\s*\[/s, content) ->
String.replace(
content,
~r/("assets\.deploy":\s*\[)(.*?)(\s*\])/s,
"\\1\\2,\n #{ssr_cmd}\\3",
global: false
)
# Legacy esbuild pattern — replace with vite production builds
true ->
String.replace(
content,
~r/"esbuild default[^"]*"/,
~s("cmd --cd assets #{pm} vite build --mode production", #{ssr_cmd})
)
end
end
# Add svelte_demo route to router
defp add_svelte_demo_route(igniter) do
web_module = Phoenix.web_module(igniter)
@ -467,25 +459,21 @@ defmodule Mix.Tasks.LiveSvelte.Install do
end)
end
# Add gitignore entries for Svelte build artifacts under a named section.
# Add gitignore entries; with package.json at root, node_modules is at project root.
defp update_gitignore(igniter) do
Igniter.update_file(igniter, ".gitignore", fn source ->
Rewrite.Source.update(source, :content, fn content ->
if String.contains?(content, "/priv/svelte/") do
content
else
String.trim_trailing(content) <>
"\n\n# LiveSvelte build artifacts\n/assets/svelte/_build/\n/priv/svelte/\n"
end
content
|> then(fn c ->
if String.contains?(c, "/assets/node_modules"), do: String.replace(c, "/assets/node_modules", "node_modules"), else: c
end)
|> then(fn c ->
if String.contains?(c, "/priv/svelte/"), do: c, else: String.trim_trailing(c) <> "\n\n# LiveSvelte build artifacts\n/assets/svelte/_build/\n/priv/svelte/\n"
end)
end)
end)
end
# Create the SSR Vite config file
defp create_ssr_vite_config(igniter) do
Igniter.create_new_file(igniter, "assets/vite.ssr.config.js", ssr_vite_config_content())
end
# Content helpers
defp server_js_content do
@ -496,26 +484,6 @@ defmodule Mix.Tasks.LiveSvelte.Install do
"""
end
defp ssr_vite_config_content do
"""
import { defineConfig } from "vite"
import { svelte } from "@sveltejs/vite-plugin-svelte"
import liveSveltePlugin from "live_svelte/vitePlugin"
export default defineConfig({
plugins: [svelte(), liveSveltePlugin({ entrypoint: "./js/server.js" })],
ssr: { noExternal: true },
build: {
ssr: "./js/server.js",
outDir: "../priv/svelte",
rollupOptions: {
output: { entryFileNames: "server.js", format: "es" }
}
}
})
"""
end
defp demo_svelte_content do
"""
<script>

View file

@ -53,62 +53,32 @@ defmodule Mix.Tasks.LiveSvelte.InstallTest do
end
end
describe "H2 regression: update_assets_deploy_alias/2" do
test "assets.deploy includes SSR vite build step" do
describe "H2: phoenix_vite two-step assets.build" do
test "assets.build includes phoenix_vite.npm client and SSR build steps" do
result = run_installer()
content = file_content(result, "mix.exs")
assert content =~ "vite.ssr.config.js",
"assets.deploy is missing the SSR build step:\n\n#{content}"
end
assert content =~ "phoenix_vite.npm vite build --manifest --emptyOutDir true",
"assets.build missing client step:\n\n#{content}"
test "assets.deploy SSR step uses npx by default" do
result = run_installer()
content = file_content(result, "mix.exs")
assert content =~ "phoenix_vite.npm vite build --ssrManifest",
"assets.build missing SSR step:\n\n#{content}"
assert content =~
~r/npx vite build --config vite\.ssr\.config\.js --mode production/,
"Expected npx SSR build in assets.deploy"
end
assert content =~ "--ssr js/server.js",
"assets.build SSR step must use js/server.js entry:\n\n#{content}"
test "assets.deploy SSR step uses bunx with --bun flag" do
result = run_installer(bun: true)
content = file_content(result, "mix.exs")
assert content =~
~r/bunx vite build --config vite\.ssr\.config\.js --mode production/,
"Expected bunx SSR build in assets.deploy with --bun"
assert content =~ "--outDir ../priv/svelte",
"assets.build SSR step must output to priv/svelte:\n\n#{content}"
end
end
describe "assets.js alias" do
test "assets.js alias is added with client and SSR build steps" do
result = run_installer()
content = file_content(result, "mix.exs")
assert content =~ ~s("assets.js":),
"assets.js alias is missing from mix.exs"
assert content =~ ~r/"assets\.js".*vite\.ssr\.config\.js/s,
"assets.js alias missing SSR step"
end
end
describe "M2: vite config — no noExternal in client config" do
test "client vite.config.mjs does not contain noExternal" do
describe "M2: vite config — ssr noExternal in main config" do
test "vite.config.mjs contains ssr noExternal for production" do
result = run_installer()
content = file_content(result, "assets/vite.config.mjs")
refute content =~ "noExternal",
"ssr.noExternal must NOT appear in client vite.config.mjs (belongs only in vite.ssr.config.js)"
end
test "SSR vite.ssr.config.js correctly contains noExternal" do
result = run_installer()
content = file_content(result, "assets/vite.ssr.config.js")
assert content =~ "noExternal: true",
"vite.ssr.config.js must have ssr.noExternal: true"
assert content =~ "noExternal",
"vite.config.mjs must have ssr.noExternal for SSR build"
end
end
@ -133,9 +103,10 @@ defmodule Mix.Tasks.LiveSvelte.InstallTest do
end
describe "config files" do
test "config.exs sets ssr: true" do
test "config.exs sets phoenix_vite and live_svelte ssr" do
result = run_installer()
content = file_content(result, "config/config.exs")
assert content =~ ~r/:phoenix_vite.*PhoenixVite\.Npm/s
assert content =~ ~r/:live_svelte.*ssr.*true/s
end
@ -152,16 +123,17 @@ defmodule Mix.Tasks.LiveSvelte.InstallTest do
end
end
describe "package.json" do
test "live_svelte dependency added" do
describe "package.json (root)" do
test "package.json is at project root with live_svelte dependency" do
result = run_installer()
content = file_content(result, "assets/package.json")
content = file_content(result, "package.json")
assert content =~ "live_svelte"
end
test "svelte dev dependencies added" do
test "phoenix_vite and svelte dev dependencies added" do
result = run_installer()
content = file_content(result, "assets/package.json")
content = file_content(result, "package.json")
assert content =~ "phoenix_vite"
assert content =~ "@sveltejs/vite-plugin-svelte"
assert content =~ ~s("svelte":)
end
@ -226,11 +198,6 @@ defmodule Mix.Tasks.LiveSvelte.InstallTest do
end
describe "created files" do
test "assets/vite.ssr.config.js is created" do
result = run_installer()
assert_creates(result, "assets/vite.ssr.config.js")
end
test "assets/js/server.js is created" do
result = run_installer()
assert_creates(result, "assets/js/server.js")