Skip to content

Commit e9dab00

Browse files
committed
Merge remote-tracking branch 'origin/four-dev' into four-dev
2 parents 112f73d + 236babc commit e9dab00

16 files changed

Lines changed: 442 additions & 79 deletions

File tree

src/htmx.js

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -343,34 +343,23 @@ var htmx = (() => {
343343
}
344344

345345
__determineMethodAndAction(elt, evt) {
346-
if (this.__isBoosted(elt)) {
347-
return this.__boostedMethodAndAction(elt, evt)
348-
} else {
349-
let method = this.__attributeValue(elt, "hx-method") || "GET"
350-
let action = this.__attributeValue(elt, "hx-action");
351-
if (!action) {
352-
for (let verb of this.#verbs) {
353-
let verbAction = this.__attributeValue(elt, "hx-" + verb);
354-
if (verbAction != null) {
355-
action = verbAction;
356-
method = verb;
357-
break;
358-
}
346+
let method = this.__attributeValue(elt, "hx-method");
347+
let action = this.__attributeValue(elt, "hx-action");
348+
if (!action) {
349+
for (let verb of this.#verbs) {
350+
let verbAction = this.__attributeValue(elt, "hx-" + verb);
351+
if (verbAction != null) {
352+
action = verbAction;
353+
method = verb;
354+
break;
359355
}
360356
}
361-
method = method.toUpperCase()
362-
return {action, method}
363357
}
364-
}
365-
366-
__boostedMethodAndAction(elt, evt) {
367-
if (elt.matches("a")) {
368-
return {action: elt.getAttribute("href"), method: "GET"}
369-
} else {
370-
let action = evt.submitter?.getAttribute?.("formAction") || elt.getAttribute("action");
371-
let method = evt.submitter?.getAttribute?.("formMethod") || elt.getAttribute("method") || "GET";
372-
return {action, method: method.toUpperCase()}
358+
if (this.__isBoosted(elt)) {
359+
action ||= evt.submitter?.getAttribute?.("formAction") || elt.getAttribute(elt.matches("a") ? "href" : "action");
373360
}
361+
method ||= evt.submitter?.getAttribute?.("formmethod") || elt.getAttribute("method") || "GET";
362+
return {action, method: method.toUpperCase()};
374363
}
375364

376365
__htmxProp(elt) {
@@ -2060,6 +2049,7 @@ var htmx = (() => {
20602049
let placeholder = document.createElement(newChild.tagName);
20612050
oldParent.insertBefore(placeholder, insertionPoint);
20622051
this.__morphNode(placeholder, newChild, ctx);
2052+
this.process(placeholder);
20632053
insertionPoint = placeholder.nextSibling;
20642054
} else {
20652055
oldParent.insertBefore(newChild, insertionPoint);
@@ -2127,10 +2117,6 @@ var htmx = (() => {
21272117
}
21282118
// Script tags must be identical to match - never patch a script with different content
21292119
if (oldNode.tagName === 'SCRIPT' && !oldNode.isEqualNode(newNode)) return false;
2130-
// If both have Alpine reactive ID bindings, ignore ID mismatch
2131-
if (oldNode._x_bindings?.id && newNode.matches?.('[\\:id], [x-bind\\:id]')) {
2132-
return true;
2133-
}
21342120
return !oldNode.id || oldNode.id === newNode.id;
21352121
}
21362122

test/lib/helpers.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ function setupTest(test) {
5252
function cleanupTest() {
5353
let pg = playground()
5454
if (pg && !testDebugging) {
55+
htmx.__cleanup(pg)
5556
pg.innerHTML = ''
5657
}
5758
testDebugging = false;

test/test.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
<!-- ============================================ -->
132132
<!-- Attribute Tests -->
133133
<!-- ============================================ -->
134+
<script src="./tests/attributes/hx-action.js"></script>
134135
<script src="./tests/attributes/hx-boost.js"></script>
135136
<script src="./tests/attributes/hx-config.js"></script>
136137
<script src="./tests/attributes/hx-confirm.js"></script>

test/tests/attributes/hx-action.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
describe('hx-action attribute', function() {
2+
3+
beforeEach(() => {
4+
setupTest(this.currentTest)
5+
})
6+
7+
afterEach(() => {
8+
cleanupTest(this.currentTest)
9+
})
10+
11+
it('hx-action alone defaults to GET', async function() {
12+
mockResponse('GET', '/test', 'Clicked!')
13+
let btn = createProcessedHTML('<button hx-action="/test">Click Me!</button>')
14+
btn.click()
15+
await forRequest()
16+
fetchMock.calls[0].request.method.should.equal('GET')
17+
btn.innerHTML.should.equal('Clicked!')
18+
})
19+
20+
it('hx-action with hx-method uses specified method', async function() {
21+
mockResponse('POST', '/test', 'Posted!')
22+
let btn = createProcessedHTML('<button hx-action="/test" hx-method="post">Click Me!</button>')
23+
btn.click()
24+
await forRequest()
25+
fetchMock.calls[0].request.method.should.equal('POST')
26+
btn.innerHTML.should.equal('Posted!')
27+
})
28+
29+
it('hx-action on form picks up native method attribute', async function() {
30+
mockResponse('POST', '/test', 'Posted!')
31+
let form = createProcessedHTML('<form hx-action="/test" hx-swap="outerHTML" method="post"><button>Submit</button></form>')
32+
form.requestSubmit()
33+
await forRequest()
34+
fetchMock.calls[0].request.method.should.equal('POST')
35+
playground().innerHTML.should.equal('Posted!')
36+
})
37+
38+
it('hx-action on form with submitter formmethod uses formmethod', async function() {
39+
mockResponse('POST', '/test', 'Posted!')
40+
let form = createProcessedHTML('<form hx-action="/test" hx-swap="outerHTML" method="get"><button id="b1" formmethod="post">Submit</button></form>')
41+
find('#b1').click()
42+
await forRequest()
43+
fetchMock.calls[0].request.method.should.equal('POST')
44+
playground().innerHTML.should.equal('Posted!')
45+
})
46+
47+
it('hx-action with hx-method takes priority over native method attribute', async function() {
48+
mockResponse('PUT', '/test', 'Put!')
49+
let form = createProcessedHTML('<form hx-action="/test" hx-method="put" hx-swap="outerHTML" method="post"><button>Submit</button></form>')
50+
form.requestSubmit()
51+
await forRequest()
52+
fetchMock.calls[0].request.method.should.equal('PUT')
53+
playground().innerHTML.should.equal('Put!')
54+
})
55+
56+
})

test/tests/ext/hx-live.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,26 @@ describe('hx-live extension', function () {
14701470
window.__spreadDataLive.should.not.have.property('htmxPowered');
14711471
});
14721472

1473+
it('spreads cascading data values into hx-vals', async function() {
1474+
mockResponse('POST', '/cursor', 'OK');
1475+
playground().innerHTML = `
1476+
<section data-x="1" data-y="2">
1477+
<button data-y="3"
1478+
hx-post="/cursor"
1479+
hx-vals="js:{ ...data }">
1480+
Send cursor
1481+
</button>
1482+
</section>
1483+
`;
1484+
htmx.process(playground());
1485+
1486+
playground().querySelector('button').click();
1487+
await forRequest();
1488+
1489+
fetchMock.calls[0].request.body.get('x').should.equal('1');
1490+
fetchMock.calls[0].request.body.get('y').should.equal('3');
1491+
});
1492+
14731493
it('data proxy supports Object.keys/Object.values/Object.entries', async function() {
14741494
playground().innerHTML = `
14751495
<section data-x="1" data-y="2">
@@ -1522,6 +1542,44 @@ describe('hx-live extension', function () {
15221542
div.classList.contains('darkmode').should.equal(true);
15231543
});
15241544

1545+
it('updates and clears flash state from a targeted server event', async function() {
1546+
let originalSetTimeout = window.setTimeout;
1547+
window.setTimeout = (fn, delay, ...args) => originalSetTimeout(fn, delay === 3000 ? 20 : delay, ...args);
1548+
1549+
try {
1550+
playground().innerHTML = `
1551+
<button id="source"></button>
1552+
<div id="flash"
1553+
data-message=""
1554+
data-level=""
1555+
hx-on="flash -> data.message = message; data.level = level;
1556+
await timeout(3000);
1557+
data.message = ''"
1558+
:text="data.message"
1559+
:.success="data.level === 'success'"
1560+
:.error="data.level === 'error'"></div>
1561+
`;
1562+
htmx.process(playground());
1563+
let source = playground().querySelector('#source');
1564+
let flash = playground().querySelector('#flash');
1565+
1566+
htmx.__handleTriggerHeader('{"flash":{"target":"#flash", "level":"success", "message":"Saved"}}', source);
1567+
flash.dataset.message.should.equal('Saved');
1568+
flash.dataset.level.should.equal('success');
1569+
1570+
await htmx.timeout(5);
1571+
flash.textContent.should.equal('Saved');
1572+
flash.classList.contains('success').should.equal(true);
1573+
flash.classList.contains('error').should.equal(false);
1574+
1575+
await htmx.timeout(30);
1576+
flash.dataset.message.should.equal('');
1577+
flash.textContent.should.equal('');
1578+
} finally {
1579+
window.setTimeout = originalSetTimeout;
1580+
}
1581+
});
1582+
15251583
it('style scope helper accesses this.style', async function() {
15261584
playground().innerHTML = `
15271585
<div id="me" hx-on:click="style.color = 'red'">x</div>

test/tests/unit/__handleTriggerHeader.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,81 @@ describe('__handleTriggerHeader unit tests', function() {
141141
assert.equal(eventDetail.value, 42)
142142
})
143143

144+
it('works with hx-on listeners using from:body', function () {
145+
let root = createProcessedHTML(`
146+
<div>
147+
<button id="source"></button>
148+
<div id="listener" hx-on="myEvent from:body -> this.classList.add('updated')"></div>
149+
</div>
150+
`)
151+
let source = root.querySelector('#source')
152+
let listener = root.querySelector('#listener')
153+
154+
htmx.__handleTriggerHeader('myEvent', source)
155+
156+
assert.isTrue(listener.classList.contains('updated'))
157+
})
158+
159+
it('exposes scalar event detail as value in hx-on handlers', function () {
160+
let root = createProcessedHTML(`
161+
<div>
162+
<button id="source"></button>
163+
<div id="listener" hx-on="notification from:body -> this.dataset.value = value"></div>
164+
</div>
165+
`)
166+
let source = root.querySelector('#source')
167+
let listener = root.querySelector('#listener')
168+
169+
htmx.__handleTriggerHeader('{"notification":"Hello World"}', source)
170+
171+
assert.equal(listener.dataset.value, 'Hello World')
172+
})
173+
174+
it('exposes nested event detail properties in hx-on handlers', function () {
175+
let root = createProcessedHTML(`
176+
<div>
177+
<button id="source"></button>
178+
<div id="listener" hx-on="notification from:body -> this.dataset.level = level; this.textContent = message"></div>
179+
</div>
180+
`)
181+
let source = root.querySelector('#source')
182+
let listener = root.querySelector('#listener')
183+
184+
htmx.__handleTriggerHeader('{"notification":{"level":"info", "message":"Saved"}}', source)
185+
186+
assert.equal(listener.dataset.level, 'info')
187+
assert.equal(listener.textContent, 'Saved')
188+
})
189+
190+
it('fires multiple events with independent detail values for hx-on handlers', function () {
191+
let root = createProcessedHTML(`
192+
<div>
193+
<button id="source"></button>
194+
<div id="listener" hx-on="notification from:body -> this.textContent = value; refreshList from:body -> this.dataset.refresh = value"></div>
195+
</div>
196+
`)
197+
let source = root.querySelector('#source')
198+
let listener = root.querySelector('#listener')
199+
200+
htmx.__handleTriggerHeader('{"notification":"Saved", "refreshList":true}', source)
201+
202+
assert.equal(listener.textContent, 'Saved')
203+
assert.equal(listener.dataset.refresh, 'true')
204+
})
205+
206+
it('dispatches targeted events to the selected element', function () {
207+
let root = createProcessedHTML(`
208+
<div>
209+
<button id="source"></button>
210+
<div id="notifications" hx-on="notification -> this.textContent = message"></div>
211+
</div>
212+
`)
213+
let source = root.querySelector('#source')
214+
let notifications = root.querySelector('#notifications')
215+
216+
htmx.__handleTriggerHeader('{"notification":{"target":"#notifications", "message":"Saved"}}', source)
217+
218+
assert.equal(notifications.textContent, 'Saved')
219+
})
220+
144221
});

test/tests/unit/morph.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,37 @@ describe('Morph Swap Styles Tests', function() {
638638
assert.equal(container.querySelector('#result').textContent, 'Clicked!', 'htmx actions on new div should work');
639639
});
640640

641+
it('processes hx-* on tag-changing outerMorph when new root has a persistent child id (issue #3882)', async function() {
642+
// The bug: when outerMorph changes the root tag (div→form) AND the new root
643+
// contains a child with a persistent id, __morphChildren creates a placeholder
644+
// element instead of reusing the fragment node. newContent then holds the
645+
// detached fragment node, so process() bails on !isConnected and hx-* attrs
646+
// on the new root are never initialized.
647+
mockResponse('GET', '/test',
648+
'<form id="target" hx-post="/submit"><input id="same-child-id" name="q"></form>');
649+
const container = createProcessedHTML(
650+
'<div><div id="target"><input id="same-child-id" name="q"></div><div id="result"></div></div>');
651+
const childInput = container.querySelector('#same-child-id');
652+
653+
await htmx.ajax('GET', '/test', {target: '#target', swap: 'outerMorph'});
654+
655+
const newForm = container.querySelector('#target');
656+
assert.isNotNull(newForm, 'new form should be in the DOM');
657+
assert.equal(newForm.tagName, 'FORM', 'root tag should have changed to FORM');
658+
assert.equal(container.querySelector('#same-child-id'), childInput,
659+
'persistent child should be the same node');
660+
assert.equal(newForm.getAttribute('data-htmx-powered'), 'true',
661+
'new form root must be processed so hx-post works');
662+
663+
mockResponse('POST', '/submit', 'Submitted!');
664+
newForm.dispatchEvent(new Event('submit', {bubbles: true}));
665+
await waitForEvent('htmx:after:swap', 100);
666+
667+
assert.equal(container.querySelector('#result') ??
668+
container.querySelector('#target'), container.querySelector('#result') ??
669+
newForm, 'swap target should be reachable');
670+
});
671+
641672
it('does not stack hx-on:click handlers across innerMorph', async function() {
642673
window._calls = 0;
643674
mockResponse('GET', '/test', '<button id="btn" hx-on:click="window._calls++">v</button>');

www/src/components/PageHeader.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ const primaryAuthor = byline?.authors[0];
144144
h1 {
145145
flex: 1;
146146
min-width: 0;
147+
overflow-wrap: anywhere;
147148
font-family: var(--font-chicago);
148149
letter-spacing: var(--tracking-tightest);
149150
font-size: 1.5rem;

www/src/content/docs.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ All events follow a new pattern: `htmx:phase:action[:sub-action]`. Most error ev
357357
| `HX-Trigger-Name` | [`HX-Source`](/reference/headers/HX-Source) | removed ||
358358
| `HX-Prompt` | [`hx-prompt` extension](/extensions/hx-prompt) | restored by ext | Load the extension. |
359359
|| [`HX-Request-Type`](/reference/headers/HX-Request-Type) | new | `"full"` or `"partial"`. |
360-
|| [`Accept`](/reference/headers/Accept) | new | Now explicitly `text/html`. |
360+
|| `Accept` | new | Core requests send `text/html`; [`hx-sse`](/extensions/hx-sse#request-headers) adds `text/event-stream`. |
361361

362362
#### Response headers
363363

@@ -400,8 +400,8 @@ Still available: `htmx.ajax()`, `htmx.config`, `htmx.find()`, `htmx.findAll()`,
400400

401401
| Attribute | Purpose |
402402
|----------------------------------------------------|-----------------------------------------------------------------------|
403-
| [`hx-action`](/reference/attributes/hx-action) | Specify URL (use with [`hx-method`](/reference/attributes/hx-method)) |
404-
| [`hx-method`](/reference/attributes/hx-method) | Specify HTTP method |
403+
| [`hx-action`](/reference/attributes/hx-action) | Specify URL, with optional [`hx-method`](/reference/attributes/hx-method). Supports progressive enhancement via native `action`/`method` fallback |
404+
| [`hx-method`](/reference/attributes/hx-method) | Specify HTTP method (overrides native `method` and `formmethod`) |
405405
| [`hx-config`](/reference/attributes/hx-config) | Per-element request config (JSON or `key:value` syntax) |
406406
| [`hx-ignore`](/reference/attributes/hx-ignore) | Disable htmx processing (was `hx-disable`) |
407407
| [`hx-validate`](/reference/attributes/hx-validate) | Control form validation behavior |
@@ -1027,7 +1027,7 @@ Use different attributes for different operations:
10271027
<button hx-delete="/users/1">Delete User</button>
10281028
```
10291029

1030-
Each attribute combines the URL and HTTP method.
1030+
Each attribute combines the URL and HTTP method. Alternatively, use [`hx-action`](/reference/attributes/hx-action) and [`hx-method`](/reference/attributes/hx-method) to separate them — useful when the method is dynamic, or when you want progressive enhancement with native `action`/`method` fallback.
10311031

10321032
#### Common Patterns
10331033

www/src/content/extensions/02-hx-sse.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ The SSE extension hooks into htmx's request pipeline. When any htmx request rece
2323

2424
This means **any [`hx-get`](/reference/attributes/hx-get), [`hx-post`](/reference/attributes/hx-post), etc. that returns an SSE stream will just work**, no special attributes needed beyond loading the extension.
2525

26+
## Request Headers
27+
28+
With this extension loaded, outgoing htmx requests advertise SSE support:
29+
30+
```http
31+
Accept: text/html, text/event-stream
32+
```
33+
34+
Core htmx requests set `Accept: text/html`. The SSE extension adds `text/event-stream` so the server can return an SSE stream for any htmx request.
35+
2636
## `hx-sse:connect`
2737

2838
For persistent SSE connections (auto-connect on load, reconnect on failure), use `hx-sse:connect`:

0 commit comments

Comments
 (0)