chore: added basic vite support

This commit is contained in:
Denis Donici 2026-02-25 23:51:22 +02:00
parent 10c0d29ccb
commit fffc4bdb5e
62 changed files with 1054 additions and 233 deletions

View file

@ -0,0 +1,131 @@
/// <reference types="vite/client" />
/**
* @typedef {Object} PluginOptions
* @property {string} [path] - SSR render endpoint path (default: "/ssr_render")
* @property {string} [entrypoint] - SSR entrypoint file (default: "./js/server.js")
*/
/**
* @param {string} path
* @returns {"css-update" | "js-update" | null}
*/
function hotUpdateType(path) {
if (path.endsWith("css")) return "css-update"
if (path.endsWith("js")) return "js-update"
return null
}
/**
* @param {import("http").ServerResponse} res
* @param {number} statusCode
* @param {unknown} data
*/
const jsonResponse = (res, statusCode, data) => {
res.statusCode = statusCode
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify(data))
}
/**
* Custom JSON parsing middleware
* @param {import("http").IncomingMessage & { body?: Record<string, unknown> }} req
* @param {import("http").ServerResponse} res
* @param {() => Promise<void>} next
*/
const jsonMiddleware = (req, res, next) => {
let data = ""
req.on("data", chunk => {
data += chunk
})
req.on("end", () => {
try {
req.body = JSON.parse(data)
next()
} catch (error) {
jsonResponse(res, 400, { error: "Invalid JSON" })
}
})
req.on("error", err => {
console.error(err)
jsonResponse(res, 500, { error: "Internal Server Error" })
})
}
/**
* LiveSvelte Vite plugin for SSR and hot reload support.
*
* NOTE: Unlike LiveVue, LiveSvelte's `render()` function returns a `{head, html, css}`
* object (not a plain HTML string). This plugin serialises that result as JSON so the
* Elixir `LiveSvelte.SSR.ViteJS` module can decode it with `Jason.decode!/1`.
*
* @param {PluginOptions} [opts]
* @returns {import("vite").Plugin}
*/
function liveSveltePlugin(opts = {}) {
return {
name: "live-svelte",
handleHotUpdate({ file, modules, server, timestamp }) {
if (file.match(/\.(heex|ex)$/)) {
const invalidatedModules = new Set()
for (const mod of modules) {
server.moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true)
}
const updates = Array.from(invalidatedModules).flatMap(m => {
const { file } = m
if (!file) return []
const updateType = hotUpdateType(file)
if (!updateType) return []
return {
type: updateType,
path: m.url,
acceptedPath: m.url,
timestamp: timestamp,
}
})
server.ws.send({
type: "update",
updates,
})
return []
}
},
configureServer(server) {
process.stdin.on("close", () => process.exit(0))
process.stdin.resume()
const path = opts.path || "/ssr_render"
const entrypoint = opts.entrypoint || "./js/server.js"
server.middlewares.use(function liveSvelteMiddleware(req, res, next) {
if (req.method == "POST" && req.url?.split("?", 1)[0] === path) {
jsonMiddleware(req, res, async () => {
try {
const render = (await server.ssrLoadModule(entrypoint)).render
const result = await render(req.body?.name, req.body?.props, req.body?.slots)
// LiveSvelte render returns {head, html, css} — must JSON-encode for Elixir decoder
res.setHeader("Content-Type", "application/json")
res.end(JSON.stringify(result))
} catch (e) {
e instanceof Error && server.ssrFixStacktrace(e)
jsonResponse(res, 500, { error: e })
}
})
} else {
next()
}
})
},
}
}
export default liveSveltePlugin

View file

@ -0,0 +1,67 @@
// Vite dev server entry point for HMR development.
// Replaces the esbuild-plugin-import-glob syntax in app.js with Vite's native
// import.meta.glob. The esbuild build (app.js) remains unchanged for production.
import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
// TODO(Epic 9): remove createLiveJsonHooks once live_json dependency is removed
import {createLiveJsonHooks} from "live_json"
import {getHooks} from "live_svelte"
// Vite-native glob (replaces `import * as Components from "../svelte/**/*.svelte"`)
const Components = import.meta.glob('../svelte/**/*.svelte', { eager: true })
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 = {
...createLiveJsonHooks(),
...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,9 @@
// Vite SSR entry point for liveSveltePlugin's /ssr_render endpoint.
// Uses Vite-native import.meta.glob instead of the esbuild-plugin-import-glob
// syntax used by server.js. Configured via vite.config.js:
// liveSveltePlugin({ entrypoint: './js/server.vite.js' })
import { getRender } from "live_svelte"
const Components = import.meta.glob("../svelte/**/*.svelte", { eager: true })
export const render = getRender(Components)

View file

@ -3,7 +3,9 @@
"@types/lodash": "^4.14.192",
"daisyui": "^5.0.0",
"dynamic-marquee": "^2.6.2",
"esbuild": "^0.24.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"esbuild": "^0.24.0",
"vite": "^6.0.0",
"esbuild-plugin-import-glob": "^0.1.1",
"esbuild-svelte": "^0.9.0",
"lodash": "^4.17.21",

View file

@ -0,0 +1,11 @@
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.vite.js" })],
server: {
host: "localhost",
port: 5173,
},
})

View file

@ -62,6 +62,21 @@ config :example, ExampleWeb.Endpoint,
# Enable dev routes for dashboard and mailbox
config :example, dev_routes: true
# To use Vite dev server for HMR + SSR during development:
#
# 1. Install Vite deps (one-time):
# cd assets && npm install
#
# 2. Start Vite dev server (in a separate terminal):
# cd assets && npx vite
#
# 3. Enable Vite in config (uncomment below):
# config :live_svelte, ssr_module: LiveSvelte.SSR.ViteJS
# config :live_svelte, vite_host: "http://localhost:5173"
#
# With :vite_host set, LiveSvelte.Reload.vite_assets/1 in root.html.heex
# automatically serves assets from Vite (with HMR) instead of compiled files.
# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"

View file

@ -84,17 +84,17 @@ defmodule ExampleWeb.CoreComponents do
<div id={"#{@id}-content"}>
<header :if={@title != []}>
<h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800">
<%= render_slot(@title) %>
{render_slot(@title)}
</h1>
<p
:if={@subtitle != []}
id={"#{@id}-description"}
class="mt-2 text-sm leading-6 text-zinc-600"
>
<%= render_slot(@subtitle) %>
{render_slot(@subtitle)}
</p>
</header>
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
<div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5">
<.button
:for={confirm <- @confirm}
@ -103,14 +103,14 @@ defmodule ExampleWeb.CoreComponents do
phx-disable-with
class="py-2 px-3"
>
<%= render_slot(confirm) %>
{render_slot(confirm)}
</.button>
<.link
:for={cancel <- @cancel}
phx-click={hide_modal(@on_cancel, @id)}
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<%= render_slot(cancel) %>
{render_slot(cancel)}
</.link>
</div>
</div>
@ -158,9 +158,9 @@ defmodule ExampleWeb.CoreComponents do
<p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6">
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="w-4 h-4" />
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="w-4 h-4" />
<%= @title %>
{@title}
</p>
<p class="mt-2 text-[0.8125rem] leading-5"><%= msg %></p>
<p class="mt-2 text-[0.8125rem] leading-5">{msg}</p>
<button
:if={@close}
type="button"
@ -227,9 +227,9 @@ defmodule ExampleWeb.CoreComponents do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
<div class="space-y-8 bg-base-100 mt-10">
<%= render_slot(@inner_block, f) %>
{render_slot(@inner_block, f)}
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
<%= render_slot(action, f) %>
{render_slot(action, f)}
</div>
</div>
</.form>
@ -260,7 +260,7 @@ defmodule ExampleWeb.CoreComponents do
]}
{@rest}
>
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</button>
"""
end
@ -325,9 +325,9 @@ defmodule ExampleWeb.CoreComponents do
class="checkbox checkbox-sm"
{@rest}
/>
<%= @label %>
{@label}
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@ -335,7 +335,7 @@ defmodule ExampleWeb.CoreComponents do
def input(%{type: "select"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<.label for={@id}>{@label}</.label>
<select
id={@id}
name={@name}
@ -343,10 +343,10 @@ defmodule ExampleWeb.CoreComponents do
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
<.error :for={msg <- @errors}><%= msg %></.error>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@ -354,7 +354,7 @@ defmodule ExampleWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<.label for={@id}>{@label}</.label>
<textarea
id={@id || @name}
name={@name}
@ -364,7 +364,7 @@ defmodule ExampleWeb.CoreComponents do
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.error :for={msg <- @errors}><%= msg %></.error>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@ -372,7 +372,7 @@ defmodule ExampleWeb.CoreComponents do
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<.label for={@id}>{@label}</.label>
<input
type={@type}
name={@name}
@ -384,7 +384,7 @@ defmodule ExampleWeb.CoreComponents do
]}
{@rest}
/>
<.error :for={msg <- @errors}><%= msg %></.error>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
@ -398,7 +398,7 @@ defmodule ExampleWeb.CoreComponents do
def label(assigns) do
~H"""
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</label>
"""
end
@ -412,7 +412,7 @@ defmodule ExampleWeb.CoreComponents do
~H"""
<p class="phx-no-feedback:hidden mt-3 flex gap-3 text-sm leading-6 text-error">
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 w-5 h-5 flex-none" />
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</p>
"""
end
@ -431,13 +431,13 @@ defmodule ExampleWeb.CoreComponents do
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
<div>
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
<%= render_slot(@subtitle) %>
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none"><%= render_slot(@actions) %></div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@ -478,8 +478,8 @@ defmodule ExampleWeb.CoreComponents do
<table class="mt-11 w-[40rem] sm:w-full">
<thead class="text-left text-[0.8125rem] leading-6 text-zinc-500">
<tr>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
<th class="relative p-0 pb-4"><span class="sr-only"><%= gettext("Actions") %></span></th>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
<th class="relative p-0 pb-4"><span class="sr-only">{gettext("Actions")}</span></th>
</tr>
</thead>
<tbody
@ -496,7 +496,7 @@ defmodule ExampleWeb.CoreComponents do
<div class="block py-4 pr-6">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
<%= render_slot(col, @row_item.(row)) %>
{render_slot(col, @row_item.(row))}
</span>
</div>
</td>
@ -507,7 +507,7 @@ defmodule ExampleWeb.CoreComponents do
:for={action <- @action}
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<%= render_slot(action, @row_item.(row)) %>
{render_slot(action, @row_item.(row))}
</span>
</div>
</td>
@ -537,8 +537,8 @@ defmodule ExampleWeb.CoreComponents do
<div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100">
<div :for={item <- @item} class="flex gap-4 py-4 sm:gap-8">
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500"><%= item.title %></dt>
<dd class="text-sm leading-6 text-zinc-700"><%= render_slot(item) %></dd>
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500">{item.title}</dt>
<dd class="text-sm leading-6 text-zinc-700">{render_slot(item)}</dd>
</div>
</dl>
</div>
@ -563,7 +563,7 @@ defmodule ExampleWeb.CoreComponents do
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<.icon name="hero-arrow-left-solid" class="w-3 h-3" />
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</.link>
</div>
"""
@ -679,5 +679,4 @@ defmodule ExampleWeb.CoreComponents do
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

View file

@ -77,12 +77,15 @@ defmodule ExampleWeb.Layouts do
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<li :for={group <- @nav_groups}>
<div class="text-xs font-semibold leading-6 text-base-content/40 uppercase tracking-wider">
<%= group.label %>
{group.label}
</div>
<ul role="list" class="-mx-2 mt-2 space-y-1">
<li :for={link <- group.links}>
<a href={link.to} class="block px-3 py-2 rounded-md text-sm font-medium text-base-content hover:bg-base-200">
<%= link.label %>
<a
href={link.to}
class="block px-3 py-2 rounded-md text-sm font-medium text-base-content hover:bg-base-200"
>
{link.label}
</a>
</li>
</ul>
@ -98,13 +101,20 @@ defmodule ExampleWeb.Layouts do
~H"""
<nav class="hidden lg:flex lg:items-center lg:gap-1">
<div :for={group <- @nav_groups} class="relative group">
<button type="button" class="px-3 py-2 text-sm font-medium text-base-content rounded-md hover:bg-base-200">
<%= group.label %>
<button
type="button"
class="px-3 py-2 text-sm font-medium text-base-content rounded-md hover:bg-base-200"
>
{group.label}
</button>
<div class="absolute left-0 mt-1 w-48 bg-base-100 rounded-box shadow-lg border border-base-300 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150 z-50">
<div class="py-1">
<a :for={link <- group.links} href={link.to} class="block px-4 py-2 text-sm text-base-content hover:bg-base-200">
<%= link.label %>
<a
:for={link <- group.links}
href={link.to}
class="block px-4 py-2 text-sm text-base-content hover:bg-base-200"
>
{link.label}
</a>
</div>
</div>

View file

@ -9,7 +9,11 @@
<%!-- Logo and mobile menu button --%>
<div class="flex items-center gap-4">
<%!-- Mobile menu button --%>
<label for="mobile-drawer" class="lg:hidden -m-2.5 p-2.5 text-zinc-700 cursor-pointer" aria-label="Open sidebar">
<label
for="mobile-drawer"
class="lg:hidden -m-2.5 p-2.5 text-zinc-700 cursor-pointer"
aria-label="Open sidebar"
>
<.icon name="hero-bars-3" class="h-6 w-6" />
</label>
@ -45,7 +49,7 @@
</header>
<main class="p-4 sm:py-8 sm:px-6 lg:px-8 min-h-[calc(100vh-65px)]">
<%= @inner_content %>
{@inner_content}
</main>
</div>

View file

@ -5,13 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title suffix=" · Phoenix Framework">
<%= assigns[:page_title] || "Example" %>
{assigns[:page_title] || "Example"}
</.live_title>
<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 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>
</head>
<body class="bg-base-200 antialiased" data-theme="light">
<%= @inner_content %>
{@inner_content}
</body>
</html>

View file

@ -33,7 +33,10 @@ defmodule ExampleWeb.LiveChat do
autocomplete="name"
aria-label="Your name"
/>
<button type="submit" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 shrink-0">
<button
type="submit"
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90 shrink-0"
>
Join
</button>
</div>
@ -41,11 +44,7 @@ defmodule ExampleWeb.LiveChat do
</div>
</form>
<div :if={@name} class="w-full flex justify-center">
<.Chat
messages={@messages}
name={@name}
socket={@socket}
/>
<.Chat messages={@messages} name={@name} socket={@socket} />
</div>
</div>
"""

View file

@ -12,12 +12,17 @@ defmodule ExampleWeb.LiveClientSideLoading do
</p>
<div class="flex flex-col sm:flex-row flex-wrap justify-center gap-6 w-full max-w-3xl">
<section data-testid="client-side-loading-client-section" class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm">
<section
data-testid="client-side-loading-client-section"
class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm"
>
<div class="card-body gap-4 p-5">
<span class="badge badge-ghost badge-sm font-medium text-base-content/70 w-fit">
Client side
</span>
<p class="text-xs text-base-content/50">Recommended: no SSR, loading slot shown until hydrated.</p>
<p class="text-xs text-base-content/50">
Recommended: no SSR, loading slot shown until hydrated.
</p>
<.svelte name="ClientSideLoading" ssr={false}>
<:loading>
<div class="flex items-center gap-2 py-4">
@ -29,7 +34,10 @@ defmodule ExampleWeb.LiveClientSideLoading do
</div>
</section>
<section data-testid="client-side-loading-server-section" class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm border-warning/50">
<section
data-testid="client-side-loading-server-section"
class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm border-warning/50"
>
<div class="card-body gap-4 p-5">
<span class="badge badge-warning badge-sm font-medium w-fit">
Server side (avoid)

View file

@ -23,14 +23,13 @@ defmodule ExampleWeb.LiveIdListDiff do
~H"""
<div class="min-h-screen bg-base-200/40 py-8 px-4">
<div class="max-w-2xl mx-auto">
<h1
class="text-center text-2xl font-light my-4"
data-testid="id-list-diff-title"
>
<h1 class="text-center text-2xl font-light my-4" data-testid="id-list-diff-title">
ID-Based List Diffing Demo
</h1>
<p class="text-sm text-base-content/50 mb-6 text-center">
Inserts and deletes produce minimal JSON Patch ops because list items carry an <code>:id</code> field.
Inserts and deletes produce minimal JSON Patch ops because list items carry an
<code>:id</code>
field.
</p>
<div class="card bg-base-100 shadow-lg border border-base-300/50 mb-6">
@ -39,18 +38,10 @@ defmodule ExampleWeb.LiveIdListDiff do
LiveView controls
</span>
<div class="flex flex-wrap gap-3">
<button
data-testid="insert-item"
class="btn btn-sm btn-primary"
phx-click="insert_item"
>
<button data-testid="insert-item" class="btn btn-sm btn-primary" phx-click="insert_item">
Insert Item
</button>
<button
data-testid="delete-first"
class="btn btn-sm btn-error"
phx-click="delete_first"
>
<button data-testid="delete-first" class="btn btn-sm btn-error" phx-click="delete_first">
Delete First
</button>
<button
@ -62,7 +53,7 @@ defmodule ExampleWeb.LiveIdListDiff do
</button>
</div>
<p class="text-xs text-base-content/50">
Item count: <%= length(@items) %>
Item count: {length(@items)}
</p>
</div>
</div>
@ -91,8 +82,12 @@ defmodule ExampleWeb.LiveIdListDiff do
def handle_event("move_last_to_top", _params, socket) do
case socket.assigns.items do
[] -> {:noreply, socket}
[_] -> {:noreply, socket}
[] ->
{:noreply, socket}
[_] ->
{:noreply, socket}
items ->
last = List.last(items)
rest = Enum.take(items, length(items) - 1)

View file

@ -17,7 +17,11 @@ defmodule ExampleWeb.LiveJson do
<span class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">
SSR
</span>
<.svelte name="LiveJson" live_json_props={%{big_data_set: @ljbig_data_set}} socket={@socket} />
<.svelte
name="LiveJson"
live_json_props={%{big_data_set: @ljbig_data_set}}
socket={@socket}
/>
</div>
</section>
<section class="card bg-base-100 shadow-md border border-base-300/50 overflow-hidden w-full max-w-sm">

View file

@ -25,10 +25,12 @@ defmodule ExampleWeb.LiveNavigation do
<div class="flex flex-col justify-center items-center gap-6 p-6">
<h2 class="text-center text-2xl font-light my-4">Navigation (useLiveNavigation)</h2>
<p class="text-sm text-base-content/50 text-center max-w-md">
Client-side navigation with <code>patch()</code> and <code>navigate()</code>
from Svelte without full page reloads, plus the <code>Link</code> component.
Client-side navigation with <code>patch()</code>
and <code>navigate()</code>
from Svelte without full page reloads, plus the <code>Link</code>
component.
</p>
<.svelte name="Navigation" props={%{page: @page, query: @query}} socket={@socket} ssr={false} />
<.svelte name="Navigation" props={%{page: @page, query: @query}} socket={@socket} />
</div>
"""
end

View file

@ -33,11 +33,13 @@ defmodule ExampleWeb.LiveNotesOtp do
</p>
<.svelte
name="NotesApp"
props={%{
notes: @notes,
encoder: "OTP",
info: @info
}}
props={
%{
notes: @notes,
encoder: "OTP",
info: @info
}
}
socket={@socket}
/>
</div>

View file

@ -22,16 +22,21 @@ defmodule ExampleWeb.LivePlusMinus do
aria-label={"Decrease by #{@amount}"}
data-testid="live-plus-minus-minus"
>
-<%= @amount %>
-{@amount}
</button>
<span class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center" data-testid="live-plus-minus-value"><%= @number %></span>
<span
class="text-3xl font-bold tabular-nums text-brand min-w-[3rem] text-center"
data-testid="live-plus-minus-value"
>
{@number}
</span>
<button
class="btn btn-square btn-sm btn-success border-0"
phx-click="add"
aria-label={"Increase by #{@amount}"}
data-testid="live-plus-minus-plus"
>
+<%= @amount %>
+{@amount}
</button>
</div>
<label class="flex flex-col gap-1.5 mx-auto">
@ -68,14 +73,17 @@ defmodule ExampleWeb.LivePlusMinus do
def handle_event("update_amount", %{"value" => value}, socket) do
amount_str = extract_amount_value(value)
amount = if is_binary(amount_str) and amount_str != "" do
case Integer.parse(amount_str) do
{n, _} when n >= 1 -> n
_ -> socket.assigns.amount
amount =
if is_binary(amount_str) and amount_str != "" do
case Integer.parse(amount_str) do
{n, _} when n >= 1 -> n
_ -> socket.assigns.amount
end
else
socket.assigns.amount
end
else
socket.assigns.amount
end
{:noreply, assign(socket, :amount, amount)}
end

View file

@ -22,8 +22,9 @@ defmodule ExampleWeb.LivePropsDiff do
Props Diff Demo
</h1>
<p class="text-sm text-base-content/50 mb-6 text-center">
Click a button, then compare the two payloads below: <strong>diff on</strong> sends only changed keys,
<strong>diff off</strong> sends the full object every time.
Click a button, then compare the two payloads below: <strong>diff on</strong>
sends only changed keys, <strong>diff off</strong>
sends the full object every time.
</p>
<div class="flex flex-col gap-6" id="props-diff-demo-root" phx-hook="PropsDiffPayloadDisplay">
@ -47,16 +48,12 @@ defmodule ExampleWeb.LivePropsDiff do
>
Increment B
</button>
<button
data-testid="props-diff-inc-c"
class="btn btn-sm btn-accent"
phx-click="inc_c"
>
<button data-testid="props-diff-inc-c" class="btn btn-sm btn-accent" phx-click="inc_c">
Increment C
</button>
</div>
<p class="text-xs text-base-content/50">
Server state: A=<%= @a %>, B=<%= @b %>, C=<%= @c %>
Server state: A={@a}, B={@b}, C={@c}
</p>
</div>
</section>
@ -92,20 +89,35 @@ defmodule ExampleWeb.LivePropsDiff do
</section>
</div>
<section class="card bg-base-100 shadow-lg border border-base-300/50" aria-label="Payload in DOM">
<section
class="card bg-base-100 shadow-lg border border-base-300/50"
aria-label="Payload in DOM"
>
<div class="card-body gap-2">
<h2 class="text-lg font-semibold text-base-content">Payload in DOM (<code>data-props</code>)</h2>
<h2 class="text-lg font-semibold text-base-content">
Payload in DOM (<code>data-props</code>)
</h2>
<p class="text-xs text-base-content/50 mb-2">
After each update, the diff-on component receives only changed keys; the diff-off component receives the full object.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-xs font-medium text-base-content/70 mb-1">diff on (only changed keys)</p>
<pre id="payload-display-diff-on" class="bg-base-200 text-xs p-3 rounded-lg overflow-x-auto min-h-[4rem]" data-testid="payload-diff-on"></pre>
<p class="text-xs font-medium text-base-content/70 mb-1">
diff on (only changed keys)
</p>
<pre
id="payload-display-diff-on"
class="bg-base-200 text-xs p-3 rounded-lg overflow-x-auto min-h-[4rem]"
data-testid="payload-diff-on"
></pre>
</div>
<div>
<p class="text-xs font-medium text-base-content/70 mb-1">diff off (full object)</p>
<pre id="payload-display-diff-off" class="bg-base-200 text-xs p-3 rounded-lg overflow-x-auto min-h-[4rem]" data-testid="payload-diff-off"></pre>
<pre
id="payload-display-diff-off"
class="bg-base-200 text-xs p-3 rounded-lg overflow-x-auto min-h-[4rem]"
data-testid="payload-diff-off"
></pre>
</div>
</div>
</div>

View file

@ -19,8 +19,17 @@ defmodule ExampleWeb.LiveSimpleCounter do
LiveView (native)
</span>
<div class="flex flex-row items-center justify-center gap-6 py-2">
<span data-testid="live-simple-counter-value" class="text-4xl font-bold tabular-nums text-brand"><%= @number %></span>
<button data-testid="live-simple-counter-increment" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90" phx-click="increment">
<span
data-testid="live-simple-counter-value"
class="text-4xl font-bold tabular-nums text-brand"
>
{@number}
</span>
<button
data-testid="live-simple-counter-increment"
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90"
phx-click="increment"
>
+1
</button>
</div>
@ -35,11 +44,19 @@ defmodule ExampleWeb.LiveSimpleCounter do
<div class="flex flex-wrap gap-6 justify-center py-4">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-base-content/50">Component 1</span>
<.svelte name="SimpleCounter" props={%{number: @number, initialClientValue: @initial_client_value}} socket={@socket} />
<.svelte
name="SimpleCounter"
props={%{number: @number, initialClientValue: @initial_client_value}}
socket={@socket}
/>
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-base-content/50">Component 2</span>
<.svelte name="SimpleCounter" props={%{number: @number, initialClientValue: @initial_client_value}} socket={@socket} />
<.svelte
name="SimpleCounter"
props={%{number: @number, initialClientValue: @initial_client_value}}
socket={@socket}
/>
</div>
</div>
</div>

View file

@ -12,14 +12,25 @@ defmodule ExampleWeb.LiveSlotsDynamic do
</p>
<.svelte name="Slots" socket={@socket}>
<div class="flex flex-wrap items-center gap-3">
<button data-testid="slots-dynamic-increment" phx-click="increase" class="btn btn-sm bg-brand text-white border-0 hover:opacity-90">
<button
data-testid="slots-dynamic-increment"
phx-click="increase"
class="btn btn-sm bg-brand text-white border-0 hover:opacity-90"
>
Increment the number
</button>
<span data-testid="slots-dynamic-number" class="text-2xl font-bold tabular-nums text-brand"><%= @number %></span>
<span data-testid="slots-dynamic-number" class="text-2xl font-bold tabular-nums text-brand">
{@number}
</span>
</div>
<:subtitle>
<span data-testid="slots-dynamic-subtitle-number" class="text-xl font-semibold tabular-nums text-brand"><%= @number %></span>
<span
data-testid="slots-dynamic-subtitle-number"
class="text-xl font-semibold tabular-nums text-brand"
>
{@number}
</span>
</:subtitle>
</.svelte>
</div>

View file

@ -9,39 +9,49 @@ defmodule ExampleWeb.LiveStaticColor do
@impl true
def render(assigns) do
~H"""
<h1 class="text-center text-2xl font-light my-4">
Static Color Demo
</h1>
<p class="text-sm text-base-content/50 mb-8 text-center">
Passing dynamic props to a list of Svelte components from LiveView.
</p>
<h1 class="text-center text-2xl font-light my-4">
Static Color Demo
</h1>
<p class="text-sm text-base-content/50 mb-8 text-center">
Passing dynamic props to a list of Svelte components from LiveView.
</p>
<div class="border-1 border-[var(--color-brand)] shadow-lg card p-5">
<div class="badge badge-outline badge-sm font-medium text-base-content/70 w-fit">LiveView</div>
<div class="flex flex-row my-4 justify-center items-center">
<div class="flex flex-col justify-center items-center gap-4">
<div>
<div class="w-full border-1 border-gray-500 card card-lg p-5 flex flex-col gap-5 justify-center items-center">
<div class="badge badge-outline badge-sm font-medium text-base-content/70 text-nowrap">LiveView Component</div>
<div class="flex flex-col md:flex-row gap-4 items-center">
<button class="btn bg-slate-50" phx-click="change_color_to_white">
Change color to white
</button>
<button class="btn bg-red-500 text-white" phx-click="change_color_to_red">
Change color to red
</button>
<button class="btn bg-[var(--color-brand)] text-white" phx-click="add_element">Add Element</button>
</div>
<div class="text-sm text-base-content/50 text-nowrap">Total elements: <span class="font-bold text-lg">{length(@list)}</span></div>
<div class="badge badge-outline badge-sm font-medium text-base-content/70 text-nowrap">
LiveView Component
</div>
<div class="flex flex-col md:flex-row gap-4 items-center">
<button class="btn bg-slate-50" phx-click="change_color_to_white">
Change color to white
</button>
<button class="btn bg-red-500 text-white" phx-click="change_color_to_red">
Change color to red
</button>
<button class="btn bg-[var(--color-brand)] text-white" phx-click="add_element">
Add Element
</button>
</div>
<div class="text-sm text-base-content/50 text-nowrap">
Total elements: <span class="font-bold text-lg">{length(@list)}</span>
</div>
</div>
</div>
<h3 class="my-4 text-base-content">Use LiveSvelte via a file based component (Static.svelte)</h3>
<h3 class="my-4 text-base-content">
Use LiveSvelte via a file based component (Static.svelte)
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<%= for {_item, index} <- Enum.with_index(@list) do %>
<.svelte name="Static" props={%{color: @color, index: index}} />
<% end %>
</div>
<div class="divider"></div>
<h3 class="mb-4 text-base-content">Use LiveSvelte as a function via the (~V sigil) to render the Svelte component</h3>
<h3 class="mb-4 text-base-content">
Use LiveSvelte as a function via the (~V sigil) to render the Svelte component
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<%= for {_item, index} <- Enum.with_index(@list) do %>
<.static_svelte_component color={@color} index={index} />

View file

@ -11,11 +11,11 @@ defmodule ExampleWeb.LiveStruct do
def render(assigns) do
~H"""
<h1 class="text-center text-2xl font-light my-4">
Struct Demo
</h1>
<p class="text-sm text-base-content/50 mb-8 text-center">
Passing a struct to Svelte.
</p>
Struct Demo
</h1>
<p class="text-sm text-base-content/50 mb-8 text-center">
Passing a struct to Svelte.
</p>
<.svelte name="Struct" props={%{struct: @struct}} socket={@socket} />
"""

View file

@ -44,10 +44,12 @@ defmodule ExampleWeb.LiveUpload do
</p>
<.svelte
name="UploadDemo"
props={%{
uploads: %{test_files: @uploads.test_files},
uploaded_files: @uploaded_files
}}
props={
%{
uploads: %{test_files: @uploads.test_files},
uploaded_files: @uploaded_files
}
}
socket={@socket}
/>
</div>

View file

@ -17,9 +17,14 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
container = session |> find(Query.css("[data-testid='breaking-news-headlines']"))
container_text = Wallaby.Element.text(container)
found = String.contains?(container_text, text)
cond do
found -> session
attempts == 0 -> raise "timeout waiting for headline containing #{inspect(text)}"
found ->
session
attempts == 0 ->
raise "timeout waiting for headline containing #{inspect(text)}"
true ->
:timer.sleep(100)
wait_for_headline_with_text(session, text, attempts - 1)
@ -30,7 +35,11 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
session
|> visit("/live-breaking-news")
|> assert_has(Query.css("h2", text: "Breaking News"))
|> assert_has(Query.css("p", text: "Add headlines and control the ticker speed; remove items from the list."))
|> assert_has(
Query.css("p",
text: "Add headlines and control the ticker speed; remove items from the list."
)
)
|> assert_has(Query.css("[data-testid='breaking-news-new-headline']"))
# Initial news has 5 items; at least one visible
@ -39,7 +48,9 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
assert Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ "Giant Pink Elephant" end)
end
test "adding a headline via the UI shows it in the list and clears the input", %{session: session} do
test "adding a headline via the UI shows it in the list and clears the input", %{
session: session
} do
session =
session
|> visit("/live-breaking-news")
@ -91,9 +102,14 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
container = session |> find(Query.css("[data-testid='breaking-news-headlines']"))
container_text = Wallaby.Element.text(container)
found = String.contains?(container_text, text)
cond do
not found -> session
attempts == 0 -> raise "timeout waiting for headline #{inspect(text)} to be removed"
not found ->
session
attempts == 0 ->
raise "timeout waiting for headline #{inspect(text)} to be removed"
true ->
:timer.sleep(100)
wait_for_headline_removed(session, text, attempts - 1)
@ -110,6 +126,7 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
_ ->
if attempts == 0 do
actual = Wallaby.Element.attr(ticker, "data-rate")
raise "timeout waiting for ticker rate (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end

View file

@ -15,9 +15,14 @@ defmodule ExampleWeb.LiveChatTest do
defp wait_for_chat_bubble_with_text(session, text, attempts \\ 50) do
bubbles = chat_bubbles(session)
found = Enum.any?(bubbles, fn el -> Wallaby.Element.text(el) =~ text end)
cond do
found -> session
attempts == 0 -> raise "timeout waiting for chat bubble containing #{inspect(text)}"
found ->
session
attempts == 0 ->
raise "timeout waiting for chat bubble containing #{inspect(text)}"
true ->
:timer.sleep(100)
wait_for_chat_bubble_with_text(session, text, attempts - 1)
@ -28,12 +33,18 @@ defmodule ExampleWeb.LiveChatTest do
session
|> visit("/live-chat")
|> assert_has(Query.css("h2", text: "Chat"))
|> assert_has(Query.css("p", text: "Enter your name to join; then send messages. Your name labels your bubbles."))
|> assert_has(
Query.css("p",
text: "Enter your name to join; then send messages. Your name labels your bubbles."
)
)
|> assert_has(Query.css("[data-testid='chat-join-name']"))
|> assert_has(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
end
test "useLiveConnection: reconnecting banner absent when connected (happy path)", %{session: session} do
test "useLiveConnection: reconnecting banner absent when connected (happy path)", %{
session: session
} do
session
|> visit("/live-chat")
|> fill_in(Query.css("[data-testid='chat-join-name']"), with: "Alice")

View file

@ -10,8 +10,10 @@ defmodule ExampleWeb.LiveClientSideLoadingTest do
defp wait_for_hydration(session, count, attempts \\ 30)
defp wait_for_hydration(_session, _count, 0), do: :ok
defp wait_for_hydration(session, count, attempts) do
els = session |> all(Query.css("[data-testid='client-side-loading-content']"))
if length(els) >= count do
:ok
else
@ -29,7 +31,12 @@ defmodule ExampleWeb.LiveClientSideLoadingTest do
test "renders description", %{session: session} do
session
|> visit("/live-client-side-loading")
|> find(Query.css("p", text: "Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client."))
|> find(
Query.css("p",
text:
"Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client."
)
)
end
test "both components hydrate and show content", %{session: session} do
@ -39,6 +46,7 @@ defmodule ExampleWeb.LiveClientSideLoadingTest do
wait_for_hydration(session, 2)
content_els = session |> all(Query.css("[data-testid='client-side-loading-content']"))
assert length(content_els) == 2,
"expected 2 hydrated ClientSideLoading components, got #{length(content_els)}"

View file

@ -72,7 +72,9 @@ defmodule ExampleWeb.LiveFormTest do
|> assert_has(Query.css("[data-testid='form-email-error']"))
end
test "submitting valid form resets fields (re-submit verifies empty values)", %{session: session} do
test "submitting valid form resets fields (re-submit verifies empty values)", %{
session: session
} do
session =
session
|> visit("/live-form")

View file

@ -16,9 +16,14 @@ defmodule ExampleWeb.LiveJsonTest do
defp wait_for_key_count(session, expected, attempts \\ 50) do
dd = first_key_count_dd(session)
actual = dd && Wallaby.Element.text(dd)
cond do
actual == expected -> session
attempts == 0 -> raise "timeout waiting for key count #{inspect(expected)}, got #{inspect(actual)}"
actual == expected ->
session
attempts == 0 ->
raise "timeout waiting for key count #{inspect(expected)}, got #{inspect(actual)}"
true ->
:timer.sleep(100)
wait_for_key_count(session, expected, attempts - 1)
@ -29,7 +34,12 @@ defmodule ExampleWeb.LiveJsonTest do
session
|> visit("/live-json")
|> assert_has(Query.css("h2", text: "Live JSON"))
|> assert_has(Query.css("p", text: "Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements."))
|> assert_has(
Query.css("p",
text:
"Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements."
)
)
end
test "renders two sections with key length and Remove element button", %{session: session} do

View file

@ -12,13 +12,19 @@ defmodule ExampleWeb.LiveLightsTest do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='light-brightness-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for brightness (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='light-brightness-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_brightness(session, expected, attempts - 1)
^expected ->
session
_ ->
:timer.sleep(100)
wait_for_brightness(session, expected, attempts - 1)
end
end
@ -28,7 +34,9 @@ defmodule ExampleWeb.LiveLightsTest do
|> find(Query.css("h1", text: "Light Bulb Controller"))
end
test "renders LightStatusBar and LightControllers with initial brightness 10%", %{session: session} do
test "renders LightStatusBar and LightControllers with initial brightness 10%", %{
session: session
} do
session = visit(session, "/live-lights")
session |> find(Query.css("[data-name='LightStatusBar']"))

View file

@ -15,9 +15,14 @@ defmodule ExampleWeb.LiveLogListTest do
defp wait_for_log_item_with_text(session, text, attempts \\ 50) do
items = log_list_items(session)
found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end)
cond do
found -> session
attempts == 0 -> raise "timeout waiting for log list item containing #{inspect(text)}"
found ->
session
attempts == 0 ->
raise "timeout waiting for log list item containing #{inspect(text)}"
true ->
:timer.sleep(100)
wait_for_log_item_with_text(session, text, attempts - 1)
@ -26,9 +31,14 @@ defmodule ExampleWeb.LiveLogListTest do
defp wait_for_at_least_one_log_item(session, attempts \\ 35) do
items = log_list_items(session)
cond do
length(items) >= 1 -> session
attempts == 0 -> raise "timeout waiting for at least one timer-driven log entry"
length(items) >= 1 ->
session
attempts == 0 ->
raise "timeout waiting for at least one timer-driven log entry"
true ->
:timer.sleep(200)
wait_for_at_least_one_log_item(session, attempts - 1)
@ -39,8 +49,11 @@ defmodule ExampleWeb.LiveLogListTest do
session
|> visit("/live-log-list")
|> assert_has(Query.css("h2", text: "Log stream"))
|> assert_has(Query.css("p", text: "Add items or let the timer append entries; limit how many are shown."))
|> assert_has(
Query.css("p", text: "Add items or let the timer append entries; limit how many are shown.")
)
|> assert_has(Query.css("[data-testid='log-list-new-entry']"))
# Empty state may be gone if timer already appended an entry
end

View file

@ -13,13 +13,19 @@ defmodule ExampleWeb.LivePlusMinusHybridTest do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='hybrid-plus-minus-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
^expected ->
session
_ ->
:timer.sleep(100)
wait_for_value(session, expected, attempts - 1)
end
end
@ -73,7 +79,9 @@ defmodule ExampleWeb.LivePlusMinusHybridTest do
assert Wallaby.Element.text(value) == "12"
end
test "multiple plus clicks with stepper 2 keep stepper at 2 and increment by 2 each time", %{session: session} do
test "multiple plus clicks with stepper 2 keep stepper at 2 and increment by 2 each time", %{
session: session
} do
session =
session
|> visit("/live-plus-minus-hybrid")

View file

@ -13,13 +13,19 @@ defmodule ExampleWeb.LivePlusMinusTest do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='live-plus-minus-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
^expected ->
session
_ ->
:timer.sleep(100)
wait_for_value(session, expected, attempts - 1)
end
end

View file

@ -25,9 +25,9 @@ defmodule ExampleWeb.LivePropsDiffTest do
assert length(values_b) == 2
assert length(values_c) == 2
for el <- values_a, do: assert Wallaby.Element.text(el) == "1"
for el <- values_b, do: assert Wallaby.Element.text(el) == "2"
for el <- values_c, do: assert Wallaby.Element.text(el) == "3"
for el <- values_a, do: assert(Wallaby.Element.text(el) == "1")
for el <- values_b, do: assert(Wallaby.Element.text(el) == "2")
for el <- values_c, do: assert(Wallaby.Element.text(el) == "3")
end
test "Increment A updates both Svelte components to A=2", %{session: session} do
@ -38,7 +38,7 @@ defmodule ExampleWeb.LivePropsDiffTest do
values_a = session |> all(Query.css("[data-testid='props-diff-value-a']"))
assert length(values_a) == 2
for el <- values_a, do: assert Wallaby.Element.text(el) == "2"
for el <- values_a, do: assert(Wallaby.Element.text(el) == "2")
end
test "Increment B then C updates displayed values", %{session: session} do
@ -52,8 +52,8 @@ defmodule ExampleWeb.LivePropsDiffTest do
values_c = session |> all(Query.css("[data-testid='props-diff-value-c']"))
assert length(values_b) == 2
assert length(values_c) == 2
for el <- values_b, do: assert Wallaby.Element.text(el) == "3"
for el <- values_c, do: assert Wallaby.Element.text(el) == "4"
for el <- values_b, do: assert(Wallaby.Element.text(el) == "3")
for el <- values_c, do: assert(Wallaby.Element.text(el) == "4")
end
test "multiple increments leave all displayed values in sync", %{session: session} do
@ -66,7 +66,7 @@ defmodule ExampleWeb.LivePropsDiffTest do
values_a = session |> all(Query.css("[data-testid='props-diff-value-a']"))
values_b = session |> all(Query.css("[data-testid='props-diff-value-b']"))
for el <- values_a, do: assert Wallaby.Element.text(el) == "3"
for el <- values_b, do: assert Wallaby.Element.text(el) == "3"
for el <- values_a, do: assert(Wallaby.Element.text(el) == "3")
for el <- values_b, do: assert(Wallaby.Element.text(el) == "3")
end
end

View file

@ -12,13 +12,19 @@ defmodule ExampleWeb.LiveSigilTest do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='#{testid}']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for #{testid} (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='#{testid}']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_sigil_value(session, testid, expected, attempts - 1)
^expected ->
session
_ ->
:timer.sleep(100)
wait_for_sigil_value(session, testid, expected, attempts - 1)
end
end

View file

@ -9,7 +9,8 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
"""
@moduletag :e2e
defp native_increment_js, do: "document.querySelector(\"[data-testid='live-simple-counter-increment']\").click()"
defp native_increment_js,
do: "document.querySelector(\"[data-testid='live-simple-counter-increment']\").click()"
defp click_native_increment(session) do
session |> Wallaby.Browser.execute_script(native_increment_js())
@ -20,17 +21,22 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
assert length(client_values) == 2, "expected 2 SimpleCounter client value elements"
actual_first = Wallaby.Element.text(Enum.at(client_values, 0))
actual_second = Wallaby.Element.text(Enum.at(client_values, 1))
assert actual_first == expected_first,
"first component client state was affected by server increment (expected: #{expected_first}, got: #{actual_first})"
assert actual_second == expected_second,
"second component client state was affected by server increment (expected: #{expected_second}, got: #{actual_second})"
session
end
defp wait_for_client_counters(session, count, attempts \\ 30)
defp wait_for_client_counters(_session, _count, 0), do: :ok
defp wait_for_client_counters(session, count, attempts) do
els = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
if length(els) >= count do
:ok
else
@ -47,14 +53,20 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
defp wait_for_counter_value(session, expected, 0) do
el = session |> find(Query.css("[data-testid='live-simple-counter-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for counter value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
defp wait_for_counter_value(session, expected, attempts) do
el = session |> find(Query.css("[data-testid='live-simple-counter-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_counter_value(session, expected, attempts - 1)
^expected ->
session
_ ->
:timer.sleep(100)
wait_for_counter_value(session, expected, attempts - 1)
end
end
@ -91,9 +103,11 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
|> click(Query.css("[data-testid='live-simple-counter-increment']"))
# Each SimpleCounter has a Server card with span.text-brand for the server number
svelte_server_numbers = session |> all(Query.css("[data-name='SimpleCounter'] span.text-brand"))
svelte_server_numbers =
session |> all(Query.css("[data-name='SimpleCounter'] span.text-brand"))
assert length(svelte_server_numbers) >= 2
for el <- Enum.take(svelte_server_numbers, 2), do: assert Wallaby.Element.text(el) == "11"
for el <- Enum.take(svelte_server_numbers, 2), do: assert(Wallaby.Element.text(el) == "11")
end
test "renders client counter at 1 in each SimpleCounter", %{session: session} do
@ -102,7 +116,7 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
wait_for_client_counters(session, 2)
client_values = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
assert length(client_values) == 2
for el <- client_values, do: assert Wallaby.Element.text(el) == "1"
for el <- client_values, do: assert(Wallaby.Element.text(el) == "1")
end
test "clicking client +1 updates only that component's client counter", %{session: session} do
@ -111,7 +125,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
session |> find(Query.css("[data-testid='live-simple-counter-value']"))
# Click the first SimpleCounter's client +1 button
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
[first_client_btn | _] =
session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(first_client_btn)
client_values = session |> all(Query.css("[data-testid='simple-counter-client-value']"))
@ -126,7 +142,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
# Bump only the first component's client counter: 1 → 2
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
[first_client_btn | _] =
session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(first_client_btn)
:timer.sleep(200)
assert_client_values_unchanged(session, "2", "1")
@ -141,7 +159,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
# First component client: 1 → 2 → 3
[first_client_btn | _] = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
[first_client_btn | _] =
session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(first_client_btn)
:timer.sleep(100)
Wallaby.Element.click(first_client_btn)
@ -161,7 +181,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
assert_client_values_unchanged(session, "3", "1")
end
test "client state on second component only is unchanged by server increments", %{session: session} do
test "client state on second component only is unchanged by server increments", %{
session: session
} do
session = visit(session, "/live-simple-counter")
wait_for_client_counters(session, 2)
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
@ -180,7 +202,9 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
assert_client_values_unchanged(session, "1", "2")
end
test "server increment never resets or changes client state on both components", %{session: session} do
test "server increment never resets or changes client state on both components", %{
session: session
} do
session = visit(session, "/live-simple-counter")
wait_for_client_counters(session, 2)
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
@ -192,12 +216,14 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
Wallaby.Element.click(Enum.at(client_btns, 0))
:timer.sleep(100)
end
:timer.sleep(100)
# Second component: 1 → 2 → 3 → 4 → 5
for _ <- 1..4 do
Wallaby.Element.click(Enum.at(client_btns, 1))
:timer.sleep(80)
end
:timer.sleep(200)
assert_client_values_unchanged(session, "4", "5")
@ -210,7 +236,8 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
assert_client_values_unchanged(session, "4", "5")
end
test "server increments first, then client state: client state still unaffected by later server increments", %{session: session} do
test "server increments first, then client state: client state still unaffected by later server increments",
%{session: session} do
session = visit(session, "/live-simple-counter")
wait_for_client_counters(session, 2)
session |> find(Query.css("[data-testid='live-simple-counter-value']", text: "10"))
@ -223,7 +250,14 @@ defmodule ExampleWeb.LiveSimpleCounterTest do
client_btns = session |> all(Query.css("[data-testid='simple-counter-client-increment']"))
Wallaby.Element.click(Enum.at(client_btns, 0))
:timer.sleep(100)
for _ <- 1..3, do: (Wallaby.Element.click(Enum.at(client_btns, 1)); :timer.sleep(80))
for _ <- 1..3,
do:
(
Wallaby.Element.click(Enum.at(client_btns, 1))
:timer.sleep(80)
)
:timer.sleep(200)
assert_client_values_unchanged(session, "2", "4")

View file

@ -8,16 +8,28 @@ defmodule ExampleWeb.LiveSlotsDynamicTest do
@moduletag :e2e
test "page mounts and shows heading, description, slots, and initial number", %{session: session} do
test "page mounts and shows heading, description, slots, and initial number", %{
session: session
} do
session
|> visit("/live-slots-dynamic")
|> assert_has(Query.css("h2", text: "Dynamic slots"))
|> assert_has(Query.css("p", text: "Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number."))
|> assert_has(
Query.css("p",
text:
"Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number."
)
)
# Slots card: badge, button, Opening/Closing, subtitle
session |> assert_has(Query.css("[data-testid='slots-card']"))
session |> assert_has(Query.css("[data-testid='slots-badge']", text: "Slots"))
session |> assert_has(Query.css("[data-testid='slots-dynamic-increment']", text: "Increment the number"))
session
|> assert_has(
Query.css("[data-testid='slots-dynamic-increment']", text: "Increment the number")
)
session |> assert_has(Query.css("[data-testid='slots-opening']", text: "Opening"))
session |> assert_has(Query.css("[data-testid='slots-closing']", text: "Closing"))
session |> assert_has(Query.css("[data-testid='slots-subtitle']", text: "Svelte subtitle"))
@ -45,12 +57,18 @@ defmodule ExampleWeb.LiveSlotsDynamicTest do
end
defp wait_for_number(session, expected, attempts \\ 30) do
default_span = session |> all(Query.css("[data-testid='slots-dynamic-number']")) |> List.first()
default_span =
session |> all(Query.css("[data-testid='slots-dynamic-number']")) |> List.first()
actual = default_span && Wallaby.Element.text(default_span)
cond do
actual == expected -> session
actual == expected ->
session
attempts == 0 ->
raise "timeout waiting for number #{inspect(expected)}, got #{inspect(actual)}"
true ->
:timer.sleep(100)
wait_for_number(session, expected, attempts - 1)

View file

@ -12,7 +12,11 @@ defmodule ExampleWeb.LiveSlotsSimpleTest do
session
|> visit("/live-slots-simple")
|> assert_has(Query.css("h2", text: "Simple slots"))
|> assert_has(Query.css("p", text: "Phoenix slots are passed into the Svelte component as the default slot content."))
|> assert_has(
Query.css("p",
text: "Phoenix slots are passed into the Svelte component as the default slot content."
)
)
# Slots card: badge and slot content
session |> assert_has(Query.css("[data-testid='slots-card']"))

View file

@ -13,7 +13,11 @@ defmodule ExampleWeb.LiveStaticColorTest do
session
|> visit("/live-static-color")
|> assert_has(Query.css("h1", text: "Static Color Demo"))
|> assert_has(Query.css("p", text: "Passing dynamic props to a list of Svelte components from LiveView."))
|> assert_has(
Query.css("p",
text: "Passing dynamic props to a list of Svelte components from LiveView."
)
)
# There are two Svelte grids (file-based + ~V sigil), each rendering the same color span.
session = wait_for_svelte_count(session, 6)
@ -25,7 +29,9 @@ defmodule ExampleWeb.LiveStaticColorTest do
end
end
test "adding an element increases Svelte component count and preserves all existing ones", %{session: session} do
test "adding an element increases Svelte component count and preserves all existing ones", %{
session: session
} do
session = visit(session, "/live-static-color")
session = wait_for_svelte_count(session, 6)
@ -50,7 +56,7 @@ defmodule ExampleWeb.LiveStaticColorTest do
session = wait_for_svelte_count(session, 6)
svelte_values = session |> all(Query.css("[data-testid='static-color-svelte-value']"))
assert length(svelte_values) == 6
for value <- svelte_values, do: assert Wallaby.Element.text(value) == "RED"
for value <- svelte_values, do: assert(Wallaby.Element.text(value) == "RED")
end
test "adding elements after color change preserves color in all components", %{session: session} do
@ -64,14 +70,19 @@ defmodule ExampleWeb.LiveStaticColorTest do
session = wait_for_svelte_count(session, 8)
svelte_values = session |> all(Query.css("[data-testid='static-color-svelte-value']"))
assert length(svelte_values) == 8
for value <- svelte_values, do: assert Wallaby.Element.text(value) == "RED"
for value <- svelte_values, do: assert(Wallaby.Element.text(value) == "RED")
end
defp wait_for_svelte_count(session, expected, attempts \\ 80) do
count = session |> all(Query.css("[data-testid='static-color-svelte-value']")) |> length()
cond do
count >= expected -> session
attempts == 0 -> raise "timeout waiting for #{expected} Svelte value elements, got #{count}"
count >= expected ->
session
attempts == 0 ->
raise "timeout waiting for #{expected} Svelte value elements, got #{count}"
true ->
:timer.sleep(100)
wait_for_svelte_count(session, expected, attempts - 1)

View file

@ -13,7 +13,9 @@ defmodule ExampleWeb.LiveStructTest do
session |> find(Query.css("p", text: "Passing a struct to Svelte."))
end
test "struct data flows from LiveView to Svelte component and updates on server events", %{session: session} do
test "struct data flows from LiveView to Svelte component and updates on server events", %{
session: session
} do
session = visit(session, "/live-struct")
# Verify initial struct from LiveView (%User{name: "Bob", age: 42})

View file

@ -46,7 +46,9 @@ defmodule ExampleWeb.LiveUploadTest do
# Create a temp file for upload tests, cleaned up after the test.
defp tmp_upload_file(content \\ "test upload content") do
path = Path.join(System.tmp_dir!(), "live_upload_test_#{System.unique_integer([:positive])}.txt")
path =
Path.join(System.tmp_dir!(), "live_upload_test_#{System.unique_integer([:positive])}.txt")
File.write!(path, content)
path
end

View file

@ -25,7 +25,9 @@ defmodule ExampleWeb.PhoenixTest.LiveBreakingNewsTest do
conn
|> visit("/live-breaking-news")
|> assert_has("h2", text: "Breaking News")
|> assert_has("p", text: "Add headlines and control the ticker speed; remove items from the list.")
|> assert_has("p",
text: "Add headlines and control the ticker speed; remove items from the list."
)
end
test "renders inline Svelte mount and initial news in props", %{conn: conn} do

View file

@ -25,7 +25,9 @@ defmodule ExampleWeb.PhoenixTest.LiveChatTest do
conn
|> visit("/live-chat")
|> assert_has("h2", text: "Chat")
|> assert_has("p", text: "Enter your name to join; then send messages. Your name labels your bubbles.")
|> assert_has("p",
text: "Enter your name to join; then send messages. Your name labels your bubbles."
)
|> assert_has("[data-testid='chat-join-name']")
|> assert_has("[data-testid='chat-join-form'] button", text: "Join")
end

View file

@ -14,7 +14,10 @@ defmodule ExampleWeb.PhoenixTest.LiveClientSideLoadingTest do
conn
|> visit("/live-client-side-loading")
|> assert_has("[data-testid='client-side-loading-heading']", text: "Client-side loading")
|> assert_has("p", text: "Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client.")
|> assert_has("p",
text:
"Use the loading slot when SSR is disabled; the slot shows until the component hydrates on the client."
)
end
test "renders two ClientSideLoading mount points", %{conn: conn} do

View file

@ -25,7 +25,10 @@ defmodule ExampleWeb.PhoenixTest.LiveJsonTest do
conn
|> visit("/live-json")
|> assert_has("h2", text: "Live JSON")
|> assert_has("p", text: "Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements.")
|> assert_has("p",
text:
"Large payloads are patched over the wire. Compare SSR vs no-SSR and watch the WebSocket traffic when removing elements."
)
end
test "renders two sections (SSR and No SSR) with LiveJson component", %{conn: conn} do

View file

@ -36,10 +36,14 @@ defmodule ExampleWeb.PhoenixTest.LiveLightsTest do
conn
|> visit("/live-lights")
|> assert_has("h1", text: "Light Bulb Controller")
|> assert_has("p", text: "Same LiveView state drives the native counter and both Svelte components.")
|> assert_has("p",
text: "Same LiveView state drives the native counter and both Svelte components."
)
end
test "renders LightStatusBar and LightControllers Svelte components with initial state", %{conn: conn} do
test "renders LightStatusBar and LightControllers Svelte components with initial state", %{
conn: conn
} do
conn
|> visit("/live-lights")
|> assert_has("[data-name='LightStatusBar']", count: 1)

View file

@ -25,7 +25,9 @@ defmodule ExampleWeb.PhoenixTest.LiveLogListTest do
conn
|> visit("/live-log-list")
|> assert_has("h2", text: "Log stream")
|> assert_has("p", text: "Add items or let the timer append entries; limit how many are shown.")
|> assert_has("p",
text: "Add items or let the timer append entries; limit how many are shown."
)
end
test "renders LogList mount and initial empty items in props", %{conn: conn} do

View file

@ -25,7 +25,10 @@ defmodule ExampleWeb.PhoenixTest.LiveNotesOtpTest do
conn
|> visit("/live-notes-otp")
|> assert_has("[data-testid='notes-otp-heading']", text: "Notes (OTP)")
|> assert_has("p", text: "Ecto structs are encoded automatically. Changes sync in real time across all browsers via PubSub.")
|> assert_has("p",
text:
"Ecto structs are encoded automatically. Changes sync in real time across all browsers via PubSub."
)
end
test "renders NotesApp with form and empty state or notes in props", %{conn: conn} do

View file

@ -22,7 +22,7 @@ defmodule ExampleWeb.PhoenixTest.LivePlusMinusHybridTest do
:ok
end
test "renders page heading and description", %{conn: conn} do
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-plus-minus-hybrid")
|> assert_has("h2", text: "Plus / Minus (Hybrid)")

View file

@ -70,7 +70,9 @@ defmodule ExampleWeb.PhoenixTest.LivePropsDiffTest do
|> assert_has("[data-use-diff='false'][data-props*='\"b\":2']")
end
test "after update diff-off component has full props, diff-on has only changed keys", %{conn: conn} do
test "after update diff-off component has full props, diff-on has only changed keys", %{
conn: conn
} do
conn
|> visit("/live-props-diff")
|> click_button("Increment A")

View file

@ -26,7 +26,9 @@ defmodule ExampleWeb.PhoenixTest.LiveSigilTest do
conn
|> visit("/live-sigil")
|> assert_has("h1", text: "Svelte template (~V sigil)")
|> assert_has("p", text: "Inline Svelte in LiveView: server state and client state in one template.")
|> assert_has("p",
text: "Inline Svelte in LiveView: server state and client state in one template."
)
end
test "renders initial server, client, and combined values", %{conn: conn} do

View file

@ -34,14 +34,19 @@ defmodule ExampleWeb.PhoenixTest.LiveSimpleCounterTest do
defp assert_client_counter_rendered_from_state(conn) do
conn
|> assert_has("[data-testid='simple-counter-client-value']", text: "#{@initial_client_value}", count: 2)
|> assert_has("[data-testid='simple-counter-client-value']",
text: "#{@initial_client_value}",
count: 2
)
end
test "renders page heading and description", %{conn: conn} do
conn
|> visit("/live-simple-counter")
|> assert_has("h1", text: "Simple Counter Demo")
|> assert_has("p", text: "Same LiveView state drives the native counter and both Svelte components.")
|> assert_has("p",
text: "Same LiveView state drives the native counter and both Svelte components."
)
end
test "renders initial counter and two SimpleCounter Svelte components", %{conn: conn} do
@ -108,7 +113,9 @@ defmodule ExampleWeb.PhoenixTest.LiveSimpleCounterTest do
|> assert_client_counter_rendered_from_state()
end
test "props diffing: after increment, data-use-diff is true and props reflect new value", %{conn: conn} do
test "props diffing: after increment, data-use-diff is true and props reflect new value", %{
conn: conn
} do
conn
|> visit("/live-simple-counter")
|> assert_has("[data-testid='live-simple-counter-value']", text: "10")

View file

@ -24,7 +24,10 @@ defmodule ExampleWeb.PhoenixTest.LiveSlotsDynamicTest do
conn
|> visit("/live-slots-dynamic")
|> assert_has("h2", text: "Dynamic slots")
|> assert_has("p", text: "Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number.")
|> assert_has("p",
text:
"Default slot and named slot (:subtitle) both receive LiveView state; the button updates the number."
)
end
test "renders Slots with default and subtitle slots showing initial number", %{conn: conn} do

View file

@ -23,7 +23,9 @@ defmodule ExampleWeb.PhoenixTest.LiveSlotsSimpleTest do
conn
|> visit("/live-slots-simple")
|> assert_has("h2", text: "Simple slots")
|> assert_has("p", text: "Phoenix slots are passed into the Svelte component as the default slot content.")
|> assert_has("p",
text: "Phoenix slots are passed into the Svelte component as the default slot content."
)
end
test "renders Slots component with default slot content", %{conn: conn} do

View file

@ -8,10 +8,14 @@ defmodule ExampleWeb.PhoenixTest.LiveStaticColorTest do
conn
|> visit("/live-static-color")
|> assert_has("h1", text: "Static Color Demo")
|> assert_has("p", text: "Passing dynamic props to a list of Svelte components from LiveView.")
|> assert_has("p",
text: "Passing dynamic props to a list of Svelte components from LiveView."
)
end
test "renders both Svelte mountpoints (file-based + ~V sigil) initially with white color", %{conn: conn} do
test "renders both Svelte mountpoints (file-based + ~V sigil) initially with white color", %{
conn: conn
} do
conn
|> visit("/live-static-color")
|> assert_has("[data-name='Static']", count: 3)
@ -19,7 +23,9 @@ defmodule ExampleWeb.PhoenixTest.LiveStaticColorTest do
|> assert_has("[data-props*='\"color\":\"white\"']", count: 6)
end
test "each Svelte component receives its index in props (twice: file-based + ~V sigil)", %{conn: conn} do
test "each Svelte component receives its index in props (twice: file-based + ~V sigil)", %{
conn: conn
} do
conn
|> visit("/live-static-color")
|> assert_has("[data-props*='\"index\":0']", count: 2)

View file

@ -31,7 +31,9 @@ defmodule ExampleWeb.PhoenixTest.PlusMinusSvelteTest do
|> assert_has("[data-props*='\"number\":10']")
end
test "renders initial value and plus/minus buttons in HTML by data-testid with SSR", %{conn: conn} do
test "renders initial value and plus/minus buttons in HTML by data-testid with SSR", %{
conn: conn
} do
conn
|> visit("/plus-minus-svelte")
|> assert_has("[data-testid='plus-minus-value']", text: "10")

View file

@ -11,13 +11,19 @@ defmodule ExampleWeb.PlusMinusSvelteTest do
if attempts == 0 do
el = session |> find(Query.css("[data-testid='plus-minus-value']"))
actual = Wallaby.Element.text(el)
raise "timeout waiting for value (expected: #{inspect(expected)}, actual: #{inspect(actual)})"
end
el = session |> find(Query.css("[data-testid='plus-minus-value']"))
case Wallaby.Element.text(el) do
^expected -> session
_ -> :timer.sleep(100); wait_for_value(session, expected, attempts - 1)
^expected ->
session
_ ->
:timer.sleep(100)
wait_for_value(session, expected, attempts - 1)
end
end

47
lib/reload.ex Normal file
View file

@ -0,0 +1,47 @@
defmodule LiveSvelte.Reload do
@moduledoc """
Utilities for easier integration with Vite in development.
"""
use Phoenix.Component
attr :assets, :list, required: true
slot :inner_block, required: true, doc: "rendered when Vite path is not defined (production)"
@doc """
Renders Vite dev server assets in development, falls back to compiled assets in production.
When `config :live_svelte, vite_host: "http://localhost:5173"` is set, injects:
- `@vite/client` script (enables Vite HMR WebSocket)
- CSS assets as `<link rel="stylesheet">` tags
- JS assets as `<script type="module">` tags
When `vite_host` is not configured, renders the `inner_block` unchanged.
## Example
<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>
"""
def vite_assets(assigns) do
vite_host = Application.get_env(:live_svelte, :vite_host)
assigns =
assigns
|> assign(:vite_host, vite_host)
|> assign(:stylesheets, for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path))
|> assign(:javascripts, for(path <- assigns.assets, String.ends_with?(path, ".js"), do: path))
~H"""
<%= if @vite_host do %>
<script type="module" src={Path.join(@vite_host, "@vite/client")}></script>
<link :for={path <- @stylesheets} rel="stylesheet" href={Path.join(@vite_host, path)} />
<script :for={path <- @javascripts} type="module" src={Path.join(@vite_host, path)}></script>
<% else %>
<%= render_slot(@inner_block) %>
<% end %>
"""
end
end

98
lib/ssr/vite_js.ex Normal file
View file

@ -0,0 +1,98 @@
defmodule LiveSvelte.SSR.ViteJS do
@moduledoc """
Implements SSR by making a POST request to `http://{:vite_host}/ssr_render`.
`ssr_render` is implemented as a Vite plugin. Add it to the `vite.config.js` plugins section:
```javascript
import liveSveltePlugin from "live_svelte/vitePlugin"
export default {
plugins: [liveSveltePlugin()],
// ...
}
```
## Configuration
In `config/dev.exs`:
```elixir
config :live_svelte, ssr_module: LiveSvelte.SSR.ViteJS
config :live_svelte, vite_host: "http://localhost:5173"
```
"""
@behaviour LiveSvelte.SSR
def render(name, props, slots) do
data = Jason.encode!(%{name: name, props: props, slots: slots})
url = vite_path("/ssr_render")
params = {String.to_charlist(url), [], ~c"application/json", data}
case :httpc.request(:post, params, [], []) do
{:ok, {{_, 200, _}, _headers, body}} ->
Jason.decode!(:erlang.list_to_binary(body))
{:ok, {{_, 500, _}, _headers, body}} ->
body_binary = :erlang.list_to_binary(body)
message =
case Jason.decode(body_binary) do
{:ok, %{"error" => %{"message" => msg, "loc" => loc, "frame" => frame}}} ->
"#{msg}\n#{loc["file"]}:#{loc["line"]}:#{loc["column"]}\n#{frame}"
{:ok, %{"error" => %{"stack" => stack}}} ->
stack
_ ->
"Unexpected Vite SSR response: 500 #{body_binary}"
end
raise %LiveSvelte.SSR.NotConfigured{message: message}
{:ok, {{_, status, code}, _headers, _body}} ->
raise %LiveSvelte.SSR.NotConfigured{
message: "Unexpected Vite SSR response: #{status} #{:erlang.list_to_binary(code)}"
}
{:error, {:failed_connect, [{:to_address, {host, port}}, {_, _, code}]}} ->
message = """
Unable to connect to Vite #{host}:#{port}: #{code}
Ensure Vite is running:
cd assets && npx vite
Or switch back to NodeJS SSR in config/dev.exs:
config :live_svelte, ssr_module: LiveSvelte.SSR.NodeJS
"""
raise %LiveSvelte.SSR.NotConfigured{message: message}
{:error, reason} ->
raise %LiveSvelte.SSR.NotConfigured{
message: "ViteJS SSR connection error: #{inspect(reason)}"
}
end
end
@doc """
Returns a path relative to the configured Vite JS host.
"""
def vite_path(path) do
case Application.get_env(:live_svelte, :vite_host) do
nil ->
message = """
Vite.js host is not configured. Please add the following to config/dev.exs:
config :live_svelte, vite_host: "http://localhost:5173"
and ensure Vite is running.
"""
raise %LiveSvelte.SSR.NotConfigured{message: message}
host ->
Path.join(host, path)
end
end
end

View file

@ -51,7 +51,7 @@ defmodule LiveSvelte.MixProject do
def application do
[
extra_applications: [:logger]
extra_applications: [:logger, :inets]
]
end

View file

@ -18,9 +18,13 @@
"svelte": "^5.1.13"
},
"exports": {
"types": "./assets/js/live_svelte/types.d.ts",
"import": "./priv/static/live_svelte.esm.js",
"require": "./priv/static/live_svelte.cjs.js"
".": {
"svelte": "./assets/js/live_svelte/index.ts",
"types": "./assets/js/live_svelte/types.d.ts",
"import": "./priv/static/live_svelte.esm.js",
"require": "./priv/static/live_svelte.cjs.js"
},
"./vitePlugin": "./assets/js/live_svelte/vite_plugin.js"
},
"repository": {
"type": "git",

82
test/reload_test.exs Normal file
View file

@ -0,0 +1,82 @@
defmodule LiveSvelte.ReloadTest do
# must be synchronous — tests are sensitive to config changes
use ExUnit.Case, async: false
setup do
original = Application.get_env(:live_svelte, :vite_host)
on_exit(fn ->
if original == nil do
Application.delete_env(:live_svelte, :vite_host)
else
Application.put_env(:live_svelte, :vite_host, original)
end
end)
:ok
end
# Renders the vite_assets/1 function component to an HTML string.
defp render(assets, inner_block_content) do
assigns = %{
__changed__: nil,
assets: assets,
inner_block: [%{inner_block: fn _, _ -> inner_block_content end}]
}
LiveSvelte.Reload.vite_assets(assigns)
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
end
describe "vite_assets/1" do
test "renders Vite dev server scripts when :vite_host is configured" do
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
html = render(["/js/app.js"], "fallback")
assert html =~ ~s|src="http://localhost:5173/@vite/client"|
assert html =~ ~s|src="http://localhost:5173/js/app.js"|
assert html =~ ~s|type="module"|
refute html =~ "fallback"
end
test "renders inner_block when :vite_host is not configured" do
Application.delete_env(:live_svelte, :vite_host)
html = render(["/js/app.js"], "FALLBACK_CONTENT")
assert html =~ "FALLBACK_CONTENT"
refute html =~ "@vite/client"
refute html =~ "localhost:5173"
end
test "CSS assets render as link stylesheet tags" do
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
html = render(["/css/app.css"], "")
assert html =~ ~s|rel="stylesheet"|
assert html =~ "http://localhost:5173/css/app.css"
end
test "JS assets render as module script tags" do
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
html = render(["/js/app.js"], "")
assert html =~ ~s|type="module"|
assert html =~ "http://localhost:5173/js/app.js"
end
test "@vite/client script appears before other assets" do
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
html = render(["/js/app.js"], "")
{client_pos, _} = :binary.match(html, "@vite/client")
{app_pos, _} = :binary.match(html, "/js/app.js")
assert client_pos < app_pos
end
end
end

61
test/ssr_vite_js_test.exs Normal file
View file

@ -0,0 +1,61 @@
defmodule LiveSvelte.SSR.ViteJSTest do
# must be synchronous — tests are sensitive to config changes
use ExUnit.Case, async: false
setup do
original = Application.get_env(:live_svelte, :vite_host)
on_exit(fn ->
if original == nil do
Application.delete_env(:live_svelte, :vite_host)
else
Application.put_env(:live_svelte, :vite_host, original)
end
end)
:ok
end
describe "vite_path/1" do
test "returns correct URL when vite_host is configured" do
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
assert LiveSvelte.SSR.ViteJS.vite_path("/ssr_render") == "http://localhost:5173/ssr_render"
end
test "appends path to host without double slash" do
Application.put_env(:live_svelte, :vite_host, "http://localhost:5173")
assert LiveSvelte.SSR.ViteJS.vite_path("/foo") == "http://localhost:5173/foo"
end
test "raises NotConfigured when vite_host is not set" do
Application.delete_env(:live_svelte, :vite_host)
assert_raise(LiveSvelte.SSR.NotConfigured, fn ->
LiveSvelte.SSR.ViteJS.vite_path("/ssr_render")
end)
end
end
describe "render/3" do
test "raises NotConfigured when vite_host is not configured" do
Application.delete_env(:live_svelte, :vite_host)
assert_raise(LiveSvelte.SSR.NotConfigured, fn ->
LiveSvelte.SSR.ViteJS.render("Counter", %{count: 0}, nil)
end)
end
test "raises NotConfigured when Vite server is unreachable" do
# Use a port that is guaranteed to be closed
Application.put_env(:live_svelte, :vite_host, "http://127.0.0.1:59998")
assert_raise(LiveSvelte.SSR.NotConfigured, fn ->
LiveSvelte.SSR.ViteJS.render("Counter", %{count: 0}, nil)
end)
end
end
test "implements LiveSvelte.SSR behaviour" do
assert function_exported?(LiveSvelte.SSR.ViteJS, :render, 3)
end
end