mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-24 01:18:53 +00:00
chore: added event reply
This commit is contained in:
parent
0cefad3200
commit
8a62ff7bff
10 changed files with 799 additions and 53 deletions
|
|
@ -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";
|
||||
|
|
|
|||
55
assets/js/live_svelte/types.d.ts
vendored
55
assets/js/live_svelte/types.d.ts
vendored
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
408
assets/js/live_svelte/useEventReply.test.ts
Normal file
408
assets/js/live_svelte/useEventReply.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
174
assets/js/live_svelte/useEventReply.ts
Normal file
174
assets/js/live_svelte/useEventReply.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
41
example_project/assets/svelte/EventReplyDemo.svelte
Normal file
41
example_project/assets/svelte/EventReplyDemo.svelte
Normal 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>
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
32
example_project/lib/example_web/live/live_event_reply.ex
Normal file
32
example_project/lib/example_web/live/live_event_reply.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue