Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions src/htmx.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,10 @@ export interface HtmxConfig {
metaCharacter?: string;
/**
* Whether an empty response body performs the main swap.
* - `true` — swap (clears target)
* - `false` — skip swap
* - `undefined` — swap unless response contained only `<hx-partial>` elements
* Overridable per element via the `swapEmpty` modifier on `hx-swap`.
* @default undefined
* @default true
*/
defaultSwapEmpty?: boolean;
defaultSwapEmpty: boolean;
/** Requires hx-live. */
live?: HtmxLiveConfig;
}
Expand Down
8 changes: 4 additions & 4 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ var htmx = (() => {
history: true,
mode: 'same-origin',
defaultSwap: "innerHTML",
defaultSwapEmpty: true,
defaultFocusScroll: false,
indicatorClass: "htmx-indicator",
requestClass: "htmx-request",
Expand Down Expand Up @@ -1268,7 +1269,7 @@ var htmx = (() => {
tasks.push(...oobTasks, ...partialTasks);

// Process main swap first
let mainSwap = this.__processMainSwap(ctx, fragment, partialTasks);
let mainSwap = this.__processMainSwap(ctx, fragment);
if (mainSwap) {
tasks.unshift(mainSwap);
}
Expand Down Expand Up @@ -1311,15 +1312,14 @@ var htmx = (() => {
}
}

__processMainSwap(ctx, fragment, partialTasks) {
__processMainSwap(ctx, fragment) {
// Create main task if needed
let swapSpec = this.__parseSwapSpec(ctx.swap || this.config.defaultSwap);
// skip main swap if fragment is empty after hx-partial removal but respect empty modifier
if (
swapSpec.style === 'delete' || // delete always runs regardless of content
fragment.childElementCount > 0 || // or fragment has elements
fragment.textContent.trim() || // or fragment has text
(swapSpec.swapEmpty ?? this.config.defaultSwapEmpty ?? !partialTasks.length) // swapEmpty:true/false overrides, default: allow if no partials
(swapSpec.swapEmpty ?? this.config.defaultSwapEmpty)
) {
if (ctx.select) {
let selected = fragment.querySelectorAll(ctx.select);
Expand Down
12 changes: 6 additions & 6 deletions test/tests/ext/hx-upsert.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe('hx-upsert extension', function() {

it('works with hx-partial', async function () {
mockResponse('GET', '/test', '<hx-partial hx-target="#list1" hx-swap="upsert"><div id="item-2">Two</div></hx-partial><hx-partial hx-target="#list2" hx-swap="upsert"><div id="item-b">B</div></hx-partial>')
let container = createProcessedHTML('<div hx-get="/test"><div id="list1"><div id="item-1">One</div></div><div id="list2"><div id="item-a">A</div></div></div>');
let container = createProcessedHTML('<div hx-get="/test" hx-swap="innerHTML swapEmpty:false"><div id="list1"><div id="item-1">One</div></div><div id="list2"><div id="item-a">A</div></div></div>');
container.click()
await htmx.timeout(20)
let list1 = container.querySelector('#list1')
Expand All @@ -189,7 +189,7 @@ describe('hx-upsert extension', function() {

it('hx-upsert tag with basic upsert', async function () {
mockResponse('GET', '/test', '<hx-upsert hx-target="#list"><div id="item-2">Two</div></hx-upsert>')
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="item-1">One</div></div></div>');
let container = createProcessedHTML('<div hx-get="/test" hx-swap="innerHTML swapEmpty:false"><div id="list"><div id="item-1">One</div></div></div>');
container.click()
await htmx.timeout(20)
let list = container.querySelector('#list')
Expand All @@ -200,7 +200,7 @@ describe('hx-upsert extension', function() {

it('hx-upsert tag with sort attribute', async function () {
mockResponse('GET', '/test', '<hx-upsert hx-target="#list" sort><div id="item-2">Two</div></hx-upsert>')
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="item-1">One</div><div id="item-3">Three</div></div></div>');
let container = createProcessedHTML('<div hx-get="/test" hx-swap="innerHTML swapEmpty:false"><div id="list"><div id="item-1">One</div><div id="item-3">Three</div></div></div>');
container.click()
await htmx.timeout(20)
let list = container.querySelector('#list')
Expand All @@ -211,7 +211,7 @@ describe('hx-upsert extension', function() {

it('hx-upsert tag with sort="desc"', async function () {
mockResponse('GET', '/test', '<hx-upsert hx-target="#list" sort="desc"><div id="item-2">Two</div></hx-upsert>')
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="item-3">Three</div><div id="item-1">One</div></div></div>');
let container = createProcessedHTML('<div hx-get="/test" hx-swap="innerHTML swapEmpty:false"><div id="list"><div id="item-3">Three</div><div id="item-1">One</div></div></div>');
container.click()
await htmx.timeout(20)
let list = container.querySelector('#list')
Expand All @@ -222,7 +222,7 @@ describe('hx-upsert extension', function() {

it('hx-upsert tag with key attribute', async function () {
mockResponse('GET', '/test', '<hx-upsert hx-target="#list" key="data-priority" sort><div id="task-2" data-priority="2">Medium</div></hx-upsert>')
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="task-3" data-priority="1">High</div><div id="task-1" data-priority="3">Low</div></div></div>');
let container = createProcessedHTML('<div hx-get="/test" hx-swap="innerHTML swapEmpty:false"><div id="list"><div id="task-3" data-priority="1">High</div><div id="task-1" data-priority="3">Low</div></div></div>');
container.click()
await htmx.timeout(20)
let list = container.querySelector('#list')
Expand All @@ -233,7 +233,7 @@ describe('hx-upsert extension', function() {

it('hx-upsert tag with prepend attribute', async function () {
mockResponse('GET', '/test', '<hx-upsert hx-target="#list" prepend><div>No Key</div></hx-upsert>')
let container = createProcessedHTML('<div hx-get="/test"><div id="list"><div id="item-1">One</div></div></div>');
let container = createProcessedHTML('<div hx-get="/test" hx-swap="innerHTML swapEmpty:false"><div id="list"><div id="item-1">One</div></div></div>');
container.click()
await htmx.timeout(20)
let list = container.querySelector('#list')
Expand Down
4 changes: 2 additions & 2 deletions test/tests/ext/hx-ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ describe('hx-ws WebSocket extension', function() {

it('respects hx-swap attribute on partial', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/test" hx-target="#list">
<div hx-ws:connect="/ws/test" hx-target="#list" hx-swap="innerHTML swapEmpty:false">
<div id="list"><p>Item 1</p></div>
</div>
`);
Expand Down Expand Up @@ -1603,7 +1603,7 @@ describe('hx-ws WebSocket extension', function() {

it('handles live notifications pattern', async function() {
let container = createProcessedHTML(`
<div hx-ws:connect="/ws/notifications" hx-target="#notifications">
<div hx-ws:connect="/ws/notifications" hx-target="#notifications" hx-swap="innerHTML swapEmpty:false">
<div id="notifications"></div>
</div>
`);
Expand Down
12 changes: 6 additions & 6 deletions test/tests/unit/swap.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ describe('swap() unit tests', function() {

it('swaps partial with custom swap style', async function () {
createProcessedHTML("<div id='d1'>Existing</div>")
await htmx.swap({"target":"#test-playground", "text":"<hx-partial hx-target='#d1' hx-swap='beforeend'>Partial</hx-partial>"})
await htmx.swap({"target":"#test-playground", "swap":"innerHTML swapEmpty:false", "text":"<hx-partial hx-target='#d1' hx-swap='beforeend'>Partial</hx-partial>"})
find('#d1').innerText.should.equal("ExistingPartial");
})

Expand Down Expand Up @@ -458,23 +458,23 @@ describe('swap() unit tests', function() {
find('#target_oob').textContent.should.equal("OOB swap!");
})

it('swaps only partial target when response contains only partial', async function () {
it('swaps empty main target when response contains only partial', async function () {
createProcessedHTML("<div id='target'>Original</div><div id='target_oob'>OOB Original</div>")
await htmx.swap({
"target":"#target",
"text":"<hx-partial hx-target='#target_oob' hx-swap='innerHTML'><div>OOB Updated</div></hx-partial>"
})
find('#target').textContent.should.equal("Original");
find('#target').textContent.should.equal("");
find('#target_oob').textContent.should.equal("OOB Updated");
})

it('does not swap main target when only whitespace and partial present', async function () {
it('swaps empty main target when only whitespace and partial present', async function () {
createProcessedHTML("<div id='target'>Original</div><div id='target_oob'>OOB</div>")
await htmx.swap({
"target":"#target",
"text":"\n <hx-partial hx-target='#target_oob' hx-swap='innerHTML'><div>OOB swap!</div></hx-partial> \n"
})
find('#target').textContent.should.equal("Original");
find('#target').textContent.trim().should.equal("");
find('#target_oob').textContent.should.equal("OOB swap!");
})

Expand Down Expand Up @@ -685,7 +685,7 @@ describe('swap() unit tests', function() {

it('swaps partial to all elements matching a class selector', async function () {
createProcessedHTML("<div class='target'>A</div><div class='target'>B</div>")
await htmx.swap({"target":"#test-playground", "text":"<hx-partial hx-target='.target' hx-swap='innerHTML'>Updated</hx-partial>"})
await htmx.swap({"target":"#test-playground", "swap":"innerHTML swapEmpty:false", "text":"<hx-partial hx-target='.target' hx-swap='innerHTML'>Updated</hx-partial>"})
playground().querySelectorAll('.target').forEach(el => el.innerText.should.equal('Updated'))
})

Expand Down
11 changes: 4 additions & 7 deletions www/src/content/docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1845,13 +1845,10 @@ You can use the equivalent <code>&lt;template&gt;</code> form: <code>&lt;templat

#### Empty Response Behaviour

When a response contains only `<hx-partial>` elements and no main content, htmx will **not** perform the main swap.
This is the opposite default to [`hx-swap-oob`](/reference/attributes/hx-swap-oob): with partials, an
all-partial response signals intent — the server is explicitly routing multiple targeted updates and there is no main
content to swap.
When a response contains only `<hx-partial>` elements, htmx performs the main swap with an empty fragment. This matches [`hx-swap-oob`](/reference/attributes/hx-swap-oob).

```html
<!-- Server returns only partials — main target is left untouched -->
<!-- Server returns only partials -->
<hx-partial hx-target="#notifications">
<span class="badge">5</span>
</hx-partial>
Expand All @@ -1860,10 +1857,10 @@ content to swap.
</hx-partial>
```

If you also want the main target cleared, add `swapEmpty:true` to `hx-swap` on the triggering element:
To leave the main target unchanged, add `swapEmpty:false` to `hx-swap` on the triggering element:

```html
<button hx-post="/submit" hx-swap="outerHTML swapEmpty:true">Submit</button>
<button hx-post="/submit" hx-swap="outerHTML swapEmpty:false">Submit</button>
```

Or set the global default via [`htmx.config.defaultSwapEmpty`](/reference/config/htmx-config-defaultSwapEmpty).
Expand Down
4 changes: 1 addition & 3 deletions www/src/content/reference/01-attributes/13-hx-swap-oob.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,7 @@ If you want to prevent the empty main swap, use the [`swapEmpty`](/reference/att

Or set the global default via [`htmx.config.defaultSwapEmpty`](/reference/config/htmx-config-defaultSwapEmpty).

[`<hx-partial>`](/reference/tags/hx-partial) uses the opposite default. Partial-only responses skip the empty main swap.

A partial-only response explicitly routes targeted updates, so htmx assumes no main swap is needed. Set `swapEmpty:true` to run it.
[`<hx-partial>`](/reference/tags/hx-partial) follows the same `swapEmpty` behavior.

## See Also

Expand Down
2 changes: 1 addition & 1 deletion www/src/content/reference/04-config/01-htmx-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ htmx.config.defaultTimeout = 5000;
| [`history`](/reference/config/htmx-config-history) | `true` | Enable history support |
| [`mode`](/reference/config/htmx-config-mode) | `"same-origin"` | Request mode for `fetch()` |
| [`defaultSwap`](/reference/config/htmx-config-defaultSwap) | `"innerHTML"` | Default swap style |
| [`defaultSwapEmpty`](/reference/config/htmx-config-defaultSwapEmpty) | `undefined` | Swap empty main content unless an `<hx-partial>` was extracted |
| [`defaultSwapEmpty`](/reference/config/htmx-config-defaultSwapEmpty) | `true` | Swap empty main content |
| [`defaultFocusScroll`](/reference/config/htmx-config-defaultFocusScroll) | `false` | Scroll to a focused element after swapping |
| [`defaultSettleDelay`](/reference/config/htmx-config-defaultSettleDelay) | `1` | Delay before settling in milliseconds |
| [`indicatorClass`](/reference/config/htmx-config-indicatorClass) | `"htmx-indicator"` | CSS class for indicators |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The `htmx.config.defaultSwapEmpty` option controls the main swap when the respon

Override it per element with [`swapEmpty`](/reference/attributes/hx-swap#swapempty).

**Default:** unset. When unset, htmx performs the main swap on an empty response except when the response contained only `<hx-partial>` elements.
**Default:** `true`

## Values

Expand Down
2 changes: 1 addition & 1 deletion www/src/content/reference/06-tags/01-hx-partial.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Either `hx-target` or `id` is required. If both are present, `hx-target` takes p

## Responses Without Main Content

When a response contains only `<hx-partial>` tags (no main content), the main target is left untouched. See [Multi-Target Updates](/docs#choosing-between-them) for details.
When a response contains only `<hx-partial>` tags, htmx performs the normal swap with an empty fragment. Set [`swapEmpty:false`](/reference/attributes/hx-swap#swapempty) to leave the normal target unchanged.

## Alternative Syntax

Expand Down
Loading