diff --git a/docs/superpowers/plans/2026-04-14-phase-1e-signalr.md b/docs/superpowers/plans/2026-04-14-phase-1e-signalr.md new file mode 100644 index 00000000..d6098053 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-phase-1e-signalr.md @@ -0,0 +1,79 @@ +# Phase 1E -- SignalR wrapper contracts + +## Goal + +Deliver a reference-counted SignalR connection wrapper and a generic SSR-safe React hook (`useLiveFlights`) that subscribes to live flight data channels. All SignalR imports are dynamic so they never enter the SSR bundle. + +## Deliverables + +| File | Purpose | +|---|---| +| `src/shared/signalr/connection.ts` | `SignalRConnection` class, `getSharedConnection`, `ConnectionStatus` type, `HubOptions` interface | +| `src/shared/signalr/connection.test.ts` | Vitest tests for connection lifecycle, ref-counting, grace period, status changes | +| `src/shared/hooks/useLiveFlights.ts` | Generic live-data hook wrapping SignalR subscription | +| `src/shared/hooks/useLiveFlights.test.ts` | Vitest tests for hook behavior including SSR path | + +## Tasks + +### Task 1 -- Install `@microsoft/signalr` + +Add `@microsoft/signalr` to the root `package.json` dependencies. + +**Commit:** "Add @microsoft/signalr dependency" + +### Task 2 -- `SignalRConnection` class + tests + +Create `src/shared/signalr/connection.ts` with: + +- `HubOptions` interface: `hubUrl`, `reconnectDelaysMs` (default `[0, 2000, 10000, 30000]`), `gracePeriodMs` (default `5000`) +- `ConnectionStatus` type: `"idle" | "connecting" | "live" | "reconnecting" | "offline"` +- `SignalRConnection` class: + - Constructor takes `HubOptions`, uses dynamic `import("@microsoft/signalr")` to build connection + - `subscribe(channel, handler)` returns unsubscribe fn; increments ref count; starts connection on first subscriber + - `onStatusChange(handler)` returns unsubscribe fn + - `get status()` returns current `ConnectionStatus` + - Reference counting: connection closes after last unsubscribe + grace period elapses +- `getSharedConnection(options)` -- singleton map keyed by `hubUrl` + +Create `src/shared/signalr/connection.test.ts` with tests: + +1. Two rapid subscribes produce exactly one `HubConnection.start()` call +2. Unmount + remount within grace period reuses connection (no second `start()`) +3. Unmount + remount after grace period creates fresh connection +4. Status transitions fire `onStatusChange` handlers +5. `getSharedConnection` returns same instance for same `hubUrl` + +**Commit:** "Add SignalRConnection ref-counted wrapper with tests" + +### Task 3 -- `useLiveFlights` hook + tests + +Create `src/shared/hooks/useLiveFlights.ts`: + +- Generic `useLiveFlights(params, initialData, config)` hook +- SSR-safe: checks `typeof window !== "undefined"` before subscribing +- Uses `useEffect` for subscribe/unsubscribe lifecycle +- Returns `{ data: TData[]; connectionStatus: ConnectionStatus }` + +Create `src/shared/hooks/useLiveFlights.test.ts`: + +1. SSR path returns `initialData` without importing `@microsoft/signalr` +2. Client path subscribes to the correct channel +3. Data updates when messages arrive +4. Cleanup unsubscribes on unmount + +**Commit:** "Add useLiveFlights SSR-safe hook with tests" + +### Task 4 -- Verification + +- `pnpm typecheck && pnpm lint && pnpm test` +- Confirm ESLint SSR-bundle guard still blocks `@microsoft/signalr` in `src/routes/` + +**Commit:** none (verification only) + +## Exit gate + +- Two rapid `useEffect` mounts (Strict Mode simulation) result in exactly one `HubConnection.start()` call +- Unmount + remount within grace period reuses connection +- Unmount + remount after grace period creates fresh connection +- SSR render path does not import `@microsoft/signalr` +- `pnpm typecheck && pnpm lint && pnpm test` all green