diff --git a/src/editors/jetbrains/htmx.web-types.json b/src/editors/jetbrains/htmx.web-types.json
index 581fc7783..c08c6df76 100644
--- a/src/editors/jetbrains/htmx.web-types.json
+++ b/src/editors/jetbrains/htmx.web-types.json
@@ -536,14 +536,14 @@
"doc-url": "https://four.htmx.org/reference/events/htmx-after-viewTransition"
},
{
- "name": "before:sse:connection",
+ "name": "sse:before:connection",
"description": "Fires before an SSE connection attempt. `detail.connection` can be modified; set `cancelled` or cancel the event to stop connecting.",
- "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxbeforesseconnection"
+ "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxssebeforeconnection"
},
{
- "name": "after:sse:connection",
+ "name": "sse:after:connection",
"description": "Fires after a successful SSE connection or reconnection. `detail.connection` describes the connection.",
- "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxaftersseconnection"
+ "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxsseafterconnection"
},
{
"name": "sse:close",
@@ -556,14 +556,14 @@
"doc-url": "https://four.htmx.org/extensions/hx-sse#htmxsseerror"
},
{
- "name": "before:sse:message",
+ "name": "sse:before:message",
"description": "Fires before an SSE message is processed. `detail.message` contains `data`, `event`, `id`, and `cancelled`.",
- "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxbeforessemessage"
+ "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxssebeforemessage"
},
{
- "name": "after:sse:message",
+ "name": "sse:after:message",
"description": "Fires after an SSE message is processed. `detail.message` contains `data`, `event`, and `id`.",
- "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxafterssemessage"
+ "doc-url": "https://four.htmx.org/extensions/hx-sse#htmxsseaftermessage"
},
{
"name": "before:ws:connection",
diff --git a/src/ext/hx-sse.js b/src/ext/hx-sse.js
index 4448873e8..fea02c19d 100644
--- a/src/ext/hx-sse.js
+++ b/src/ext/hx-sse.js
@@ -1,25 +1,25 @@
(() => {
let api;
+ let warnedLegacyAttributes = new Set();
// ========================================
// HELPERS
// ========================================
- function getConfig(ctx) {
- let isConnect = api.attributeValue(ctx.sourceElement, 'hx-sse:connect') != null;
- let defaults = {
- reconnect: isConnect,
+ function getConfig(element) {
+ let hasHxSseConnect = api.attributeValue(element, 'hx-sse:connect') != null;
+ let hxConfig = api.HCON.parse(api.attributeValue(element, 'hx-config')).sse || {};
+
+ return {
+ reconnect: hasHxSseConnect,
reconnectDelay: 500,
reconnectMaxDelay: 60000,
reconnectMaxAttempts: Infinity,
reconnectJitter: 0.3,
- pauseOnBackground: isConnect
+ pauseOnBackground: hasHxSseConnect,
+ ...htmx.config.sse,
+ ...hxConfig
};
- let global = htmx.config.sse || {};
- // hx-config="sse.reconnect:true sse.reconnectDelay:50ms" is parsed by
- // core's __mergeConfig into ctx.request.sse during createRequestContext
- let perElement = ctx.request.sse || {};
- return {...defaults, ...global, ...perElement};
}
function clearLastEventIdHeader(headers) {
@@ -111,7 +111,7 @@
// with the saved request context (no full pipeline re-run).
async function handleSSEResponse(ctx) {
let element = ctx.sourceElement;
- let config = getConfig(ctx);
+ let config = getConfig(element);
let reconnectRequested = false;
let connection = {
@@ -153,13 +153,13 @@
}
connection.cancelled = false;
- if (!api.triggerHtmxEvent(element, 'htmx:before:sse:connection', {connection}) || connection.cancelled) {
+ if (!api.triggerHtmxEvent(element, 'htmx:sse:before:connection', {connection}) || connection.cancelled) {
cleanup(element, 'cancelled');
return;
}
connection.status = ctx.response.status;
- api.triggerHtmxEvent(element, 'htmx:after:sse:connection', {connection});
+ api.triggerHtmxEvent(element, 'htmx:sse:after:connection', {connection});
let currentResponse = ctx.response.raw;
@@ -192,7 +192,7 @@
}
connection.cancelled = false;
- if (!api.triggerHtmxEvent(element, 'htmx:before:sse:connection', {connection}) || connection.cancelled) break;
+ if (!api.triggerHtmxEvent(element, 'htmx:sse:before:connection', {connection}) || connection.cancelled) break;
await new Promise(r => {
connection.delayCanceller = r;
@@ -231,7 +231,7 @@
}
connection.status = currentResponse.status;
- api.triggerHtmxEvent(element, 'htmx:after:sse:connection', {connection});
+ api.triggerHtmxEvent(element, 'htmx:sse:after:connection', {connection});
connection.attempt = 0;
}
@@ -254,14 +254,14 @@
let detail = {
message: {data: msg.data, event: msg.event, id: msg.id, cancelled: false}
};
- if (!api.triggerHtmxEvent(element, 'htmx:before:sse:message', detail) || detail.message.cancelled) continue;
+ if (!api.triggerHtmxEvent(element, 'htmx:sse:before:message', detail) || detail.message.cancelled) continue;
if (msg.retry != null) config.reconnectDelay = msg.retry;
if (detail.message.event) {
htmx.trigger(element, detail.message.event, {data: detail.message.data, id: detail.message.id});
delete detail.message.cancelled;
- api.triggerHtmxEvent(element, 'htmx:after:sse:message', detail);
+ api.triggerHtmxEvent(element, 'htmx:sse:after:message', detail);
// hx-sse:close="eventname" — close connection on matching event
let closeEvent = api.attributeValue(element, 'hx-sse:close');
@@ -277,7 +277,7 @@
if (!ctx.swap.includes('swapEmpty')) ctx.swap += ' swapEmpty:false';
await htmx.swap(ctx);
delete detail.message.cancelled;
- api.triggerHtmxEvent(element, 'htmx:after:sse:message', detail);
+ api.triggerHtmxEvent(element, 'htmx:sse:after:message', detail);
}
} catch (e) {
if (!connection.abortController?.signal?.aborted) {
@@ -334,17 +334,15 @@
// ========================================
function checkLegacyAttributes(element) {
- if (element.hasAttribute('sse-connect')) {
- console.warn('htmx: [hx-sse] legacy attribute sse-connect is deprecated; use hx-sse:connect instead');
+ for (let attribute of ['sse-connect', 'sse-close', 'sse-swap']) {
+ if (!element.hasAttribute(attribute) || warnedLegacyAttributes.has(attribute)) continue;
- let url = element.getAttribute('sse-connect');
- let attr = (htmx.config.prefix || 'hx-') + 'sse' + (htmx.config.metaCharacter || ':') + 'connect';
- if (!element.hasAttribute(attr)) {
- element.setAttribute(attr, url);
+ if (attribute === 'sse-swap') {
+ console.warn('htmx: [hx-sse] sse-swap is removed in htmx 4. Unnamed SSE messages are swapped automatically. Named events are dispatched as DOM events.');
+ } else {
+ console.warn(`htmx: [hx-sse] legacy attribute ${attribute} is deprecated; use hx-sse:${attribute.slice(4)} instead`);
}
- }
- if (element.hasAttribute('sse-swap')) {
- console.warn('htmx: [hx-sse] sse-swap is removed in htmx 4. Unnamed SSE messages are swapped automatically. Named events are dispatched as DOM events.');
+ warnedLegacyAttributes.add(attribute);
}
}
@@ -377,16 +375,24 @@
},
htmx_after_process: (element) => {
- checkLegacyAttributes(element);
- processElement(element);
let mc = htmx.config.metaCharacter || ':';
+ let processSSEElement = (element) => {
+ checkLegacyAttributes(element);
+ for (let name of ['connect', 'close']) {
+ let legacyAttr = `sse-${name}`;
+ if (!element.hasAttribute(legacyAttr)) continue;
+
+ let attr = (htmx.config.prefix || 'hx-') + 'sse' + mc + name;
+ if (!element.hasAttribute(attr)) element.setAttribute(attr, element.getAttribute(legacyAttr));
+ }
+ processElement(element);
+ };
+
+ processSSEElement(element);
let sseAttr = CSS.escape('hx-sse' + mc + 'connect');
let sseSelector = `[${sseAttr}]`;
if (htmx.config.prefix) sseSelector += `,[${CSS.escape(htmx.config.prefix + 'sse' + mc + 'connect')}]`;
- element.querySelectorAll(`${sseSelector},[sse-connect]`).forEach((el) => {
- checkLegacyAttributes(el);
- processElement(el);
- });
+ element.querySelectorAll(`${sseSelector},[sse-connect],[sse-close],[sse-swap]`).forEach(processSSEElement);
},
htmx_before_cleanup: (element) => {
diff --git a/src/scripts/upgrade-check.py b/src/scripts/upgrade-check.py
index a91f939c8..671116b2c 100755
--- a/src/scripts/upgrade-check.py
+++ b/src/scripts/upgrade-check.py
@@ -82,10 +82,10 @@
}
SSE_EVENT_RENAMES = {
- "htmx:sseOpen": "htmx:after:sse:connection",
+ "htmx:sseOpen": "htmx:sse:after:connection",
"htmx:sseError": "htmx:sse:error",
- "htmx:sseBeforeMessage": "htmx:before:sse:message",
- "htmx:sseMessage": "htmx:after:sse:message",
+ "htmx:sseBeforeMessage": "htmx:sse:before:message",
+ "htmx:sseMessage": "htmx:sse:after:message",
"htmx:sseClose": "htmx:sse:close",
}
@@ -102,6 +102,7 @@
# Extension attribute renames
EXT_ATTR_RENAMES = {
"sse-connect": "rename to hx-sse:connect",
+ "sse-close": "rename to hx-sse:close",
"sse-swap": "removed — SSE now integrates with standard htmx request pipeline",
"ws-connect": "rename to hx-ws:connect",
"ws-send": "rename to hx-ws:send",
diff --git a/test/tests/ext/hx-sse.js b/test/tests/ext/hx-sse.js
index febceac9e..cfd482891 100644
--- a/test/tests/ext/hx-sse.js
+++ b/test/tests/ext/hx-sse.js
@@ -52,11 +52,11 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('message 1');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'message 1');
stream.send('message 2');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'message 2');
stream.close();
@@ -68,7 +68,7 @@ describe('hx-sse SSE extension', function() {
let reconnectAttempts = 0;
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) {
reconnectAttempts++;
}
@@ -78,16 +78,16 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('first');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'first');
stream.close();
- await waitForEvent('htmx:after:sse:connection');
+ await waitForEvent('htmx:sse:after:connection');
assert.equal(reconnectAttempts, 1, 'Should attempt to reconnect');
stream.send('second');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'second');
stream.close();
@@ -99,7 +99,7 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let lastEventIdSent = null;
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) {
lastEventIdSent = e.detail.connection.lastEventId;
}
@@ -109,13 +109,13 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('first message', null, 'msg-123');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'first message');
await new Promise(r => setTimeout(r, 50));
stream.close();
- await waitForEvent('htmx:after:sse:connection', 5000);
+ await waitForEvent('htmx:sse:after:connection', 5000);
assert.equal(lastEventIdSent, 'msg-123', 'Should send last event ID on reconnect');
@@ -130,7 +130,7 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let ids = [];
- onDoc('htmx:before:sse:message', (e) => {
+ onDoc('htmx:sse:before:message', (e) => {
ids.push(e.detail.message.id);
});
@@ -138,9 +138,9 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.sendRaw('id: 42\ndata: first\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
stream.sendRaw('data: second\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.deepEqual(ids, ['42', '42']);
stream.close();
@@ -151,7 +151,7 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let lastMessageId = null;
- onDoc('htmx:before:sse:message', (e) => {
+ onDoc('htmx:sse:before:message', (e) => {
lastMessageId = e.detail.message.id;
});
@@ -159,9 +159,9 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.sendRaw('id: 42\ndata: first\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
stream.sendRaw('id:\ndata: reset\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.equal(lastMessageId, '');
assert.equal(find('button')._htmx.sse.lastEventId, '');
@@ -177,17 +177,17 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.sendRaw('id: 42\ndata: first\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
stream.close();
- await waitForEvent('htmx:after:sse:connection', 1000);
+ await waitForEvent('htmx:sse:after:connection', 1000);
let reconnectCall = fetchMock.getLastCall();
assert.equal(reconnectCall.request.headers['Last-Event-ID'], '42');
stream.sendRaw('id:\ndata: reset\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
stream.close();
- await waitForEvent('htmx:after:sse:connection', 1000);
+ await waitForEvent('htmx:sse:after:connection', 1000);
reconnectCall = fetchMock.getLastCall();
assert.isFalse(
@@ -203,8 +203,8 @@ describe('hx-sse SSE extension', function() {
let beforeMessages = 0;
let afterMessages = 0;
- onDoc('htmx:before:sse:message', () => { beforeMessages++; });
- onDoc('htmx:after:sse:message', () => { afterMessages++; });
+ onDoc('htmx:sse:before:message', () => { beforeMessages++; });
+ onDoc('htmx:sse:after:message', () => { afterMessages++; });
find('button').click();
await htmx.timeout(1);
@@ -218,7 +218,7 @@ describe('hx-sse SSE extension', function() {
assertTextContentIs('button', 'Connect');
stream.close();
- await waitForEvent('htmx:after:sse:connection', 1000);
+ await waitForEvent('htmx:sse:after:connection', 1000);
assert.equal(fetchMock.getLastCall().request.headers['Last-Event-ID'], 'cursor-7');
});
@@ -232,12 +232,12 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.sendRaw('id: 42\ndata: first\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
let beforeMessages = 0;
let afterMessages = 0;
- onDoc('htmx:before:sse:message', () => { beforeMessages++; });
- onDoc('htmx:after:sse:message', () => { afterMessages++; });
+ onDoc('htmx:sse:before:message', () => { beforeMessages++; });
+ onDoc('htmx:sse:after:message', () => { afterMessages++; });
stream.sendRaw('id:\n\n');
await htmx.timeout(20);
@@ -247,7 +247,7 @@ describe('hx-sse SSE extension', function() {
assert.equal(afterMessages, 0);
stream.close();
- await waitForEvent('htmx:after:sse:connection', 1000);
+ await waitForEvent('htmx:sse:after:connection', 1000);
assert.isFalse(
Object.keys(fetchMock.getLastCall().request.headers).some(name => name.toLowerCase() === 'last-event-id'),
@@ -260,7 +260,7 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let ids = [];
- onDoc('htmx:before:sse:message', (e) => {
+ onDoc('htmx:sse:before:message', (e) => {
ids.push(e.detail.message.id);
});
@@ -268,9 +268,9 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.sendRaw('id: 42\ndata: first\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
stream.sendRaw('id: bad\0id\ndata: second\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.deepEqual(ids, ['42', '42']);
assert.equal(find('button')._htmx.sse.lastEventId, '42');
@@ -285,20 +285,20 @@ describe('hx-sse SSE extension', function() {
let beforeMessageFired = false;
let afterMessageFired = false;
- onDoc('htmx:before:sse:connection', () => { beforeConnectFired = true; });
- onDoc('htmx:before:sse:message', () => { beforeMessageFired = true; });
- onDoc('htmx:after:sse:message', () => { afterMessageFired = true; });
+ onDoc('htmx:sse:before:connection', () => { beforeConnectFired = true; });
+ onDoc('htmx:sse:before:message', () => { beforeMessageFired = true; });
+ onDoc('htmx:sse:after:message', () => { afterMessageFired = true; });
find('button').click();
await htmx.timeout(1);
- assert.isTrue(beforeConnectFired, 'before:sse:connect should fire');
+ assert.isTrue(beforeConnectFired, 'sse:before:connection should fire');
stream.send('test');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
- assert.isTrue(beforeMessageFired, 'before:sse:message should fire');
- assert.isTrue(afterMessageFired, 'after:sse:message should fire');
+ assert.isTrue(beforeMessageFired, 'sse:before:message should fire');
+ assert.isTrue(afterMessageFired, 'sse:after:message should fire');
assertTextContentIs('button', 'test');
stream.close();
@@ -312,7 +312,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('message 1');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'message 1');
let closeFired = false;
@@ -328,7 +328,7 @@ describe('hx-sse SSE extension', function() {
// Verify no further messages are processed
let messageAfterRemoval = false;
- onDoc('htmx:after:sse:message', () => { messageAfterRemoval = true; });
+ onDoc('htmx:sse:after:message', () => { messageAfterRemoval = true; });
stream.send('message 2');
await htmx.timeout(50);
@@ -354,7 +354,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('SSE response');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#sse', 'SSE response');
stream.close();
@@ -369,7 +369,7 @@ describe('hx-sse SSE extension', function() {
// Send HTML with id field (no event field, so it swaps)
stream.send('
message with id
', null, '42');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#msg1', 'message with id');
stream.close();
@@ -395,12 +395,12 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let reconnectAttempts = 0;
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) reconnectAttempts++;
});
find('button').click();
- await waitForEvent('htmx:before:sse:connection');
+ await waitForEvent('htmx:sse:before:connection');
await new Promise(r => setTimeout(r, 150));
@@ -428,12 +428,12 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let reconnectAttempts = [];
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) reconnectAttempts.push(e.detail.connection.attempt);
});
find('button').click();
- await waitForEvent('htmx:before:sse:connection');
+ await waitForEvent('htmx:sse:before:connection');
await new Promise(r => setTimeout(r, 350));
@@ -461,12 +461,12 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let reconnectAttempts = [];
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) reconnectAttempts.push(e.detail.connection.attempt);
});
find('button').click();
- await waitForEvent('htmx:before:sse:connection');
+ await waitForEvent('htmx:sse:before:connection');
// Wait for multiple reconnects
await new Promise(r => setTimeout(r, 800));
@@ -495,12 +495,12 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let reconnectAttempts = [];
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) reconnectAttempts.push(e.detail.connection.attempt);
});
find('button').click();
- await waitForEvent('htmx:before:sse:connection');
+ await waitForEvent('htmx:sse:before:connection');
await new Promise(r => setTimeout(r, 450));
@@ -513,14 +513,14 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let connectionAttempts = 0;
- onDoc('htmx:before:sse:connection', () => { connectionAttempts++; });
+ onDoc('htmx:sse:before:connection', () => { connectionAttempts++; });
find('button').click();
- await waitForEvent('htmx:after:sse:connection');
+ await waitForEvent('htmx:sse:after:connection');
assert.equal(connectionAttempts, 1, 'Initial connection');
stream.send('hello');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'hello');
// Simulate tab going hidden
@@ -536,11 +536,11 @@ describe('hx-sse SSE extension', function() {
Object.defineProperty(document, 'hidden', {value: false, configurable: true});
document.dispatchEvent(new Event('visibilitychange'));
- await waitForEvent('htmx:after:sse:connection', 3000);
+ await waitForEvent('htmx:sse:after:connection', 3000);
assert.equal(connectionAttempts, 2, 'Should reconnect when tab becomes visible');
stream.send('world');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'world');
stream.close();
@@ -552,17 +552,17 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let attempts = [];
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) {
attempts.push(e.detail.connection.attempt);
}
});
find('button').click();
- await waitForEvent('htmx:after:sse:connection');
+ await waitForEvent('htmx:sse:after:connection');
stream.send('msg1');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
// First pause/resume cycle
Object.defineProperty(document, 'hidden', {value: true, configurable: true});
@@ -570,10 +570,10 @@ describe('hx-sse SSE extension', function() {
await new Promise(r => setTimeout(r, 50));
Object.defineProperty(document, 'hidden', {value: false, configurable: true});
document.dispatchEvent(new Event('visibilitychange'));
- await waitForEvent('htmx:after:sse:connection', 3000);
+ await waitForEvent('htmx:sse:after:connection', 3000);
stream.send('msg2');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
// Second pause/resume cycle
Object.defineProperty(document, 'hidden', {value: true, configurable: true});
@@ -581,7 +581,7 @@ describe('hx-sse SSE extension', function() {
await new Promise(r => setTimeout(r, 50));
Object.defineProperty(document, 'hidden', {value: false, configurable: true});
document.dispatchEvent(new Event('visibilitychange'));
- await waitForEvent('htmx:after:sse:connection', 3000);
+ await waitForEvent('htmx:sse:after:connection', 3000);
// Both attempts should be 1 (not escalating across pause cycles)
assert.equal(attempts.length, 2, 'Should have 2 reconnections');
@@ -652,12 +652,12 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('50%', 'progress');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.equal(find('button').getAttribute('data-progress'), '50%', 'hx-on should handle custom event');
stream.send('100%', 'progress');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.equal(find('button').getAttribute('data-progress'), '100%', 'hx-on should handle multiple custom events');
@@ -679,13 +679,13 @@ describe('hx-sse SSE extension', function() {
// Send a custom event (should NOT swap)
stream.send('processing', 'status');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.isTrue(statusEventFired, 'Custom event should fire');
assertTextContentIs('button', 'Connect', 'Content should NOT be swapped for custom events');
// Send HTML content (no event field, so it swaps normally)
stream.send('Result
');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'Result');
stream.close();
@@ -708,7 +708,7 @@ describe('hx-sse SSE extension', function() {
// Send event - should trigger event but NOT swap
stream.send('notification data', 'notify');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.isTrue(notifyFired, 'Custom event should fire');
assert.equal(notifyData, 'notification data', 'Event should have data');
@@ -725,11 +725,11 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send(' \ttext-with-two-leading-spaces');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#ws-btn', ' \ttext-with-two-leading-spaces');
stream.send('NoTrim');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#ws-btn', 'NoTrim');
stream.close();
@@ -742,7 +742,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('connected!');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('div', 'connected!');
stream.close();
@@ -755,16 +755,16 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('first');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('div', 'first');
let reconnectFired = false;
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) reconnectFired = true;
});
stream.close();
- await waitForEvent('htmx:after:sse:connection');
+ await waitForEvent('htmx:sse:after:connection');
assert.isTrue(reconnectFired, 'connect should reconnect by default');
});
@@ -783,7 +783,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(150);
stream.send('delayed!');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('div', 'delayed!');
stream.close();
@@ -801,7 +801,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('clicked!');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'clicked!');
stream.close();
@@ -838,7 +838,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('targeted!');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#output', 'targeted!');
stream.close();
@@ -851,7 +851,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('Ignored
Selected
');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('div', 'Selected');
assert.isUndefined(find('div > p:not(.message)'));
@@ -866,7 +866,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('Message
Online
');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#stream', 'Message');
assertTextContentIs('#status', 'Online');
@@ -881,9 +881,9 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('1');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
stream.send('2');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('div', 'start12');
@@ -898,7 +898,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('payload', 'update');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
await htmx.timeout(50);
// Event dispatches on parent, bubbles UP — child doesn't see it
@@ -915,7 +915,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('payload', 'update');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
await forRequest();
assertTextContentIs('#child', 'Updated!');
@@ -1001,11 +1001,11 @@ describe('hx-sse SSE extension', function() {
assert.equal(errorStatus, 500, 'Error detail should include status code');
});
- it('htmx:before:sse:message cancellation prevents swap', async function() {
+ it('htmx:sse:before:message cancellation prevents swap', async function() {
const stream = mockStreamResponse('/cancel-msg');
createProcessedHTML('');
- onDoc('htmx:before:sse:message', (e) => {
+ onDoc('htmx:sse:before:message', (e) => {
if (e.detail.message.data === 'skip me') {
e.detail.message.cancelled = true;
}
@@ -1019,17 +1019,17 @@ describe('hx-sse SSE extension', function() {
assertTextContentIs('button', 'Original', 'Cancelled message should not swap');
stream.send('keep me');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'keep me', 'Non-cancelled message should swap');
stream.close();
});
- it('htmx:before:sse:message cancellation via preventDefault', async function() {
+ it('htmx:sse:before:message cancellation via preventDefault', async function() {
const stream = mockStreamResponse('/cancel-prevent');
createProcessedHTML('');
- onDoc('htmx:before:sse:message', (e) => {
+ onDoc('htmx:sse:before:message', (e) => {
if (e.detail.message.data === 'blocked') {
e.preventDefault();
}
@@ -1043,7 +1043,7 @@ describe('hx-sse SSE extension', function() {
assertTextContentIs('button', 'Original', 'preventDefault should skip message');
stream.send('allowed');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('button', 'allowed');
stream.close();
@@ -1056,7 +1056,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('message 1');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('div', 'message 1');
let closeFired = false;
@@ -1075,7 +1075,7 @@ describe('hx-sse SSE extension', function() {
// Verify no reconnection occurs
let reconnectFired = false;
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) reconnectFired = true;
});
@@ -1090,21 +1090,21 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('message 1');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
let closeFired = false;
let beforeMessageFired = false;
let afterMessageFired = false;
onDoc('htmx:sse:close', (e) => { closeFired = true; });
- onDoc('htmx:before:sse:message', (e) => { if (e.detail.message.event === 'done') beforeMessageFired = true; });
- onDoc('htmx:after:sse:message', (e) => { if (e.detail.message.event === 'done') afterMessageFired = true; });
+ onDoc('htmx:sse:before:message', (e) => { if (e.detail.message.event === 'done') beforeMessageFired = true; });
+ onDoc('htmx:sse:after:message', (e) => { if (e.detail.message.event === 'done') afterMessageFired = true; });
stream.sendRaw('event: done\n\n');
await waitForEvent('htmx:sse:close');
assert.isTrue(closeFired);
- assert.isTrue(beforeMessageFired, 'htmx:before:sse:message should fire for data-less named event');
- assert.isTrue(afterMessageFired, 'htmx:after:sse:message should fire for data-less named event');
+ assert.isTrue(beforeMessageFired, 'htmx:sse:before:message should fire for data-less named event');
+ assert.isTrue(afterMessageFired, 'htmx:sse:after:message should fire for data-less named event');
});
it('hx-sse:close does not close on non-matching events', async function() {
@@ -1118,13 +1118,13 @@ describe('hx-sse SSE extension', function() {
// Send a different named event — should NOT close
stream.send('status update', 'status');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.isFalse(closeFired, 'Should NOT close on non-matching event');
// Regular messages should still swap
stream.send('content');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('div', 'content');
stream.close();
@@ -1194,13 +1194,13 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
find('button').click();
- await waitForEvent('htmx:after:sse:connection');
+ await waitForEvent('htmx:sse:after:connection');
// Server sends first 2 notifications
controllers[0].enqueue(enc.encode('id: n-1\ndata: Notification 1
\n\n'));
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
controllers[0].enqueue(enc.encode('id: n-2\ndata: Notification 2
\n\n'));
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assert.include(find('#output').innerHTML, 'Notification 1');
assert.include(find('#output').innerHTML, 'Notification 2');
@@ -1213,14 +1213,14 @@ describe('hx-sse SSE extension', function() {
// 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);
+ await waitForEvent('htmx:sse:after: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);
+ await waitForEvent('htmx:sse:after:message', 1000);
assert.include(find('#output').innerHTML, 'Notification 3', 'Missed notification should be replayed on reconnect');
controllers[controllers.length - 1].close();
@@ -1232,7 +1232,7 @@ describe('hx-sse SSE extension', function() {
createProcessedHTML('');
let reconnected = false;
- onDoc('htmx:before:sse:connection', (e) => {
+ onDoc('htmx:sse:before:connection', (e) => {
if (e.detail.connection.attempt > 0) reconnected = true;
});
@@ -1241,10 +1241,10 @@ describe('hx-sse SSE extension', function() {
// Send a message with retry field set to 200ms
stream.sendRaw('retry: 200\ndata: hello\n\n');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
stream.close();
- await waitForEvent('htmx:after:sse:connection', 5000);
+ await waitForEvent('htmx:sse:after:connection', 5000);
// Server retry field should be respected (reconnection happens)
assert.isTrue(reconnected, 'Should reconnect using server-provided retry delay');
@@ -1286,7 +1286,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('Updated
');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#target', 'Original');
assertTextContentIs('#oob', 'Updated');
@@ -1301,7 +1301,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('Updated
');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#target', '');
assertTextContentIs('#oob', 'Updated');
@@ -1316,7 +1316,7 @@ describe('hx-sse SSE extension', function() {
await htmx.timeout(1);
stream.send('New Content
Updated
');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('#oob', 'Updated');
find('#target').innerText.trim().should.equal('New Content');
@@ -1324,24 +1324,57 @@ describe('hx-sse SSE extension', function() {
stream.close();
});
- it('supports legacy sse-connect attribute with deprecation warning', async function() {
- let warnCalled = false;
+ it('supports legacy sse-connect attribute and warns once', async function() {
+ let warnings = [];
let originalWarn = console.warn;
- console.warn = () => { warnCalled = true; };
+ console.warn = warning => warnings.push(warning);
const stream = mockStreamResponse('/legacy-test');
createProcessedHTML('Waiting
');
-
+ htmx.process(find('div'));
await htmx.timeout(1);
console.warn = originalWarn;
stream.send('legacy works');
- await waitForEvent('htmx:after:sse:message');
+ await waitForEvent('htmx:sse:after:message');
assertTextContentIs('div', 'legacy works');
-
- assert.isTrue(warnCalled, 'Should emit deprecation warning');
+ assert.equal(warnings.filter(warning => warning.includes('sse-connect')).length, 1);
stream.close();
});
+
+ it('supports legacy sse-close attribute and warns once', async function() {
+ let warnings = [];
+ let originalWarn = console.warn;
+ console.warn = warning => warnings.push(warning);
+
+ const stream = mockStreamResponse('/legacy-close');
+ createProcessedHTML('Waiting
');
+ htmx.process(find('div'));
+ await htmx.timeout(1);
+
+ console.warn = originalWarn;
+
+ let closeReason;
+ onDoc('htmx:sse:close', event => closeReason = event.detail.reason);
+ stream.send('Complete', 'done');
+ await waitForEvent('htmx:sse:close');
+
+ assert.equal(closeReason, 'message');
+ assert.equal(warnings.filter(warning => warning.includes('sse-close')).length, 1);
+ });
+
+ it('warns once for removed sse-swap attribute', function() {
+ let warnings = [];
+ let originalWarn = console.warn;
+ console.warn = warning => warnings.push(warning);
+
+ createProcessedHTML('');
+ htmx.process(find('div'));
+
+ console.warn = originalWarn;
+
+ assert.equal(warnings.filter(warning => warning.includes('sse-swap')).length, 1);
+ });
});
diff --git a/www/src/content/extensions/02-hx-sse.md b/www/src/content/extensions/02-hx-sse.md
index 5a552d24c..6b4ded349 100644
--- a/www/src/content/extensions/02-hx-sse.md
+++ b/www/src/content/extensions/02-hx-sse.md
@@ -447,12 +447,12 @@ event.detail.message = {
}
```
-### `htmx:before:sse:connection`
+### `htmx:sse:before:connection`
Fires before htmx starts the initial stream or schedules a reconnect.
```js
-document.addEventListener('htmx:before:sse:connection', event => {
+document.addEventListener('htmx:sse:before:connection', event => {
if (event.detail.connection.attempt > 5) event.preventDefault()
})
```
@@ -462,24 +462,24 @@ The initial HTTP response has already arrived. Cancel either way:
- call `event.preventDefault()`
- set `event.detail.connection.cancelled` to `true`
-### `htmx:after:sse:connection`
+### `htmx:sse:after:connection`
Fires after the initial response or a reconnect is ready to stream.
```js
-document.addEventListener('htmx:after:sse:connection', event => {
+document.addEventListener('htmx:sse:after:connection', event => {
console.log('Connected:', event.detail.connection.url)
})
```
`connection.status` contains the HTTP status.
-### `htmx:before:sse:message`
+### `htmx:sse:before:message`
Fires before processing an SSE message.
```js
-document.addEventListener('htmx:before:sse:message', event => {
+document.addEventListener('htmx:sse:before:message', event => {
let message = event.detail.message
if (message.event === 'heartbeat') event.preventDefault()
else message.data = sanitize(message.data)
@@ -488,12 +488,12 @@ document.addEventListener('htmx:before:sse:message', event => {
Changing `message.data` changes the swap or named event data. Changing `message.event` changes whether the message swaps or dispatches a DOM event.
-### `htmx:after:sse:message`
+### `htmx:sse:after:message`
Fires after htmx swaps or dispatches an SSE message.
```js
-document.addEventListener('htmx:after:sse:message', event => {
+document.addEventListener('htmx:sse:after:message', event => {
console.log('Received:', event.detail.message.data)
})
```
@@ -662,7 +662,7 @@ These attributes changed:
|----------|----------|---------------|
| [`sse-connect`](https://htmx.org/extensions/sse/#connecting-to-an-sse-server) | [`hx-sse:connect`](#hx-sseconnect) | Works with a warning |
| [`sse-swap`](https://htmx.org/extensions/sse/#receiving-named-events) | Unnamed messages swap automatically | Removed; warns |
-| [`sse-close`](https://htmx.org/extensions/sse/) | [`hx-sse:close`](#hx-sseclose) | Removed |
+| [`sse-close`](https://htmx.org/extensions/sse/) | [`hx-sse:close`](#hx-sseclose) | Works with a warning |
#### Events
@@ -670,10 +670,21 @@ These events changed:
| htmx 2.x | htmx 4.x |
|----------|----------|
-| [`htmx:sseOpen`](https://htmx.org/extensions/sse/#htmxsseopen) | [`htmx:after:sse:connection`](#htmxaftersseconnection) |
+| [`htmx:sseOpen`](https://htmx.org/extensions/sse/#htmxsseopen) | [`htmx:sse:after:connection`](#htmxsseafterconnection) |
| [`htmx:sseError`](https://htmx.org/extensions/sse/#htmxsseerror) | [`htmx:sse:error`](#htmxsseerror) |
-| [`htmx:sseBeforeMessage`](https://htmx.org/extensions/sse/#htmxssebeforemessage) | [`htmx:before:sse:message`](#htmxbeforessemessage) |
-| [`htmx:sseMessage`](https://htmx.org/extensions/sse/#htmxssemessage) | [`htmx:after:sse:message`](#htmxafterssemessage) |
+| [`htmx:sseBeforeMessage`](https://htmx.org/extensions/sse/#htmxssebeforemessage) | [`htmx:sse:before:message`](#htmxssebeforemessage) |
+| [`htmx:sseMessage`](https://htmx.org/extensions/sse/#htmxssemessage) | [`htmx:sse:after:message`](#htmxsseaftermessage) |
| [`htmx:sseClose`](https://htmx.org/extensions/sse/#htmxsseclose) | [`htmx:sse:close`](#htmxsseclose) |
htmx 4 uses [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) and [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) instead of [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource). SSE responses can therefore use any htmx HTTP method, request values, and headers.
+
+### Beta to RC1
+
+RC1 namespaces SSE lifecycle events:
+
+| Beta | RC1 |
+|------|-----|
+| `htmx:before:sse:connection` | [`htmx:sse:before:connection`](#htmxssebeforeconnection) |
+| `htmx:after:sse:connection` | [`htmx:sse:after:connection`](#htmxsseafterconnection) |
+| `htmx:before:sse:message` | [`htmx:sse:before:message`](#htmxssebeforemessage) |
+| `htmx:after:sse:message` | [`htmx:sse:after:message`](#htmxsseaftermessage) |