Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 139 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -723,11 +723,29 @@ The unified reconnection logic applies to all transport types: on error with `re

Formula: `min(initialDelay * (backoffMultiplier ^ attemptNumber), maxDelay)`

### maxAttempts Exhaustion

When the attempt counter reaches `maxAttempts`, reconnection stops silently. No additional error callback is fired at the point of exhaustion — `onError` fires on each failed attempt, but there is no dedicated "gave up" notification.

To detect this condition, monitor `useSSEStatus()`:

```typescript
const { connected, error, reconnectAttempt } = useSSEStatus()

// connected: false — no active connection
// error: populated with the last connection error
// reconnectAttempt: equals maxAttempts (or close to it)
```

When these three signals align — `connected` is `false`, `error` is set, and no further `reconnectAttempt` increments are observed — the provider has stopped retrying. At that point you can display a manual reconnect UI or surface the error to the user.

The visibility handler (tab focus) applies the **same** `maxAttempts` guard. After exhaustion, switching back to a hidden-then-visible tab will **not** trigger another reconnect attempt. If you need unlimited reconnection on tab focus regardless of prior failures, set `maxAttempts: Infinity` (the default) or reset the page.

### Browser Tab Visibility

When the browser tab becomes hidden:
- SSE connection may be throttled by the browser
- On tab focus, connection is checked and re-established if needed
- On tab focus, connection is checked and re-established if needed, subject to the `maxAttempts` limit
- Pending reconnect timers are cancelled before immediate reconnection to avoid duplicate connections

### Error Handling
Expand Down Expand Up @@ -996,6 +1014,126 @@ mockSSE.restore: () => void

---

## Troubleshooting

### Connection stuck in "connecting" state

**Check the SSE endpoint response.**
The server must respond with HTTP 200 and `Content-Type: text/event-stream`. Any other status code or content type causes the connection to fail silently or loop.

```text
# Verify with curl
curl -v -N -H "Accept: text/event-stream" http://localhost:3000/api/events
# Look for: < HTTP/1.1 200 OK
# < Content-Type: text/event-stream
```

**Check for CORS errors.**
Open the browser devtools Network tab. If the SSE request is blocked, you will see a CORS error in the console. Ensure the server sets `Access-Control-Allow-Origin` for your client origin.

**Check credentials configuration.**
If your endpoint requires cookies or auth headers, the native `EventSource` does not send credentials by default. Switch to the fetch transport and set the appropriate headers:

```typescript
const config: SSEConfig = {
url: '/api/events',
headers: { Authorization: `Bearer ${token}` },
// or for cookies:
// credentials: 'include' requires a custom transport
events: { ... },
}
```

---

### Events not arriving

**Confirm events are terminated with `\n\n`.**
SSE requires each event block to end with a double newline. A single `\n` is a field separator, not an event boundary. The server must write:

```text
data: {"type":"order:updated","payload":{...}}\n\n
```

Use `formatSSEEvent` or `formatSSEData` from the library to avoid this mistake.

**Enable debug mode** to log every received event and routing decision:

```typescript
const config: SSEConfig = {
url: '/api/events',
debug: true,
events: { ... },
}
// Console will show: [reactiveSWR] Event received: { type: "...", payload: ... }
// And for unmatched events: [reactiveSWR] Unhandled event type: "..."
```

**Verify `parseEvent` returns the correct shape.**
The default parser expects unnamed events to contain JSON with `{ type: string, payload: unknown }`. If your server sends a different format, provide a custom `parseEvent`:

```typescript
parseEvent: (event) => ({
type: event.type || 'message', // must be a non-empty string
payload: JSON.parse(event.data), // payload can be any value
})
```

If `parseEvent` throws or returns an object missing `type`, the event is silently dropped (or logged with `debug: true`).

---

### Memory usage grows over time

**Ensure `useSSEEvent` cleanup functions are called.**
`useSSEEvent` registers a handler inside `SSEProvider`. In custom hooks that call `useSSEEvent` directly, verify the enclosing component unmounts cleanly. If you ever call the subscribe API from `useSSEContext` manually, save and invoke the returned cleanup function:

```typescript
const { subscribe } = useSSEContext()
useEffect(() => {
const cleanup = subscribe('order:updated', handleOrderUpdate)
return cleanup // required — omitting this leaks the handler
}, [subscribe])
```

**Check `useSSEStream` with non-serializable bodies.**
`useSSEStream` uses reference counting to share and close connections. When the `body` option is a non-serializable type (`Blob`, `FormData`, `ArrayBuffer`, `ReadableStream`), each hook call gets its own connection key. Verify the component unmounts fully (no leaked component trees) so the refCount reaches zero and the transport closes.

---

### Reconnection not working

**Check whether `maxAttempts` has been reached.**
The default is `Infinity`, but if you set a finite limit the provider stops retrying after that many failures. Check `useSSEStatus().reconnectAttempt` against your configured `maxAttempts`:

```typescript
const { reconnectAttempt, connecting, connected } = useSSEStatus()
// reconnectAttempt increments on each retry
```

**Inspect the `onError` callback for the error type.**
Network-level errors (DNS failure, server down) arrive as a DOM `Event` on the `onerror` handler — they do not carry a descriptive message. Log the event to confirm the connection is actually closing:

```typescript
const config: SSEConfig = {
url: '/api/events',
onError: (event) => {
console.error('SSE error event:', event)
},
onDisconnect: () => {
console.warn('SSE disconnected, reconnection scheduled')
},
onConnect: () => {
console.info('SSE reconnected successfully')
},
events: { ... },
}
```

If `onDisconnect` never fires after `onError`, the transport's `readyState` did not transition to `CLOSED` (2). This can happen with custom transports that do not call `onerror` after closing — ensure your transport implementation sets `readyState` to `2` and fires `onerror` when the stream ends unexpectedly.

---

## Future Considerations

### Potential Enhancements
Expand Down
106 changes: 99 additions & 7 deletions src/SSEProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
SSEStatus,
SSETransport,
} from './types.ts'
import { SSEProviderError } from './types.ts'

interface SSEContextValue {
status: SSEStatus
Expand Down Expand Up @@ -237,6 +238,12 @@ export function SSEProvider({
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const attemptCountRef = useRef<number>(0)

// Re-entrancy guard: prevents overlapping createConnection() calls when URL
// changes rapidly across multiple renders. Also serves as a monotonic
// connection ID so that callbacks from stale connections are ignored.
const connectionGenerationRef = useRef<number>(0)
const creatingConnectionRef = useRef<boolean>(false)

const subscribe = useCallback(
(eventType: string, handler: (payload: unknown) => void) => {
const subscribers = subscribersRef.current
Expand Down Expand Up @@ -378,9 +385,33 @@ export function SSEProvider({
)

/**
* Create and configure a new connection (EventSource or transport)
* Create and configure a new connection (EventSource or transport).
*
* Re-entrancy guard: if a createConnection() call is already in progress
* (possible during rapid URL changes across synchronous renders), the new call
* is skipped. The caller is responsible for clearing `creatingConnectionRef`
* only through this function's own execution paths.
*
* Each call also increments a monotonic generation counter so that callbacks
* installed by a superseded connection can detect they are stale and bail out
* early, preventing out-of-order onConnect / onDisconnect sequences.
*/
const createConnection = useCallback(() => {
// Re-entrancy guard: bail out if a connection is already being created
if (creatingConnectionRef.current) {
return
}
creatingConnectionRef.current = true

// Increment generation so closures from any previous connection know they
// are stale. Capture the current generation for this connection's callbacks.
connectionGenerationRef.current += 1
const myGeneration = connectionGenerationRef.current

// Helper: returns true when this connection is still the active one
const isActiveConnection = () =>
myGeneration === connectionGenerationRef.current

// Clean up any existing connection
if (eventSourceRef.current) {
const oldConnection = eventSourceRef.current
Expand All @@ -398,9 +429,18 @@ export function SSEProvider({
try {
connection = createTransport(url)
} catch (error) {
// Release the guard before returning on error
creatingConnectionRef.current = false

const providerError = new SSEProviderError(
error instanceof Error ? error.message : String(error),
'TRANSPORT',
{ cause: error },
)

configRef.current.onEventError?.(
{ type: 'transport_error', payload: null },
error,
providerError,
)
// Install a no-op closed transport to prevent re-entry on next render
eventSourceRef.current = {
Expand All @@ -416,16 +456,24 @@ export function SSEProvider({
updateStatus({
connected: false,
connecting: false,
error: error instanceof Error ? error : new Error(String(error)),
error: providerError,
})
return
}

eventSourceRef.current = connection
currentUrlRef.current = url

// Release the guard now that the connection object is stored
creatingConnectionRef.current = false

// Handle connection open
connection.onopen = () => {
// Ignore callbacks from superseded connections (rapid URL changes)
if (!isActiveConnection()) {
return
}

// Clear any pending reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
Expand All @@ -446,8 +494,13 @@ export function SSEProvider({

// Handle connection error
connection.onerror = (event: Event) => {
// Ignore callbacks from superseded connections (rapid URL changes)
if (!isActiveConnection()) {
return
}

updateStatus({
error: new Error('EventSource connection error'),
error: new SSEProviderError('SSE connection error', 'NETWORK'),
})
configRef.current.onError?.(event)

Expand Down Expand Up @@ -489,6 +542,11 @@ export function SSEProvider({

// Handle generic messages (unnamed events)
connection.onmessage = (event: MessageEvent) => {
// Ignore messages from superseded connections (rapid URL changes)
if (!isActiveConnection()) {
return
}

try {
const parseEvent = configRef.current.parseEvent ?? defaultParseEvent
const parsed = parseEvent(event)
Expand All @@ -499,15 +557,45 @@ export function SSEProvider({
}
configRef.current.onEventError?.(
{ type: 'parse_error', payload: event.data },
error as Error,
new SSEProviderError(
error instanceof Error ? error.message : String(error),
'PARSE',
{ cause: error },
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
}
}

// Register listeners for each named event type in config.events
// Register listeners for each named event type in config.events.
//
// Memoization of these handler closures (e.g. via useRef<Map<string, handler>>)
// was evaluated and intentionally skipped for the following reasons:
//
// 1. These closures are NOT recreated on every render. createConnection() is a
// useCallback and is only called at connection time: initial mount, URL change,
// or reconnection after a disconnect. Between connections the same handler
// instances remain registered on the EventSource — no render-driven recreation.
//
// 2. Reusing handlers across reconnections would be unsafe. createConnection's
// dependencies include processEvent, which may change identity if mutate or
// other upstream hooks change. A memoized handler Map would silently close over
// a stale processEvent, producing incorrect behaviour on reconnect.
//
// 3. The allocation overhead is proportional to the number of event types
// (typically a small constant) and occurs only at connection/reconnection time,
// not continuously. The GC pressure is negligible in practice.
//
// Correctness is preserved by reading all mutable config through configRef.current
// inside each handler; only the per-event `eventType` string is closed over by
// value, which is the intended behaviour for parseNamedEvent dispatch.
const eventTypes = Object.keys(configRef.current.events)
for (const eventType of eventTypes) {
const handler = (event: MessageEvent) => {
// Ignore messages from superseded connections (rapid URL changes)
if (!isActiveConnection()) {
return
}

try {
let parsed: ParsedEvent
if (configRef.current.parseEvent) {
Expand All @@ -524,7 +612,11 @@ export function SSEProvider({
}
configRef.current.onEventError?.(
{ type: 'parse_error', payload: event.data },
error as Error,
new SSEProviderError(
error instanceof Error ? error.message : String(error),
'PARSE',
{ cause: error },
),
)
}
}
Expand Down
Loading