Skip to content
Open
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
70 changes: 70 additions & 0 deletions test/tests/ext/hx-sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,76 @@ describe('hx-sse SSE extension', function() {
assert.equal(closeReason, 'ended', 'Close reason should be "ended"');
});

it('pauseOnBackground reconnect sends Last-Event-ID for message replay', async function() {
this.timeout(5000);
const enc = new TextEncoder();
const controllers = [];

// All notifications the "server" knows about
const allMessages = [
{id: 'n-1', data: '<p>Notification 1</p>'},
{id: 'n-2', data: '<p>Notification 2</p>'},
{id: 'n-3', data: '<p>Notification 3</p>'}
];

// Simulate a server that replays missed messages using Last-Event-ID
fetchMock.mockResponse('GET', '/notifications', () => {
let ctrl;
const body = new ReadableStream({ start(c) { ctrl = c; controllers.push(c); } });
const response = new MockResponse(body, {
headers: { 'Content-Type': 'text/event-stream' }
});
response.body = body;

// On reconnect, check Last-Event-ID and replay missed messages
if (controllers.length > 1) {
const calls = fetchMock.getCalls();
const lastId = calls[calls.length - 1].request.headers?.['Last-Event-ID'];
if (lastId) {
let start = allMessages.findIndex(m => m.id === lastId) + 1;
for (let i = start; i < allMessages.length; i++) {
ctrl.enqueue(enc.encode(`id: ${allMessages[i].id}\ndata: ${allMessages[i].data}\n\n`));
}
}
}
return response;
});

createProcessedHTML('<button hx-get="/notifications" hx-target="#output" hx-config="sse.reconnect:true sse.pauseOnBackground:true sse.reconnectDelay:50ms sse.reconnectJitter:0" hx-swap="beforeend">Connect</button><div id="output"></div>');

find('button').click();
await waitForEvent('htmx:after:sse:connection');

// Server sends first 2 notifications
controllers[0].enqueue(enc.encode('id: n-1\ndata: <p>Notification 1</p>\n\n'));
await waitForEvent('htmx:after:sse:message');
controllers[0].enqueue(enc.encode('id: n-2\ndata: <p>Notification 2</p>\n\n'));
await waitForEvent('htmx:after:sse:message');

assert.include(find('#output').innerHTML, 'Notification 1');
assert.include(find('#output').innerHTML, 'Notification 2');

// Tab goes to background (n-3 exists on the server but client never received it)
Object.defineProperty(document, 'hidden', {value: true, configurable: true});
document.dispatchEvent(new Event('visibilitychange'));
await new Promise(r => setTimeout(r, 50));

// Tab comes back — extension reconnects with Last-Event-ID: n-2
Object.defineProperty(document, 'hidden', {value: false, configurable: true});
document.dispatchEvent(new Event('visibilitychange'));
await waitForEvent('htmx:after:sse:connection', 3000);

// Verify Last-Event-ID header was sent
const reconnectCall = fetchMock.getCalls()[fetchMock.getCalls().length - 1];
assert.equal(reconnectCall.request.headers['Last-Event-ID'], 'n-2', 'Should send Last-Event-ID of last received message');

// Server replayed n-3 on reconnect — verify it was swapped in
await waitForEvent('htmx:after:sse:message', 1000);
assert.include(find('#output').innerHTML, 'Notification 3', 'Missed notification should be replayed on reconnect');

controllers[controllers.length - 1].close();
});

it('server retry field updates reconnect delay', async function() {
this.timeout(5000);
const stream = mockStreamResponse('/retry-test');
Expand Down
70 changes: 68 additions & 2 deletions www/content/extensions/sse.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,81 @@ Configure SSE behavior globally via `htmx.config.sse` or per-element via `hx-con
| `reconnectMaxDelay` | `60000` | `60000` | Maximum reconnect delay (ms) |
| `reconnectMaxAttempts` | `Infinity` | `Infinity` | Maximum reconnection attempts |
| `reconnectJitter` | `0.3` | `0.3` | Jitter factor (0-1) for delay randomization |
| `pauseOnBackground` | `true` | `false` | Close the stream when the tab is backgrounded, reconnect when visible |
| `pauseOnBackground` | `true` | `false` | Disconnect when the tab is backgrounded, reconnect when visible (see [Background Tab Behavior](#background-tab-behavior)) |

### Reconnection Strategy

The extension uses exponential backoff with jitter:

- **Formula**: `delay = min(reconnectDelay × 2^(attempt-1), reconnectMaxDelay)`
- **Jitter**: Adds ±`reconnectJitter` randomization to avoid thundering herd
- **Last-Event-ID**: Automatically sent on reconnection if the server provided message IDs
- **Last-Event-ID**: Automatically sent on reconnection if the server provided message IDs (see [Background Tab Behavior](#background-tab-behavior))

### Background Tab Behavior

When `pauseOnBackground` is enabled (the default for `hx-sse:connect`), the extension disconnects the
stream when the browser tab is hidden and reconnects when the tab becomes visible again. This exists
because some browsers (notably iOS Safari) silently kill SSE connections when the app is backgrounded
without firing any error events, leaving the connection in a zombie state.

**Messages sent by the server while the tab is in the background are not received by the client.** Whether
those messages can be recovered depends on your server:

- If the server includes `id:` fields in its SSE messages, the extension tracks the last received ID and
sends it as a `Last-Event-ID` header when reconnecting.
- If the server reads the `Last-Event-ID` header and replays missed messages, nothing is lost.
- If the server does not send `id:` fields or does not support `Last-Event-ID`, messages sent during the
background period are lost.

#### Example: Resumable Notifications Stream

**Server** (Python with FastAPI + sse-starlette):

```python
from fastapi import FastAPI, Request
from sse_starlette.sse import EventSourceResponse

app = FastAPI()
notifications = [] # In production, use a database

@app.get("/notifications")
async def sse(request: Request):
last_id = request.headers.get("last-event-id")

async def stream():
# Replay any missed messages
start = 0
if last_id:
for i, n in enumerate(notifications):
if str(n["id"]) == last_id:
start = i + 1
break
for n in notifications[start:]:
yield {"id": str(n["id"]), "data": n["data"]}

# Stream new messages as they arrive
seen = len(notifications)
while True:
if len(notifications) > seen:
for n in notifications[seen:]:
yield {"id": str(n["id"]), "data": n["data"]}
seen = len(notifications)
await asyncio.sleep(0.5)

return EventSourceResponse(stream())
```

**Client:**

```html
<div hx-sse:connect="/notifications" hx-swap="beforeend">
<!-- Notifications appear here -->
</div>
```

When the user switches tabs and comes back, the extension reconnects with
`Last-Event-ID: <last-received-id>`, and the server replays any notifications
that were sent in the meantime.

## Events

Expand Down