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) |