CI / build + unit + e2e (pull_request) Successful in 1m14s
- DELETE /api/games/:id route + Db.delete() (404 when absent) - PresetPicker: per-saved-game trash button + confirm dialog (Cancel/Delete) - App: runDeleteGame; useSse onError -> goHome on 'deleted'/'not found' (a deleted/missing game now returns the live tab and any deep link home) - DeleteGameResponse in shared/protocol.ts; api.deleteGame() - unit test (Db.delete) + 2 e2e tests (home-screen delete; in-flight delete returns the active tab home and a fresh deep link lands on home) - version 0.7.0 -> 0.8.0 + changelog Fixes #20
62 lines
2.0 KiB
TypeScript
62 lines
2.0 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import type { GameState } from '@shared/types';
|
|
import type { TurnResult } from '@shared/protocol';
|
|
|
|
interface SseHandlers {
|
|
onState?: (state: GameState) => void;
|
|
onTurn?: (state: GameState, result: TurnResult) => void;
|
|
/**
|
|
* Fired for a server-sent NAMED `event: error` (a MessageEvent with a JSON
|
|
* body `{ message }`) — e.g. the game was deleted or no longer exists. Pure
|
|
* connection errors (no data) are ignored and EventSource auto-reconnects.
|
|
*/
|
|
onError?: (message: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Subscribe to a game's SSE stream. The server sends a `state` event on connect
|
|
* and a `turn` event after every turn; EventSource auto-reconnects on drop, so a
|
|
* reload always resyncs to the persisted server state.
|
|
*
|
|
* Handlers are kept in a ref so the (id-keyed) effect always invokes the latest
|
|
* callbacks without re-subscribing on every render.
|
|
*/
|
|
export function useSse(id: string | null, handlers: SseHandlers): void {
|
|
const handlersRef = useRef(handlers);
|
|
handlersRef.current = handlers;
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
const es = new EventSource(`/sse/${id}`);
|
|
const onState = (e: MessageEvent) => {
|
|
try {
|
|
handlersRef.current.onState?.(JSON.parse(e.data).state as GameState);
|
|
} catch {
|
|
/* ignore malformed */
|
|
}
|
|
};
|
|
const onTurn = (e: MessageEvent) => {
|
|
try {
|
|
const d = JSON.parse(e.data) as { state: GameState; result: TurnResult };
|
|
handlersRef.current.onTurn?.(d.state, d.result);
|
|
} catch {
|
|
/* ignore malformed */
|
|
}
|
|
};
|
|
const onError = (e: Event) => {
|
|
if (e instanceof MessageEvent && e.data) {
|
|
try {
|
|
const d = JSON.parse(e.data) as { message?: string };
|
|
if (d.message) handlersRef.current.onError?.(d.message);
|
|
} catch {
|
|
/* ignore malformed */
|
|
}
|
|
}
|
|
};
|
|
es.addEventListener('state', onState);
|
|
es.addEventListener('turn', onTurn);
|
|
es.addEventListener('error', onError);
|
|
return () => es.close();
|
|
}, [id]);
|
|
}
|