htmx/test/tests/end2end/events.js
Christian Tanul 923b022d9b
Extract SSE from core into standalone extension (#3666)
Move SSE out of htmx core into a standalone extension (hx-sse.js).

The new extension uses Fetch + ReadableStream instead of EventSource,
so it works with POST, custom headers, and cookies. Two modes:
- One-off streams: any hx-get/hx-post returning text/event-stream
- Persistent connections: hx-sse:connect with auto-reconnect

SSE events now follow the extension pattern:
- htmx:before:sse:connection / htmx:after:sse:connection
- htmx:before:sse:message / htmx:after:sse:message
- htmx:sse:error / htmx:sse:close

New features:
- hx-sse:close="eventname" to close connection on a named event
- htmx:sse:close event with reason (message, removed, ended, etc.)
- htmx:before:response core event for extension response interception
- Server retry: field updates reconnect delay per SSE spec
- Non-2xx reconnect responses fire htmx:sse:error

SSE parser improvements (spec compliance):
- Handle CRLF, CR, and LF line endings
- Strip leading BOM (U+FEFF)
- Dispatch empty data fields
- Ignore id fields containing NULL
- Process field-only lines (no colon) per spec

Docs:
- Document htmx:before:response in events, reference, building guide
- Add htmx 2.x → 4.x SSE migration guide
- Document hx-sse:close and htmx:sse:close
- Add hx-sse:connect, hx-sse:close to JetBrains web-types
- Fix stale Accept header reference in migration guide

Remove htmx.config.sse from core config tables (extension-only now).
Remove stale SSE event references from core docs.
2026-02-21 14:43:06 -07:00

116 lines
4.1 KiB
JavaScript

describe('htmx events', function() {
beforeEach(function() {
setupTest();
});
afterEach(function() {
cleanupTest();
});
it('htmx:before:request fires on sourceElement', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-get="/test" hx-swap="none"></div>')
let firedOnSource = false
div.addEventListener('htmx:before:request', () => firedOnSource = true)
div.click();
await forRequest()
assert.isTrue(firedOnSource)
})
it('htmx:after:request fires on sourceElement', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-get="/test" hx-swap="none"></div>')
let firedOnSource = false
div.addEventListener('htmx:after:request', () => firedOnSource = true)
div.click()
await forRequest()
assert.isTrue(firedOnSource)
})
it('htmx:before:swap fires on sourceElement', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-get="/test"></div>')
let firedOnSource = false
div.addEventListener('htmx:before:swap', () => firedOnSource = true)
div.click()
await forRequest()
assert.isTrue(firedOnSource)
})
it('htmx:after:swap fires on sourceElement', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-get="/test"></div>')
let firedOnSource = false
div.addEventListener('htmx:after:swap', () => firedOnSource = true)
div.click()
await forRequest()
assert.isTrue(firedOnSource)
})
it('htmx:after:swap does not fire on element removed from DOM by swap', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-get="/test" hx-swap="outerHTML"></div>')
let firedOnSource = false
div.addEventListener('htmx:after:swap', () => firedOnSource = true)
div.click()
await forRequest()
assert.isFalse(firedOnSource)
})
it('htmx:after:swap triggers on document when element is removed from DOM by swap', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-get="/test" hx-swap="outerHTML"></div>')
let firedOnDocument = false
document.addEventListener('htmx:after:swap', (e) => {
if (e.target === document) {
firedOnDocument = true
}
}, {once: true})
div.click()
await forRequest()
assert.isTrue(firedOnDocument)
})
it('events bubble from sourceElement to document', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-get="/test" hx-swap="none"></div>')
let bubbledToDocument = false
document.addEventListener('htmx:before:request', (e) => {
if (e.target === div) {
bubbledToDocument = true
}
}, {once: true})
div.click()
await forRequest()
assert.isTrue(bubbledToDocument)
})
it('htmx:before:response fires with ctx after fetch', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-get="/test"></div>')
let firedCtx = null
div.addEventListener('htmx:before:response', (e) => {
firedCtx = e.detail.ctx
})
div.click()
await forRequest()
assert.isNotNull(firedCtx, 'should fire with ctx')
assert.equal(firedCtx.response.status, 200)
})
it('htmx:before:response cancellation prevents swap', async function () {
mockResponse('GET', '/test', 'Should not appear')
let div = createProcessedHTML('<div hx-get="/test">Original</div>')
div.addEventListener('htmx:before:response', (e) => {
e.preventDefault()
})
div.click()
await forRequest()
assertTextContentIs('div', 'Original')
})
// TODO - verify shape of details in these events
})