chore: added event reply

This commit is contained in:
Denis Donici 2026-02-25 10:13:17 +02:00 committed by Wout De Puysseleir
parent 0cefad3200
commit 8a62ff7bff
10 changed files with 799 additions and 53 deletions

View file

@ -17,3 +17,5 @@ export type {
} from "./useLiveForm";
export { useLiveUpload } from "./useLiveUpload";
export type { UploadEntry, UploadConfig, UploadOptions, UseLiveUploadReturn } from "./useLiveUpload";
export { useEventReply } from "./useEventReply";
export type { UseEventReplyOptions, UseEventReplyReturn } from "./useEventReply";

View file

@ -359,3 +359,58 @@ export declare function useLiveUpload(
options: UploadOptions
): UseLiveUploadReturn;
// ---------------------------------------------------------------------------
// useEventReply types
// Keep in sync with useEventReply.ts — that file is the source of truth.
// ---------------------------------------------------------------------------
/** Options for `useEventReply`. */
export interface UseEventReplyOptions<T> {
/** Initial value for `data` store before first reply. Defaults to `null`. */
defaultValue?: T;
/**
* Transform the reply before storing it in `data`.
* Receives the server reply and current store value; return value is stored.
*/
updateData?: (reply: T, currentData: T | null) => T;
/** Reject the promise if the LiveView has not replied within this many milliseconds. */
timeout?: number;
}
/** Return value of `useEventReply`. */
export interface UseEventReplyReturn<T, P extends object | void = object> {
/** Reactive store of the last reply data (`null` until first successful reply). */
data: Readable<T | null>;
/** `true` while the event is in-flight, `false` otherwise. */
isLoading: Readable<boolean>;
/**
* Push the named event to Phoenix with optional params.
* Returns a promise that resolves with the reply payload from `{:reply, map, socket}`.
* Rejects if already executing, if no LiveSvelte context, or if timeout expires.
*/
execute(params?: P): Promise<T>;
/**
* Cancel the in-flight execution.
* Rejects the pending promise and resets `isLoading` to `false`.
*/
cancel(): void;
}
/**
* Bind a LiveView event to a reactive request-response composable.
*
* @example
* ```svelte
* <script lang="ts">
* import { useEventReply } from "live_svelte"
* const { data, isLoading, execute, cancel } = useEventReply<{ result: number }>("compute")
* </script>
* <button onclick={() => execute({ value: 21 })}>{$isLoading ? "Loading..." : "Go"}</button>
* {#if $data}<p>{$data.result}</p>{/if}
* ```
*/
export declare function useEventReply<T = unknown, P extends object | void = object>(
eventName: string,
options?: UseEventReplyOptions<T>
): UseEventReplyReturn<T, P>;

View file

@ -0,0 +1,408 @@
/**
* Tests for useEventReply composable.
*
* Mocking strategy:
* - `svelte` module is mocked: getContext (for useLiveSvelte), onDestroy (capture cleanup).
* vi.mock is hoisted before imports.
* - onDestroy does NOT auto-invoke tests capture it and call manually when needed.
* - mockPushEvent captures the reply callback so tests can simulate server replies.
* - jsdom environment (from vitest.config.js) is sufficient no DOM setup needed
* since useEventReply has no DOM operations.
*/
import { vi, describe, it, expect, beforeEach } from "vitest"
import { get } from "svelte/store"
// CRITICAL: vi.mock is hoisted before imports by Vitest. Must appear before
// any import of the module under test.
vi.mock("svelte", () => ({
getContext: vi.fn(),
onDestroy: vi.fn(),
}))
import { getContext, onDestroy } from "svelte"
import { useEventReply } from "./useEventReply"
import type { UseEventReplyOptions } from "./useEventReply"
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
// Captures the last pushEvent reply callback so tests can trigger server replies.
let lastReplyCallback: ((reply: unknown, ref: number) => void) | null = null
const mockPushEvent = vi
.fn()
.mockImplementation(
(
_event: string,
_params: object,
callback: (reply: unknown, ref: number) => void
) => {
lastReplyCallback = callback
return 1
}
)
const mockLive = {
pushEvent: mockPushEvent,
pushEventTo: vi.fn(),
handleEvent: vi.fn().mockReturnValue(() => {}),
removeHandleEvent: vi.fn(),
upload: vi.fn(),
uploadTo: vi.fn(),
liveSocket: undefined,
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
lastReplyCallback = null
vi.mocked(getContext).mockReturnValue(mockLive)
})
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
describe("initialization", () => {
it("returns all expected properties", () => {
const result = useEventReply("compute")
expect(result).toHaveProperty("data")
expect(result).toHaveProperty("isLoading")
expect(result).toHaveProperty("execute")
expect(result).toHaveProperty("cancel")
})
it("data store initializes to null when no defaultValue provided", () => {
const { data } = useEventReply("compute")
expect(get(data)).toBeNull()
})
it("data store initializes to defaultValue when provided", () => {
const { data } = useEventReply("compute", { defaultValue: { result: 0 } })
expect(get(data)).toEqual({ result: 0 })
})
it("isLoading store initializes to false", () => {
const { isLoading } = useEventReply("compute")
expect(get(isLoading)).toBe(false)
})
it("registers onDestroy cleanup during init", () => {
useEventReply("compute")
expect(vi.mocked(onDestroy)).toHaveBeenCalledTimes(1)
expect(typeof vi.mocked(onDestroy).mock.calls[0][0]).toBe("function")
})
})
// ---------------------------------------------------------------------------
// execute() — happy path
// ---------------------------------------------------------------------------
describe("execute()", () => {
it("returns a Promise", () => {
const { execute } = useEventReply("compute")
const result = execute({})
expect(result).toBeInstanceOf(Promise)
// Prevent unhandled rejection in test environment.
result.catch(() => {})
})
it("sets isLoading to true immediately when called", () => {
const { isLoading, execute } = useEventReply("compute")
const promise = execute({})
expect(get(isLoading)).toBe(true)
promise.catch(() => {})
})
it("calls pushEvent with the event name and params", () => {
const { execute } = useEventReply("compute")
execute({ value: 42 })
expect(mockPushEvent).toHaveBeenCalledWith(
"compute",
{ value: 42 },
expect.any(Function)
)
})
it("calls pushEvent with empty object when no params given", () => {
const { execute } = useEventReply("compute")
execute()
expect(mockPushEvent).toHaveBeenCalledWith("compute", {}, expect.any(Function))
})
it("resolves with reply payload from server", async () => {
const { execute } = useEventReply<{ result: number }>("compute")
const promise = execute({ value: 21 })
expect(lastReplyCallback).not.toBeNull()
lastReplyCallback!({ result: 42 }, 1)
const reply = await promise
expect(reply).toEqual({ result: 42 })
})
it("updates data store when reply arrives", async () => {
const { data, execute } = useEventReply<{ result: number }>("compute")
const promise = execute({})
lastReplyCallback!({ result: 99 }, 1)
await promise
expect(get(data)).toEqual({ result: 99 })
})
it("sets isLoading to false after reply", async () => {
const { isLoading, execute } = useEventReply("compute")
const promise = execute({})
expect(get(isLoading)).toBe(true)
lastReplyCallback!({ result: 1 }, 1)
await promise
expect(get(isLoading)).toBe(false)
})
it("applies updateData transformer before storing reply", async () => {
const options: UseEventReplyOptions<{ total: number }> = {
defaultValue: { total: 0 },
updateData: (reply, current) => ({ total: (current?.total ?? 0) + reply.total }),
}
const { data, execute } = useEventReply<{ total: number }>("accumulate", options)
const p1 = execute({})
lastReplyCallback!({ total: 10 }, 1)
await p1
const p2 = execute({})
lastReplyCallback!({ total: 5 }, 1)
await p2
expect(get(data)).toEqual({ total: 15 })
})
})
// ---------------------------------------------------------------------------
// execute() — already loading guard
// ---------------------------------------------------------------------------
describe("execute() while already loading", () => {
it("rejects if already executing", async () => {
const { execute } = useEventReply("compute")
execute({}) // First call — in flight
await expect(execute({})).rejects.toThrow("is already executing")
})
it("logs a console.warn when already executing", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
try {
const { execute } = useEventReply("compute")
execute({})
execute({}).catch(() => {})
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("already executing"))
} finally {
warnSpy.mockRestore()
}
})
it("does not call pushEvent a second time when already loading", () => {
const { execute } = useEventReply("compute")
execute({})
execute({}).catch(() => {})
expect(mockPushEvent).toHaveBeenCalledTimes(1)
})
})
// ---------------------------------------------------------------------------
// cancel()
// ---------------------------------------------------------------------------
describe("cancel()", () => {
it("rejects the pending promise", async () => {
const { execute, cancel } = useEventReply("compute")
const promise = execute({})
cancel()
await expect(promise).rejects.toThrow("was cancelled")
})
it("resets isLoading to false", () => {
const { isLoading, execute, cancel } = useEventReply("compute")
const p = execute({})
p.catch(() => {})
expect(get(isLoading)).toBe(true)
cancel()
expect(get(isLoading)).toBe(false)
})
it("stale reply callback is ignored after cancel", async () => {
const { data, execute, cancel } = useEventReply<{ result: number }>("compute")
const promise = execute({})
// Attach catch handler BEFORE cancel() to prevent unhandled rejection.
const handled = promise.catch(() => {})
const capturedCallback = lastReplyCallback
cancel()
// Try to invoke the stale callback — should be ignored.
capturedCallback!({ result: 99 }, 1)
// data store should remain unchanged.
expect(get(data)).toBeNull()
await handled
})
it("cancel() does nothing when not executing", () => {
const { isLoading, cancel } = useEventReply("compute")
expect(() => cancel()).not.toThrow()
expect(get(isLoading)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Timeout option
// ---------------------------------------------------------------------------
describe("timeout option", () => {
it("rejects promise after timeout elapses", async () => {
vi.useFakeTimers()
try {
const { execute } = useEventReply("slow-event", { timeout: 500 })
const promise = execute({})
vi.advanceTimersByTime(501)
await expect(promise).rejects.toThrow("timed out after 500ms")
} finally {
vi.useRealTimers()
}
})
it("resets isLoading after timeout", async () => {
vi.useFakeTimers()
try {
const { isLoading, execute } = useEventReply("slow-event", { timeout: 300 })
const promise = execute({})
expect(get(isLoading)).toBe(true)
vi.advanceTimersByTime(301)
expect(get(isLoading)).toBe(false)
await promise.catch(() => {})
} finally {
vi.useRealTimers()
}
})
it("does NOT reject if reply arrives before timeout", async () => {
vi.useFakeTimers()
try {
const { execute } = useEventReply<{ result: number }>("fast-event", { timeout: 1000 })
const promise = execute({})
// Reply arrives before timeout.
lastReplyCallback!({ result: 7 }, 1)
vi.advanceTimersByTime(999)
const reply = await promise
expect(reply).toEqual({ result: 7 })
} finally {
vi.useRealTimers()
}
})
it("data store is unchanged after timeout", async () => {
vi.useFakeTimers()
try {
const { data, execute } = useEventReply<{ result: number }>("slow-event", {
defaultValue: { result: 0 },
timeout: 500,
})
const promise = execute({})
vi.advanceTimersByTime(501)
await promise.catch(() => {})
expect(get(data)).toEqual({ result: 0 })
} finally {
vi.useRealTimers()
}
})
it("stale timeout callback is ignored after cancel", async () => {
vi.useFakeTimers()
try {
const { data, execute, cancel } = useEventReply<{ result: number }>("slow-event", {
timeout: 500,
})
const promise = execute({})
cancel()
vi.advanceTimersByTime(501)
// Should not throw or update state.
expect(get(data)).toBeNull()
await promise.catch(() => {})
} finally {
vi.useRealTimers()
}
})
})
// ---------------------------------------------------------------------------
// onDestroy cleanup
// ---------------------------------------------------------------------------
describe("onDestroy cleanup", () => {
it("cancels any pending execution when component is destroyed", () => {
const { isLoading, execute } = useEventReply("compute")
const destroyFn = vi.mocked(onDestroy).mock.calls[0][0] as () => void
const p = execute({})
p.catch(() => {})
expect(get(isLoading)).toBe(true)
destroyFn()
expect(get(isLoading)).toBe(false)
})
it("rejects pending promise on destroy", async () => {
const { execute } = useEventReply("compute")
const destroyFn = vi.mocked(onDestroy).mock.calls[0][0] as () => void
const promise = execute({})
destroyFn()
await expect(promise).rejects.toThrow("was cancelled")
})
})
// ---------------------------------------------------------------------------
// Graceful degradation without LiveSvelte context
// ---------------------------------------------------------------------------
describe("graceful degradation without LiveSvelte context", () => {
beforeEach(() => {
vi.mocked(getContext).mockReturnValue(null)
})
it("initialises without throwing when context is absent", () => {
expect(() => useEventReply("compute")).not.toThrow()
})
it("execute() returns a rejected promise when context is absent", async () => {
const { execute } = useEventReply("compute")
await expect(execute({})).rejects.toThrow("no LiveSvelte context")
})
it("does not call pushEvent when context is absent", async () => {
const { execute } = useEventReply("compute")
await execute({}).catch(() => {})
expect(mockPushEvent).not.toHaveBeenCalled()
})
it("data and isLoading stores still work without live context", () => {
const { data, isLoading } = useEventReply("compute", { defaultValue: { x: 1 } })
expect(get(data)).toEqual({ x: 1 })
expect(get(isLoading)).toBe(false)
})
it("cancel() does not throw when context is absent", () => {
const { cancel } = useEventReply("compute")
expect(() => cancel()).not.toThrow()
})
})

View file

@ -0,0 +1,174 @@
/**
* useEventReply Svelte composable for Phoenix LiveView request-response events.
*
* Binds a named event to pushEvent, returning reactive `data` and `isLoading` stores
* plus a promise-based `execute()` that resolves with the server's {:reply, map, socket}.
*
* Usage:
* ```svelte
* <script lang="ts">
* import { useEventReply } from "live_svelte"
*
* const { data, isLoading, execute, cancel } = useEventReply<{ result: number }>("compute")
* </script>
*
* <button onclick={() => execute({ value: 21 })}>
* {$isLoading ? "Loading..." : "Compute"}
* </button>
* {#if $data}
* <p>Result: {$data.result}</p>
* {/if}
* ```
*/
import { writable, get, type Readable, type Writable } from "svelte/store"
import { onDestroy } from "svelte"
import { useLiveSvelte } from "./composables"
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** Options for `useEventReply`. */
export interface UseEventReplyOptions<T> {
/** Initial value for `data` store before first reply. Defaults to `null`. */
defaultValue?: T
/**
* Transform the reply before storing it in `data`.
* Receives the server reply and current store value; return value is stored.
*/
updateData?: (reply: T, currentData: T | null) => T
/** Reject the promise if the LiveView has not replied within this many milliseconds. */
timeout?: number
}
/** Return value of `useEventReply`. */
export interface UseEventReplyReturn<T, P extends object | void = object> {
/** Reactive store of the last reply data (`null` until first successful reply). */
data: Readable<T | null>
/** `true` while the event is in-flight, `false` otherwise. */
isLoading: Readable<boolean>
/**
* Push the named event to Phoenix with optional params.
* Returns a promise that resolves with the reply payload from `{:reply, map, socket}`.
* Rejects if already executing, if no LiveSvelte context, or if timeout expires.
*/
execute(params?: P): Promise<T>
/**
* Cancel the in-flight execution.
* Rejects the pending promise and resets `isLoading` to `false`.
* The execution token is incremented so any stale in-flight callback is ignored.
*/
cancel(): void
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export function useEventReply<T = unknown, P extends object | void = object>(
eventName: string,
options?: UseEventReplyOptions<T>
): UseEventReplyReturn<T, P> {
// Graceful degradation: works without LiveSvelte context (SSR, tests without mock).
let liveCtx: ReturnType<typeof useLiveSvelte> | null = null
try {
liveCtx = useLiveSvelte()
} catch {
// SSR or test without LiveSvelte context — pushEvent unavailable.
}
// Core reactive stores.
const dataStore: Writable<T | null> = writable(options?.defaultValue ?? null)
const isLoadingStore: Writable<boolean> = writable(false)
// Execution-token counter — incremented on each new execution or cancellation.
// Used to ignore stale callbacks from previous (superseded) executions.
let executionToken = 0
// Stored so cancel() can reject the pending promise.
let pendingReject: ((err: Error) => void) | null = null
function execute(params?: P): Promise<T> {
if (get(isLoadingStore)) {
console.warn(
`useEventReply: "${eventName}" is already executing. Call cancel() first.`
)
return Promise.reject(
new Error(`useEventReply: "${eventName}" is already executing`)
)
}
if (!liveCtx) {
return Promise.reject(
new Error(
`useEventReply: no LiveSvelte context — "${eventName}" must be called inside a LiveSvelte-mounted component`
)
)
}
isLoadingStore.set(true)
const currentToken = ++executionToken
return new Promise<T>((resolve, reject) => {
pendingReject = reject
// Optional timeout: reject if the LiveView hasn't replied in time.
let timeoutId: ReturnType<typeof setTimeout> | undefined
if (options?.timeout != null) {
timeoutId = setTimeout(() => {
if (currentToken === executionToken) {
executionToken++
isLoadingStore.set(false)
pendingReject = null
reject(
new Error(
`useEventReply: "${eventName}" timed out after ${options.timeout}ms`
)
)
}
}, options.timeout)
}
liveCtx!.pushEvent(
eventName,
(params as object | undefined) ?? {},
(reply: unknown, _ref: number) => {
// Only update state if this is still the current execution.
if (currentToken === executionToken) {
if (timeoutId != null) clearTimeout(timeoutId)
const typedReply = reply as T
const updated = options?.updateData
? options.updateData(typedReply, get(dataStore))
: typedReply
dataStore.set(updated)
isLoadingStore.set(false)
pendingReject = null
resolve(typedReply)
}
// Token mismatch: execution was cancelled — ignore stale reply.
}
)
})
}
function cancel(): void {
if (pendingReject) {
pendingReject(new Error(`useEventReply: "${eventName}" was cancelled`))
pendingReject = null
}
// Increment token to invalidate any in-flight callbacks.
executionToken++
isLoadingStore.set(false)
}
// Auto-cancel on component destroy to prevent dangling promises.
onDestroy(() => cancel())
return {
data: { subscribe: dataStore.subscribe },
isLoading: { subscribe: isLoadingStore.subscribe },
execute,
cancel,
}
}

View file

@ -0,0 +1,41 @@
<script lang="ts">
import {useEventReply} from "live_svelte"
const {data, isLoading, execute, cancel} = useEventReply<{result: number; input: number}, {value: number}>("compute")
let inputValue = $state(21)
</script>
<div data-testid="event-reply-container" class="w-full max-w-md mx-auto card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body gap-4">
<h3 class="card-title text-base">Request-Response Demo</h3>
<div class="form-control">
<label class="label" for="value-input">
<span class="label-text">Input value</span>
</label>
<input id="value-input" data-testid="value-input" type="number" bind:value={inputValue} class="input input-bordered" />
</div>
<div class="flex gap-2">
<button
data-testid="compute-btn"
onclick={() => execute({value: inputValue})}
disabled={$isLoading}
class="btn bg-brand text-white border-0 hover:opacity-90 flex-1"
>
{$isLoading ? "Computing..." : "Compute ×2"}
</button>
<button data-testid="cancel-btn" onclick={() => cancel()} disabled={!$isLoading} class="btn btn-ghost"> Cancel </button>
</div>
{#if $data}
<div data-testid="reply-result" class="alert">
<span>
Result: <span data-testid="result-value" class="font-bold">{$data.result}</span>
(input was {$data.input})
</span>
</div>
{/if}
</div>
</div>

View file

@ -62,7 +62,8 @@ defmodule ExampleWeb.Layouts do
%{label: "Form (useLiveForm)", to: ~p"/live-form"},
%{label: "Navigation (useLiveNavigation)", to: ~p"/live-navigation"},
%{label: "Composition (useLiveSvelte)", to: ~p"/live-composition"},
%{label: "File Upload (useLiveUpload)", to: ~p"/live-upload"}
%{label: "File Upload (useLiveUpload)", to: ~p"/live-upload"},
%{label: "Event Reply (useEventReply)", to: ~p"/live-event-reply"}
]
}
]

View file

@ -11,8 +11,7 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
<span class="badge badge-primary badge-lg">1</span>
Basics
<span class="badge badge-primary badge-lg">1</span> Basics
</h2>
<ul class="space-y-2">
<li>
@ -41,15 +40,11 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
<span class="badge badge-success badge-lg">2</span>
Interactive
<span class="badge badge-success badge-lg">2</span> Interactive
</h2>
<ul class="space-y-2">
<li>
<a
href={~p"/live-simple-counter"}
class="link link-primary"
>
<a href={~p"/live-simple-counter"} class="link link-primary">
Counter
</a>
<span class="text-base-content/50 text-sm">- Server + client state</span>
@ -67,10 +62,7 @@
<span class="text-base-content/50 text-sm">- Inline Svelte with ~V sigil</span>
</li>
<li>
<a
href={~p"/plus-minus-svelte"}
class="link link-primary"
>
<a href={~p"/plus-minus-svelte"} class="link link-primary">
Plus/Minus (Static)
</a>
<span class="text-base-content/50 text-sm">- Dead view integration</span>
@ -82,10 +74,7 @@
<span class="text-base-content/50 text-sm">- LiveView integration</span>
</li>
<li>
<a
href={~p"/live-plus-minus-hybrid"}
class="link link-primary"
>
<a href={~p"/live-plus-minus-hybrid"} class="link link-primary">
Hybrid Counter
</a>
<span class="text-base-content/50 text-sm">- Client + server events</span>
@ -104,8 +93,7 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
<span class="badge badge-secondary badge-lg">3</span>
Data
<span class="badge badge-secondary badge-lg">3</span> Data
</h2>
<ul class="space-y-2">
<li>
@ -115,10 +103,7 @@
<span class="text-base-content/50 text-sm">- Dynamic list updates</span>
</li>
<li>
<a
href={~p"/live-breaking-news"}
class="link link-primary"
>
<a href={~p"/live-breaking-news"} class="link link-primary">
Breaking News
</a>
<span class="text-base-content/50 text-sm">- Real-time updates with ~V sigil</span>
@ -145,7 +130,9 @@
<a href={~p"/live-id-list-diff"} class="link link-primary">
ID List Diff
</a>
<span class="text-base-content/50 text-sm">- ID-based list diffing with minimal patch ops</span>
<span class="text-base-content/50 text-sm">
- ID-based list diffing with minimal patch ops
</span>
</li>
</ul>
</div>
@ -155,24 +142,17 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
<span class="badge badge-warning badge-lg">4</span>
Slots
<span class="badge badge-warning badge-lg">4</span> Slots
</h2>
<ul class="space-y-2">
<li>
<a
href={~p"/live-slots-simple"}
class="link link-primary"
>
<a href={~p"/live-slots-simple"} class="link link-primary">
Simple Slots
</a>
<span class="text-base-content/50 text-sm">- Basic slot usage</span>
</li>
<li>
<a
href={~p"/live-slots-dynamic"}
class="link link-primary"
>
<a href={~p"/live-slots-dynamic"} class="link link-primary">
Dynamic Slots
</a>
<span class="text-base-content/50 text-sm">- Named slots with dynamic content</span>
@ -185,15 +165,11 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
<span class="badge badge-error badge-lg">5</span>
Advanced
<span class="badge badge-error badge-lg">5</span> Advanced
</h2>
<ul class="space-y-2">
<li>
<a
href={~p"/live-client-side-loading"}
class="link link-primary"
>
<a href={~p"/live-client-side-loading"} class="link link-primary">
Client Loading
</a>
<span class="text-base-content/50 text-sm">- Loading states and SSR</span>
@ -206,8 +182,7 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
<span class="badge badge-info badge-lg">6</span>
Ecto
<span class="badge badge-info badge-lg">6</span> Ecto
</h2>
<ul class="space-y-2">
<li>
@ -224,15 +199,16 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h2 class="text-lg font-semibold text-base-content mb-4 flex items-center gap-2">
<span class="badge badge-neutral badge-lg">7</span>
Composables
<span class="badge badge-neutral badge-lg">7</span> Composables
</h2>
<ul class="space-y-2">
<li>
<a href={~p"/live-form"} class="link link-primary">
Form (useLiveForm)
</a>
<span class="text-base-content/50 text-sm">- Server-side validation with Ecto changesets</span>
<span class="text-base-content/50 text-sm">
- Server-side validation with Ecto changesets
</span>
</li>
<li>
<a href={~p"/live-navigation"} class="link link-primary">
@ -244,13 +220,25 @@
<a href={~p"/live-composition"} class="link link-primary">
Composition (useLiveSvelte)
</a>
<span class="text-base-content/50 text-sm">- pushEvent from composed component trees</span>
<span class="text-base-content/50 text-sm">
- pushEvent from composed component trees
</span>
</li>
<li>
<a href={~p"/live-upload"} class="link link-primary">
File Upload (useLiveUpload)
</a>
<span class="text-base-content/50 text-sm">- Upload files using Phoenix LiveView uploads</span>
<span class="text-base-content/50 text-sm">
- Upload files using Phoenix LiveView uploads
</span>
</li>
<li>
<a href={~p"/live-event-reply"} class="link link-primary">
Event reply (useEventReply)
</a>
<span class="text-base-content/50 text-sm">
- Push an event to Phoenix and receive reply
</span>
</li>
</ul>
</div>
@ -260,11 +248,7 @@
<div class="mt-12 text-center text-base-content/50 text-sm">
<p>
View the source code on
<a
href="https://github.com/woutdp/live_svelte"
target="_blank"
class="link link-primary"
>
<a href="https://github.com/woutdp/live_svelte" target="_blank" class="link link-primary">
GitHub
</a>
</p>

View file

@ -0,0 +1,32 @@
defmodule ExampleWeb.LiveEventReply do
@moduledoc """
LiveView demo for the `useEventReply()` composable.
Demonstrates request-response pattern: push event, await typed reply.
"""
use ExampleWeb, :live_view
def mount(_params, _session, socket) do
{:ok, socket}
end
def handle_event("compute", %{"value" => value}, socket) do
input = value || 0
{:reply, %{result: input * 2, input: input}, socket}
end
def handle_event("compute", _params, socket) do
{:reply, %{result: 0, input: 0}, socket}
end
def render(assigns) do
~H"""
<div class="flex flex-col items-center gap-6 p-6">
<h2 class="text-center text-2xl font-light my-4">Event Reply (useEventReply)</h2>
<p class="text-sm text-base-content/50 text-center max-w-md">
Push an event to Phoenix and receive a typed reply via promise.
</p>
<.svelte name="EventReplyDemo" props={%{}} socket={@socket} />
</div>
"""
end
end

View file

@ -43,9 +43,9 @@ defmodule ExampleWeb.Router do
live "/live-notes-otp", LiveNotesOtp
live "/live-form", LiveForm
live "/live-upload", LiveUpload
live "/live-event-reply", LiveEventReply
live "/live-navigation", LiveNavigation
live "/live-navigation/:page", LiveNavigation
# not referenced in app.html.heex:
live "/live-composition", LiveComposition
end

View file

@ -0,0 +1,49 @@
defmodule ExampleWeb.LiveEventReplyTest do
@moduledoc """
E2E tests for the LiveEventReply LiveView (/live-event-reply).
Validates useEventReply() composable: initial render and request-response flow.
"""
use ExampleWeb.FeatureCase, async: false
@moduletag :e2e
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Wait for an element with the given testid to appear (retries up to 3s).
defp wait_for(session, testid, attempts \\ 30)
defp wait_for(_session, testid, 0), do: raise("timeout waiting for [data-testid='#{testid}']")
defp wait_for(session, testid, attempts) do
els = session |> all(Query.css("[data-testid='#{testid}']"))
if length(els) > 0 do
session
else
:timer.sleep(100)
wait_for(session, testid, attempts - 1)
end
end
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
test "initial render shows compute button with no result", %{session: session} do
session
|> visit("/live-event-reply")
|> wait_for("compute-btn")
|> assert_has(Query.css("[data-testid='compute-btn']"))
|> refute_has(Query.css("[data-testid='reply-result']"))
end
test "clicking compute shows result from Phoenix reply", %{session: session} do
session
|> visit("/live-event-reply")
|> wait_for("compute-btn")
|> click(Query.css("[data-testid='compute-btn']"))
|> wait_for("reply-result")
|> assert_has(Query.css("[data-testid='result-value']"))
end
end