mirror of
https://github.com/bigskysoftware/htmx.git
synced 2026-03-13 18:08:10 +00:00
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.
116 lines
4.1 KiB
JavaScript
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
|
|
})
|